all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm
@ 2025-09-24 14:51 Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical Shannon Sterz
                   ` (7 more replies)
  0 siblings, 8 replies; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

this series adds a ui for adding, editing, removing and regenarating api
tokens to proxmox-yew-comp and integrates it into
proxmox-datacenter-manager. it also allows for adding acl entries for
them in the permissions panel.

sending this as an rfc as this series also factors out the token related
api endpoints into proxmox-access-control and i would like some feedback
on the approach there. i didn't want to add even more methods to the
AccessControlConfig here in order to not clutter it too much.

proxmox:

Shannon Sterz (3):
  access-control: refactor api module to be more hirachical
  access-control: move `ApiTokenSecret` to types module
  access-control: add api endpoints for handling tokens

 proxmox-access-control/Cargo.toml             |   2 +
 .../src/{api.rs => api/acl.rs}                |   0
 proxmox-access-control/src/api/mod.rs         |   8 +
 proxmox-access-control/src/api/tokens.rs      | 306 ++++++++++++++++++
 proxmox-access-control/src/token_shadow.rs    |   9 -
 proxmox-access-control/src/types.rs           |  29 +-
 6 files changed, 344 insertions(+), 10 deletions(-)
 rename proxmox-access-control/src/{api.rs => api/acl.rs} (100%)
 create mode 100644 proxmox-access-control/src/api/mod.rs
 create mode 100644 proxmox-access-control/src/api/tokens.rs


proxmox-yew-comp:

Shannon Sterz (2):
  utils/user_panel: factor out epoch_to_input_value helper
  token_panel: implement a token panel

 src/lib.rs         |   3 +
 src/token_panel.rs | 569 +++++++++++++++++++++++++++++++++++++++++++++
 src/user_panel.rs  |  21 +-
 src/utils.rs       |  19 ++
 4 files changed, 592 insertions(+), 20 deletions(-)
 create mode 100644 src/token_panel.rs


proxmox-datacenter-manager:

Shannon Sterz (3):
  ui: add a token panel and a token acl edit menu in the permissions
    panel
  server: access: use token endpoints from proxmox-access-control
  server: clean up acl tree entries and api tokens when deleting users

 server/src/api/access/users.rs | 388 ++++++---------------------------
 ui/src/configuration/mod.rs    |  33 ++-
 2 files changed, 95 insertions(+), 326 deletions(-)


Summary over all repositories:
  12 files changed, 1031 insertions(+), 356 deletions(-)

--
Generated by git-murpp 0.8.1


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-26  8:26   ` Dominik Csapak
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 2/3] access-control: move `ApiTokenSecret` to types module Shannon Sterz
                   ` (6 subsequent siblings)
  7 siblings, 1 reply; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

this is mainly in preparation of factoring out more api endpoints
related to access control. the refactoring is done in a way where
users should not need to adapt.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 proxmox-access-control/src/{api.rs => api/acl.rs} | 0
 proxmox-access-control/src/api/mod.rs             | 8 ++++++++
 2 files changed, 8 insertions(+)
 rename proxmox-access-control/src/{api.rs => api/acl.rs} (100%)
 create mode 100644 proxmox-access-control/src/api/mod.rs

diff --git a/proxmox-access-control/src/api.rs b/proxmox-access-control/src/api/acl.rs
similarity index 100%
rename from proxmox-access-control/src/api.rs
rename to proxmox-access-control/src/api/acl.rs
diff --git a/proxmox-access-control/src/api/mod.rs b/proxmox-access-control/src/api/mod.rs
new file mode 100644
index 00000000..59dc32e2
--- /dev/null
+++ b/proxmox-access-control/src/api/mod.rs
@@ -0,0 +1,8 @@
+mod tokens;
+pub use tokens::{
+    API_METHOD_DELETE_TOKEN, API_METHOD_GENERATE_TOKEN, API_METHOD_LIST_TOKENS,
+    API_METHOD_READ_TOKEN, API_METHOD_UPDATE_TOKEN,
+};
+
+mod acl;
+pub use acl::{ACL_ROUTER, API_METHOD_READ_ACL, API_METHOD_UPDATE_ACL, ROLE_ROUTER};
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH proxmox 2/3] access-control: move `ApiTokenSecret` to types module
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-26  9:14   ` Fabian Grünbichler
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 3/3] access-control: add api endpoints for handling tokens Shannon Sterz
                   ` (5 subsequent siblings)
  7 siblings, 1 reply; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

this is technically a breaking change, but so far this type has no
users

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 proxmox-access-control/src/token_shadow.rs |  9 ---------
 proxmox-access-control/src/types.rs        | 12 +++++++++++-
 2 files changed, 11 insertions(+), 10 deletions(-)

diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
index 60b71ac9..46397edb 100644
--- a/proxmox-access-control/src/token_shadow.rs
+++ b/proxmox-access-control/src/token_shadow.rs
@@ -1,7 +1,6 @@
 use std::collections::HashMap;
 
 use anyhow::{bail, format_err, Error};
-use serde::{Deserialize, Serialize};
 use serde_json::{from_value, Value};
 
 use proxmox_auth_api::types::Authid;
@@ -9,14 +8,6 @@ use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
 
 use crate::init::{token_shadow, token_shadow_lock};
 
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "kebab-case")]
-/// ApiToken id / secret pair
-pub struct ApiTokenSecret {
-    pub tokenid: Authid,
-    pub secret: String,
-}
-
 // Get exclusive lock
 fn lock_config() -> Result<ApiLockGuard, Error> {
     open_api_lockfile(token_shadow_lock(), None, true)
diff --git a/proxmox-access-control/src/types.rs b/proxmox-access-control/src/types.rs
index ea64d333..a146700d 100644
--- a/proxmox-access-control/src/types.rs
+++ b/proxmox-access-control/src/types.rs
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
 
 use const_format::concatcp;
 
-use proxmox_auth_api::types::{Authid, Userid, PROXMOX_TOKEN_ID_SCHEMA};
+use proxmox_auth_api::types::{Authid, Tokenname, Userid, PROXMOX_TOKEN_ID_SCHEMA};
 use proxmox_schema::{
     api,
     api_types::{COMMENT_SCHEMA, SAFE_ID_REGEX_STR, SINGLE_LINE_COMMENT_FORMAT},
@@ -147,6 +147,16 @@ impl ApiToken {
     }
 }
 
+#[api]
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+/// ApiToken id / secret pair
+pub struct ApiTokenSecret {
+    pub tokenid: Authid,
+    /// The secret associated with the token.
+    pub secret: String,
+}
+
 #[api(
     properties: {
         userid: {
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH proxmox 3/3] access-control: add api endpoints for handling tokens
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 2/3] access-control: move `ApiTokenSecret` to types module Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-26  9:14   ` Fabian Grünbichler
  2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 1/2] utils/user_panel: factor out epoch_to_input_value helper Shannon Sterz
                   ` (4 subsequent siblings)
  7 siblings, 1 reply; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

by adding most of the logic of token creation, updating and deleting
here, users can more easily add api tokens to their api. this requires
the api feature.

users of these api endpoints are expected to set the `access`
parameters according to their needs. by default only super users can
access any of them.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 proxmox-access-control/Cargo.toml        |   2 +
 proxmox-access-control/src/api/tokens.rs | 306 +++++++++++++++++++++++
 proxmox-access-control/src/types.rs      |  17 ++
 3 files changed, 325 insertions(+)
 create mode 100644 proxmox-access-control/src/api/tokens.rs

diff --git a/proxmox-access-control/Cargo.toml b/proxmox-access-control/Cargo.toml
index dbcbeced..274c08a7 100644
--- a/proxmox-access-control/Cargo.toml
+++ b/proxmox-access-control/Cargo.toml
@@ -31,12 +31,14 @@ proxmox-section-config = { workspace = true, optional = true }
 proxmox-shared-memory = { workspace = true, optional = true }
 proxmox-sys = { workspace = true, features = [ "crypt" ], optional = true }
 proxmox-time = { workspace = true }
+proxmox-uuid = { workspace = true, optional = true }
 
 [features]
 default = []
 api = [
     "impl",
     "dep:hex",
+    "dep:proxmox-uuid"
 ]
 impl = [
     "dep:nix",
diff --git a/proxmox-access-control/src/api/tokens.rs b/proxmox-access-control/src/api/tokens.rs
new file mode 100644
index 00000000..1bde36c8
--- /dev/null
+++ b/proxmox-access-control/src/api/tokens.rs
@@ -0,0 +1,306 @@
+//! User Management
+
+use anyhow::{bail, Error};
+use proxmox_config_digest::ConfigDigest;
+use proxmox_schema::api_types::COMMENT_SCHEMA;
+
+use proxmox_auth_api::types::{Authid, Tokenname, Userid};
+use proxmox_router::{ApiMethod, RpcEnvironment};
+use proxmox_schema::api;
+
+use crate::token_shadow::{self};
+use crate::types::{
+    ApiToken, ApiTokenSecret, TokenApiEntry, ENABLE_USER_SCHEMA, EXPIRE_USER_SCHEMA,
+};
+
+#[api(
+    input: {
+        properties: {
+            userid: {
+                type: Userid,
+            },
+            "token-name": {
+                type: Tokenname,
+            },
+        },
+    },
+    returns: { type: ApiToken },
+)]
+/// Read user's API token metadata
+pub fn read_token(
+    userid: Userid,
+    token_name: Tokenname,
+    _info: &ApiMethod,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<ApiToken, Error> {
+    let (config, digest) = crate::user::config()?;
+
+    let tokenid = Authid::from((userid, Some(token_name)));
+
+    rpcenv["digest"] = hex::encode(digest).into();
+    config.lookup("token", &tokenid.to_string())
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            userid: {
+                type: Userid,
+            },
+            "token-name": {
+                type: Tokenname,
+            },
+            comment: {
+                optional: true,
+                schema: COMMENT_SCHEMA,
+            },
+            enable: {
+                schema: ENABLE_USER_SCHEMA,
+                optional: true,
+            },
+            expire: {
+                schema: EXPIRE_USER_SCHEMA,
+                optional: true,
+            },
+            digest: {
+                optional: true,
+                type: ConfigDigest,
+            },
+        },
+    },
+    returns: { type: ApiTokenSecret },
+)]
+/// Generate a new API token with given metadata
+pub fn generate_token(
+    userid: Userid,
+    token_name: Tokenname,
+    comment: Option<String>,
+    enable: Option<bool>,
+    expire: Option<i64>,
+    digest: Option<ConfigDigest>,
+) -> Result<ApiTokenSecret, Error> {
+    let _lock = crate::user::lock_config()?;
+    let (mut config, config_digest) = crate::user::config()?;
+
+    config_digest.detect_modification(digest.as_ref())?;
+
+    let tokenid = Authid::from((userid.clone(), Some(token_name.clone())));
+    let tokenid_string = tokenid.to_string();
+
+    if config.sections.contains_key(&tokenid_string) {
+        bail!(
+            "token '{}' for user '{userid}' already exists.",
+            token_name.as_str(),
+        );
+    }
+
+    let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
+    token_shadow::set_secret(&tokenid, &secret)?;
+
+    let token = ApiToken {
+        tokenid: tokenid.clone(),
+        comment,
+        enable,
+        expire,
+    };
+
+    config.set_data(&tokenid_string, "token", &token)?;
+
+    crate::user::save_config(&config)?;
+
+    Ok(ApiTokenSecret { tokenid, secret })
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            userid: {
+                type: Userid,
+            },
+            "token-name": {
+                type: Tokenname,
+            },
+            comment: {
+                optional: true,
+                schema: COMMENT_SCHEMA,
+            },
+            enable: {
+                schema: ENABLE_USER_SCHEMA,
+                optional: true,
+            },
+            expire: {
+                schema: EXPIRE_USER_SCHEMA,
+                optional: true,
+            },
+            regenerate: {
+                description: "Whether the token should be regenerated or not.",
+                optional: true,
+                type: bool,
+                default: false,
+            },
+            digest: {
+                optional: true,
+                type: ConfigDigest,
+            },
+        },
+    },
+    returns: {
+        type: ApiTokenSecret,
+        optional: true
+    }
+)]
+/// Update user's API token metadata. If regenerate is set to true, the token and it's new secret
+/// will be returned.
+pub fn update_token(
+    userid: Userid,
+    token_name: Tokenname,
+    comment: Option<String>,
+    enable: Option<bool>,
+    expire: Option<i64>,
+    regenerate: bool,
+    digest: Option<ConfigDigest>,
+) -> Result<Option<ApiTokenSecret>, Error> {
+    let _lock = crate::user::lock_config()?;
+
+    let (mut config, config_digest) = crate::user::config()?;
+    config_digest.detect_modification(digest.as_ref())?;
+
+    let tokenid = Authid::from((userid, Some(token_name)));
+    let tokenid_string = tokenid.to_string();
+
+    let mut data: ApiToken = config.lookup("token", &tokenid_string)?;
+
+    if let Some(comment) = comment {
+        let comment = comment.trim().to_string();
+        if comment.is_empty() {
+            data.comment = None;
+        } else {
+            data.comment = Some(comment);
+        }
+    }
+
+    if let Some(enable) = enable {
+        data.enable = if enable { None } else { Some(false) };
+    }
+
+    if let Some(expire) = expire {
+        data.expire = if expire > 0 { Some(expire) } else { None };
+    }
+
+    let new_secret = if regenerate {
+        let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
+        crate::token_shadow::set_secret(&tokenid, &secret)?;
+
+        Some(ApiTokenSecret { tokenid, secret })
+    } else {
+        None
+    };
+
+    config.set_data(&tokenid_string, "token", &data)?;
+
+    crate::user::save_config(&config)?;
+
+    Ok(new_secret)
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            userid: {
+                type: Userid,
+            },
+            "token-name": {
+                type: Tokenname,
+            },
+            digest: {
+                optional: true,
+                type: ConfigDigest,
+            },
+        },
+    },
+)]
+/// Delete a user's API token
+pub fn delete_token(
+    userid: Userid,
+    token_name: Tokenname,
+    digest: Option<ConfigDigest>,
+) -> Result<(), Error> {
+    let _lock = crate::user::lock_config()?;
+
+    let (mut config, config_digest) = crate::user::config()?;
+    config_digest.detect_modification(digest.as_ref())?;
+
+    let tokenid = Authid::from((userid.clone(), Some(token_name.clone())));
+    let tokenid_string = tokenid.to_string();
+
+    match config.sections.get(&tokenid_string) {
+        Some(_) => {
+            config.sections.remove(&tokenid_string);
+        }
+        None => bail!(
+            "token '{}' of user '{userid}' does not exist.",
+            token_name.as_str(),
+        ),
+    }
+
+    token_shadow::delete_secret(&tokenid)?;
+
+    crate::user::save_config(&config)?;
+
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            userid: {
+                type: Userid,
+            },
+        },
+    },
+    returns: {
+        description: "List user's API tokens (with config digest).",
+        type: Array,
+        items: { type: TokenApiEntry },
+    },
+)]
+/// List user's API tokens
+pub fn list_tokens(
+    userid: Userid,
+    _info: &ApiMethod,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<TokenApiEntry>, Error> {
+    let (config, digest) = crate::user::config()?;
+
+    let list: Vec<ApiToken> = config.convert_to_typed_array("token")?;
+
+    rpcenv["digest"] = hex::encode(digest).into();
+
+    let filter_by_owner = |token: ApiToken| {
+        if token.tokenid.is_token() && token.tokenid.user() == &userid {
+            let token_name = token.tokenid.tokenname().unwrap().to_owned();
+            Some(TokenApiEntry { token_name, token })
+        } else {
+            None
+        }
+    };
+
+    let res = list.into_iter().filter_map(filter_by_owner).collect();
+
+    Ok(res)
+}
+
+/*const TOKEN_ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_READ_TOKEN)
+    .put(&API_METHOD_UPDATE_TOKEN)
+    .post(&API_METHOD_GENERATE_TOKEN)
+    .delete(&API_METHOD_DELETE_TOKEN);
+
+const TOKEN_ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_TOKENS)
+    .match_all("token-name", &TOKEN_ITEM_ROUTER);
+
+const USER_SUBDIRS: SubdirMap = &[("token", &TOKEN_ROUTER)];*/
diff --git a/proxmox-access-control/src/types.rs b/proxmox-access-control/src/types.rs
index a146700d..2771f3b8 100644
--- a/proxmox-access-control/src/types.rs
+++ b/proxmox-access-control/src/types.rs
@@ -154,9 +154,26 @@ impl ApiToken {
 pub struct ApiTokenSecret {
     pub tokenid: Authid,
     /// The secret associated with the token.
+    // rename to `value` as that is what it is called in the api
+    #[serde(rename = "value")]
     pub secret: String,
 }
 
+#[api(
+    properties: {
+        token: { type: ApiToken },
+    }
+)]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// A Token Entry that contains the token-name
+pub struct TokenApiEntry {
+    /// The Token name
+    pub token_name: Tokenname,
+    #[serde(flatten)]
+    pub token: ApiToken,
+}
+
 #[api(
     properties: {
         userid: {
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH yew-comp 1/2] utils/user_panel: factor out epoch_to_input_value helper
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
                   ` (2 preceding siblings ...)
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 3/3] access-control: add api endpoints for handling tokens Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel Shannon Sterz
                   ` (3 subsequent siblings)
  7 siblings, 0 replies; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

being able to convert an epoch to a timestamp string that is
compatible with input fields is generally useful. so move it to the
utils module and expose it publicly.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 src/user_panel.rs | 21 +--------------------
 src/utils.rs      | 19 +++++++++++++++++++
 2 files changed, 20 insertions(+), 20 deletions(-)

diff --git a/src/user_panel.rs b/src/user_panel.rs
index 14c43c4..6e55b63 100644
--- a/src/user_panel.rs
+++ b/src/user_panel.rs
@@ -20,7 +20,7 @@ use pwt::widget::form::{delete_empty_values, Checkbox, Field, FormContext, Input
 use pwt::widget::{Button, Dialog, InputPanel, Toolbar};
 
 use crate::percent_encoding::percent_encode_component;
-use crate::utils::render_epoch_short;
+use crate::utils::{epoch_to_input_value, render_epoch_short};
 use crate::{
     EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
     PermissionPanel, RealmSelector, SchemaValidation,
@@ -537,22 +537,3 @@ fn edit_user_input_panel(_form_ctx: &FormContext) -> Html {
         .with_large_field(tr!("Comment"), Field::new().name("comment").autofocus(true))
         .into()
 }
-
-fn epoch_to_input_value(epoch: i64) -> String {
-    let date = js_sys::Date::new_0();
-    date.set_time((epoch * 1000) as f64);
-
-    if date.get_date() == 0 {
-        // invalid data (clear field creates this)
-        String::new()
-    } else {
-        format!(
-            "{:04}-{:02}-{:02}T{:02}:{:02}",
-            date.get_full_year(),
-            date.get_month() + 1,
-            date.get_date(),
-            date.get_hours(),
-            date.get_minutes(),
-        )
-    }
-}
diff --git a/src/utils.rs b/src/utils.rs
index 544ed76..79b7ad7 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -132,6 +132,25 @@ pub fn render_url(url: &str) -> Html {
     }
 }
 
+pub fn epoch_to_input_value(epoch: i64) -> String {
+    let date = js_sys::Date::new_0();
+    date.set_time((epoch * 1000) as f64);
+
+    if date.get_date() == 0 {
+        // invalid data (clear field creates this)
+        String::new()
+    } else {
+        format!(
+            "{:04}-{:02}-{:02}T{:02}:{:02}",
+            date.get_full_year(),
+            date.get_month() + 1,
+            date.get_date(),
+            date.get_hours(),
+            date.get_minutes(),
+        )
+    }
+}
+
 // todo: we want to use Fn(&str, Option<&str>),
 #[allow(clippy::type_complexity)]
 static TASK_DESCR_TABLE: Mutex<
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
                   ` (3 preceding siblings ...)
  2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 1/2] utils/user_panel: factor out epoch_to_input_value helper Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-26  8:50   ` Dominik Csapak
  2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 1/3] ui: add a token panel and a token acl edit menu in the permissions panel Shannon Sterz
                   ` (2 subsequent siblings)
  7 siblings, 1 reply; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

this is analogous to the user panel. the token panel allows adding,
editing and remove api tokens. existing tokens can also be
re-generated and their permissions can be displayed.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
note that this could probably use several refinments:

- this could use the Clipboard api once an appropriate web_sys version is
  packaged instead of NodeRef
- use a base_url instead of hardcoding everything.

but i wanted some early feedback for now

 src/lib.rs         |   3 +
 src/token_panel.rs | 569 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 572 insertions(+)
 create mode 100644 src/token_panel.rs

diff --git a/src/lib.rs b/src/lib.rs
index 492326a..b6fcd81 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -203,6 +203,9 @@ pub use wizard::{PwtWizard, Wizard, WizardPageRenderInfo};
 mod user_panel;
 pub use user_panel::UserPanel;

+mod token_panel;
+pub use token_panel::TokenPanel;
+
 pub mod utils;

 mod xtermjs;
diff --git a/src/token_panel.rs b/src/token_panel.rs
new file mode 100644
index 0000000..26e3575
--- /dev/null
+++ b/src/token_panel.rs
@@ -0,0 +1,569 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use anyhow::Error;
+use proxmox_access_control::types::{ApiToken, UserWithTokens};
+use proxmox_auth_api::types::Authid;
+use proxmox_client::ApiResponseData;
+use serde_json::{json, Value};
+
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use pwt::prelude::*;
+use pwt::state::{Selection, Store};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::form::{Checkbox, DisplayField, Field, FormContext, InputType};
+use pwt::widget::{Button, Column, Container, Dialog, InputPanel, Toolbar};
+
+use crate::percent_encoding::percent_encode_component;
+use crate::utils::{copy_to_clipboard, epoch_to_input_value, render_boolean, render_epoch_short};
+use crate::{
+    AuthidSelector, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
+    LoadableComponentLink, LoadableComponentMaster, PermissionPanel,
+};
+
+async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
+    let url = "/access/users/?include_tokens=1";
+    let users: Vec<UserWithTokens> = crate::http_get(url, None).await?;
+    let mut list: Vec<ApiToken> = Vec::new();
+
+    for user in users.into_iter() {
+        list.extend(user.tokens)
+    }
+
+    Ok(list)
+}
+
+async fn create_token(
+    form_ctx: FormContext,
+    link: LoadableComponentLink<ProxmoxTokenView>,
+) -> Result<(), Error> {
+    let mut data = form_ctx.get_submit_data();
+
+    let userid = form_ctx.read().get_field_text("userid");
+    let tokenname = form_ctx.read().get_field_text("tokenname");
+
+    let url = token_api_url(&userid, &tokenname);
+
+    let expire = form_ctx.read().get_field_text("expire");
+
+    if let Ok(epoch) = proxmox_time::parse_rfc3339(&expire) {
+        data["expire"] = epoch.into();
+    }
+
+    let res: Value = crate::http_post(url, Some(data)).await?;
+
+    link.change_view(Some(ViewState::DisplayTokenSecret(res)));
+
+    Ok(())
+}
+
+async fn load_token(tokenid: Key) -> Result<ApiResponseData<Value>, Error> {
+    let tokenid: Authid = tokenid.parse().unwrap();
+
+    let userid = tokenid.user().to_string();
+    let tokenname = tokenid.tokenname().map(|n| n.as_str().to_owned()).unwrap();
+
+    let url = token_api_url(&userid, &tokenname);
+
+    let mut resp: ApiResponseData<Value> = crate::http_get_full(&url, None).await?;
+
+    if let Value::Number(number) = &resp.data["expire"] {
+        if let Some(epoch) = number.as_f64() {
+            resp.data["expire"] = Value::String(epoch_to_input_value(epoch as i64));
+        }
+    }
+    resp.data["userid"] = userid.into();
+    resp.data["tokenname"] = tokenname.into();
+
+    Ok(resp)
+}
+
+async fn update_token(form_ctx: FormContext) -> Result<(), Error> {
+    let mut data = form_ctx.get_submit_data();
+
+    let userid = form_ctx.read().get_field_text("userid");
+    let tokenname = form_ctx.read().get_field_text("tokenname");
+
+    let url = token_api_url(&userid, &tokenname);
+
+    let expire = form_ctx.read().get_field_text("expire");
+    if let Ok(epoch) = proxmox_time::parse_rfc3339(&expire) {
+        data["expire"] = epoch.into();
+    } else {
+        data["expire"] = 0.into();
+    }
+
+    crate::http_put(url, Some(data)).await
+}
+
+#[derive(PartialEq, Properties)]
+pub struct TokenPanel {}
+
+impl TokenPanel {
+    pub fn new() -> Self {
+        yew::props!(Self {})
+    }
+}
+
+impl Default for TokenPanel {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[derive(Clone, PartialEq)]
+enum ViewState {
+    AddToken,
+    EditToken,
+    ShowPermissions,
+    DisplayTokenSecret(Value),
+}
+
+enum Msg {
+    Refresh,
+    Remove,
+    Regenerate,
+}
+
+struct ProxmoxTokenView {
+    selection: Selection,
+    store: Store<ApiToken>,
+    secret_node_ref: NodeRef,
+    columns: Rc<Vec<DataTableHeader<ApiToken>>>,
+}
+
+fn token_api_url(user: &str, tokenname: &str) -> String {
+    format!(
+        "/access/users/{}/token/{}",
+        percent_encode_component(user),
+        percent_encode_component(tokenname),
+    )
+}
+
+impl LoadableComponent for ProxmoxTokenView {
+    type Properties = TokenPanel;
+    type Message = Msg;
+    type ViewState = ViewState;
+
+    fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+        let link = ctx.link();
+        link.repeated_load(5000);
+
+        let selection = Selection::new().on_select(link.callback(|_| Msg::Refresh));
+        let store =
+            Store::with_extract_key(|record: &ApiToken| Key::from(record.tokenid.to_string()));
+
+        Self {
+            selection,
+            store,
+            secret_node_ref: NodeRef::default(),
+            columns: columns(),
+        }
+    }
+
+    fn load(
+        &self,
+        _ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+        let store = self.store.clone();
+        Box::pin(async move {
+            let data = load_api_tokens().await?;
+            store.write().set_data(data);
+            Ok(())
+        })
+    }
+
+    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+        let selected_id = self.selection.selected_key().map(|k| k.to_string());
+        let disabled = selected_id.is_none();
+        let link = ctx.link();
+
+        let toolbar = Toolbar::new()
+            .class("pwt-w-100")
+            .class("pwt-overflow-hidden")
+            .class("pwt-border-bottom")
+            .border_top(true)
+            .with_child(
+                Button::new(tr!("Add"))
+                    .on_activate(link.change_view_callback(|_| Some(ViewState::AddToken))),
+            )
+            .with_spacer()
+            .with_child(
+                Button::new(tr!("Edit"))
+                    .disabled(disabled)
+                    .on_activate(link.change_view_callback(|_| Some(ViewState::EditToken))),
+            )
+            .with_child(
+                Button::new(tr!("Remove"))
+                    .disabled(disabled)
+                    .on_activate(link.callback(|_| Msg::Remove)),
+            )
+            .with_spacer()
+            .with_child(
+                ConfirmButton::new(tr!("Regenerate Secret"))
+                    .confirm_message(tr!("
+                        Regenerate the secret of the selected API token? All current use-sites will loose access!"
+                    ))
+                    .disabled(disabled)
+                    .on_activate(link.callback(|_| Msg::Regenerate))
+            )
+            .with_spacer()
+            .with_child(
+                Button::new(tr!("Show Permissions"))
+                    .disabled(disabled)
+                    .on_activate(link.change_view_callback(|_| Some(ViewState::ShowPermissions))),
+            );
+
+        Some(toolbar.into())
+    }
+
+    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Msg::Refresh => true,
+            Msg::Remove => {
+                let record = match self.selection.selected_key() {
+                    Some(selected_key) => self.store.read().lookup_record(&selected_key).cloned(),
+                    None => None,
+                };
+                if let Some(record) = record {
+                    let user = record.tokenid.user().to_string();
+                    let tokenname = match record.tokenid.tokenname() {
+                        Some(name) => name.as_str().to_owned(),
+                        None => {
+                            log::error!(
+                                "ApiToken '{}' has no name - internal error",
+                                record.tokenid
+                            );
+                            return true;
+                        }
+                    };
+
+                    let url = token_api_url(&user, &tokenname);
+                    let link = ctx.link();
+                    link.clone().spawn(async move {
+                        match crate::http_delete(url, None).await {
+                            Ok(()) => {
+                                link.send_reload();
+                            }
+                            Err(err) => {
+                                link.show_error("Removing API token failed", err, true);
+                            }
+                        }
+                    });
+                }
+                false
+            }
+            Msg::Regenerate => {
+                let record = match self.selection.selected_key() {
+                    Some(selected_key) => self.store.read().lookup_record(&selected_key).cloned(),
+                    None => None,
+                };
+                if let Some(record) = record {
+                    let user = record.tokenid.user().to_string();
+                    let tokenname = match record.tokenid.tokenname() {
+                        Some(name) => name.as_str().to_owned(),
+                        None => {
+                            log::error!(
+                                "ApiToken '{}' has no name - internal error",
+                                record.tokenid
+                            );
+                            return true;
+                        }
+                    };
+
+                    let url = token_api_url(&user, &tokenname);
+                    let link = ctx.link().clone();
+                    ctx.link().spawn(async move {
+                        match crate::http_put(url, Some(json!({"regenerate": true}))).await {
+                            Ok(secret) => {
+                                link.change_view(Some(ViewState::DisplayTokenSecret(secret)));
+                            }
+                            Err(err) => {
+                                link.show_error("Regenerating API token failed", err, true);
+                            }
+                        }
+                    });
+                }
+                false
+            }
+        }
+    }
+
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+        let link = ctx.link();
+
+        DataTable::new(self.columns.clone(), self.store.clone())
+            .class("pwt-flex-fit")
+            .selection(self.selection.clone())
+            .on_row_dblclick(move |_: &mut _| {
+                link.change_view(Some(ViewState::EditToken));
+            })
+            .into()
+    }
+
+    fn dialog_view(
+        &self,
+        ctx: &LoadableComponentContext<Self>,
+        view_state: &Self::ViewState,
+    ) -> Option<Html> {
+        match view_state {
+            ViewState::AddToken => Some(self.create_add_dialog(ctx)),
+            ViewState::EditToken => self
+                .selection
+                .selected_key()
+                .map(|key| self.create_edit_dialog(ctx, key)),
+            ViewState::ShowPermissions => self
+                .selection
+                .selected_key()
+                .map(|key| self.create_show_permissions_dialog(ctx, key)),
+            ViewState::DisplayTokenSecret(secret) => Some(self.show_secret_dialog(ctx, secret)),
+        }
+    }
+}
+
+impl ProxmoxTokenView {
+    fn create_show_permissions_dialog(
+        &self,
+        ctx: &LoadableComponentContext<Self>,
+        key: Key,
+    ) -> Html {
+        Dialog::new(key.to_string() + " - " + &tr!("Granted Permissions"))
+            .resizable(true)
+            .width(840)
+            .height(600)
+            .with_child(PermissionPanel::new().auth_id(key.to_string()))
+            .on_close(ctx.link().change_view_callback(|_| None))
+            .into()
+    }
+
+    fn show_secret_dialog(&self, ctx: &LoadableComponentContext<Self>, secret: &Value) -> Html {
+        let secret = secret.clone();
+
+        Dialog::new(tr!("Token Secret"))
+            .with_child(
+                Column::new()
+                    .with_child(
+                        InputPanel::new()
+                            .padding(4)
+                            .with_large_field(
+                                tr!("Token ID"),
+                                DisplayField::new()
+                                    .value(AttrValue::from(
+                                        secret["tokenid"].as_str().unwrap_or("").to_owned(),
+                                    ))
+                                    .border(true),
+                            )
+                            .with_large_field(
+                                tr!("Secret"),
+                                DisplayField::new()
+                                    .value(AttrValue::from(
+                                        secret["value"].as_str().unwrap_or("").to_owned(),
+                                    ))
+                                    .border(true),
+                            ),
+                    )
+                    .with_child(
+                        Container::new()
+                            .style("opacity", "0")
+                            .with_child(AttrValue::from(
+                                secret["value"].as_str().unwrap_or("").to_owned(),
+                            ))
+                            .into_html_with_ref(self.secret_node_ref.clone()),
+                    )
+                    .with_child(
+                        Container::new()
+                            .padding(4)
+                            .class(pwt::css::FlexFit)
+                            .class("pwt-bg-color-warning-container")
+                            .class("pwt-color-on-warning-container")
+                            .with_child(tr!(
+                                "Please record the API token secret - it will only be displayed now"
+                            )),
+                    )
+                    .with_child(
+                        Toolbar::new()
+                            .class("pwt-bg-color-surface")
+                            .with_flex_spacer()
+                            .with_child(
+                                Button::new(tr!("Copy Secret Value"))
+                                    .icon_class("fa fa-clipboard")
+                                    .class("pwt-scheme-primary")
+                                    .on_activate({
+                                        let copy_ref = self.secret_node_ref.clone();
+                                        move |_| copy_to_clipboard(&copy_ref)
+                                    }),
+                            ),
+                    ),
+            )
+            .on_close(ctx.link().change_view_callback(|_| None))
+            .into()
+    }
+
+    fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+        let link = ctx.link().clone();
+        EditWindow::new(tr!("Add") + ": " + &tr!("Token"))
+            .renderer(add_input_panel)
+            .on_submit(move |form_ctx| {
+                let link = link.clone();
+                create_token(form_ctx, link)
+            })
+            .on_close(ctx.link().change_view_callback(|_| None))
+            .into()
+    }
+
+    fn create_edit_dialog(&self, ctx: &LoadableComponentContext<Self>, key: Key) -> Html {
+        EditWindow::new(tr!("Edit") + ": " + &tr!("Token"))
+            .renderer(edit_input_panel)
+            .on_submit(update_token)
+            .on_done(ctx.link().change_view_callback(|_| None))
+            .loader(move || load_token(key.clone()))
+            .into()
+    }
+}
+
+fn edit_input_panel(_form_ctx: &FormContext) -> Html {
+    InputPanel::new()
+        .padding(4)
+        .with_field(
+            tr!("User"),
+            Field::new()
+                .name("userid")
+                .required(true)
+                .disabled(true)
+                .submit(false),
+        )
+        .with_right_field(
+            tr!("Expire"),
+            Field::new()
+                .name("expire")
+                .placeholder(tr!("never"))
+                .input_type(InputType::DatetimeLocal),
+        )
+        .with_field(
+            tr!("Token Name"),
+            Field::new()
+                .name("tokenname")
+                .submit(false)
+                .disabled(true)
+                .required(true),
+        )
+        .with_right_field(tr!("Enabled"), Checkbox::new().name("enable").default(true))
+        .with_large_field(
+            tr!("Comment"),
+            Field::new().name("comment").submit_empty(true),
+        )
+        .into()
+}
+
+fn add_input_panel(_form_ctx: &FormContext) -> Html {
+    InputPanel::new()
+        .padding(4)
+        .with_field(
+            tr!("User"),
+            AuthidSelector::new()
+                .name("userid")
+                .required(true)
+                .submit(false)
+                .include_tokens(false),
+        )
+        .with_right_field(
+            tr!("Expire"),
+            Field::new()
+                .name("expire")
+                .placeholder(tr!("never"))
+                .input_type(InputType::DatetimeLocal),
+        )
+        .with_field(
+            tr!("Token Name"),
+            Field::new().name("tokenname").submit(false).required(true),
+        )
+        .with_right_field(tr!("Enabled"), Checkbox::new().name("enable").default(true))
+        .with_large_field(tr!("Comment"), Field::new().name("comment"))
+        .into()
+}
+
+fn columns() -> Rc<Vec<DataTableHeader<ApiToken>>> {
+    Rc::new(vec![
+        DataTableColumn::new(tr!("User"))
+            .width("200px")
+            .render(|item: &ApiToken| {
+                html! {&item.tokenid.user()}
+            })
+            .sorter(|a: &ApiToken, b: &ApiToken| a.tokenid.user().cmp(b.tokenid.user()))
+            .sort_order(true)
+            .into(),
+        DataTableColumn::new(tr!("Token name"))
+            .width("100px")
+            .render(|item: &ApiToken| {
+                let name = item
+                    .tokenid
+                    .tokenname()
+                    .map(|name| name.as_str())
+                    .unwrap_or("");
+                html! {name}
+            })
+            .sorter(|a: &ApiToken, b: &ApiToken| {
+                let a = a
+                    .tokenid
+                    .tokenname()
+                    .map(|name| name.as_str())
+                    .unwrap_or("");
+                let b = b
+                    .tokenid
+                    .tokenname()
+                    .map(|name| name.as_str())
+                    .unwrap_or("");
+                a.cmp(b)
+            })
+            .sort_order(true)
+            .into(),
+        DataTableColumn::new(tr!("Enable"))
+            .width("80px")
+            .render(|item: &ApiToken| {
+                html! {render_boolean(item.enable.unwrap_or(true))}
+            })
+            .sorter(|a: &ApiToken, b: &ApiToken| a.enable.cmp(&b.enable))
+            .into(),
+        DataTableColumn::new(tr!("Expire"))
+            .width("80px")
+            .render({
+                let never_text = tr!("never");
+                move |item: &ApiToken| {
+                    html! {
+                        {
+                            match item.expire {
+                                Some(epoch) if epoch != 0 => render_epoch_short(epoch),
+                                _ => never_text.clone(),
+                            }
+                        }
+                    }
+                }
+            })
+            .sorter(|a: &ApiToken, b: &ApiToken| {
+                let a = if let Some(0) = a.expire {
+                    None
+                } else {
+                    a.expire
+                };
+                let b = if let Some(0) = b.expire {
+                    None
+                } else {
+                    b.expire
+                };
+                a.cmp(&b)
+            })
+            .into(),
+        DataTableColumn::new("Comment")
+            .flex(1)
+            .render(|item: &ApiToken| item.comment.as_deref().unwrap_or_default().into())
+            .into(),
+    ])
+}
+
+impl From<TokenPanel> for VNode {
+    fn from(value: TokenPanel) -> Self {
+        VComp::new::<LoadableComponentMaster<ProxmoxTokenView>>(Rc::new(value), None).into()
+    }
+}
--
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 1/3] ui: add a token panel and a token acl edit menu in the permissions panel
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
                   ` (4 preceding siblings ...)
  2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 2/3] server: access: use token endpoints from proxmox-access-control Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 3/3] server: clean up acl tree entries and api tokens when deleting users Shannon Sterz
  7 siblings, 0 replies; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

this allows users to create and manage api tokens.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 ui/src/configuration/mod.rs | 33 +++++++++++++++++++++++++++------
 1 file changed, 27 insertions(+), 6 deletions(-)

diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs
index 0a92796..4110f39 100644
--- a/ui/src/configuration/mod.rs
+++ b/ui/src/configuration/mod.rs
@@ -7,7 +7,7 @@ use pwt::widget::{Container, MiniScrollMode, Panel, TabBarItem, TabPanel};
 use proxmox_yew_comp::configuration::TimePanel;
 use proxmox_yew_comp::configuration::{DnsPanel, NetworkView};
 use proxmox_yew_comp::tfa::TfaView;
-use proxmox_yew_comp::{AclEdit, AclView, AuthView, UserPanel};
+use proxmox_yew_comp::{AclEdit, AclView, AuthView, TokenPanel, UserPanel};
 
 mod permission_path_selector;
 mod webauthn;
@@ -73,6 +73,19 @@ pub fn access_control() -> Html {
                     .into()
             },
         )
+        .with_item_builder(
+            TabBarItem::new()
+                .key("api-tokens")
+                .icon_class("fa fa-user-o")
+                .label(tr!("API Token")),
+            |_| {
+                Container::new()
+                    .class("pwt-content-spacer")
+                    .class(pwt::css::FlexFit)
+                    .with_child(TokenPanel::new())
+                    .into()
+            },
+        )
         .with_item_builder(
             TabBarItem::new()
                 .key("two-factor")
@@ -95,11 +108,19 @@ pub fn access_control() -> Html {
                 Container::new()
                     .class("pwt-content-spacer")
                     .class(pwt::css::FlexFit)
-                    .with_child(AclView::new().with_acl_edit_menu_entry(
-                        tr!("User Permission"),
-                        "fa fa-fw fa-user",
-                        acl_edit.clone().use_tokens(false),
-                    ))
+                    .with_child(
+                        AclView::new()
+                            .with_acl_edit_menu_entry(
+                                tr!("User Permission"),
+                                "fa fa-fw fa-user",
+                                acl_edit.clone().use_tokens(false),
+                            )
+                            .with_acl_edit_menu_entry(
+                                tr!("Token Permission"),
+                                "fa fa-fw fa-user-o",
+                                acl_edit.clone().use_tokens(true),
+                            ),
+                    )
                     .into()
             },
         )
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 2/3] server: access: use token endpoints from proxmox-access-control
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
                   ` (5 preceding siblings ...)
  2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 1/3] ui: add a token panel and a token acl edit menu in the permissions panel Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 3/3] server: clean up acl tree entries and api tokens when deleting users Shannon Sterz
  7 siblings, 0 replies; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

this allows keeping this logic in one place accross products

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 server/src/api/access/users.rs | 349 ++++-----------------------------
 1 file changed, 34 insertions(+), 315 deletions(-)

diff --git a/server/src/api/access/users.rs b/server/src/api/access/users.rs
index 7b6839e..da598d8 100644
--- a/server/src/api/access/users.rs
+++ b/server/src/api/access/users.rs
@@ -1,20 +1,16 @@
 //! User Management
 
 use anyhow::{bail, format_err, Error};
-use serde::{Deserialize, Serialize};
-use serde_json::{json, Value};
 use std::collections::HashMap;
 
-use proxmox_access_control::types::{
-    ApiToken, User, UserUpdater, UserWithTokens, ENABLE_USER_SCHEMA, EXPIRE_USER_SCHEMA,
-};
-use proxmox_access_control::{token_shadow, CachedUserInfo};
+use proxmox_access_control::types::{ApiToken, User, UserUpdater, UserWithTokens};
+use proxmox_access_control::CachedUserInfo;
 use proxmox_router::{ApiMethod, Permission, Router, RpcEnvironment, SubdirMap};
 use proxmox_schema::api;
 
 use pdm_api_types::{
-    Authid, ConfigDigest, DeletableUserProperty, Tokenname, Userid, PDM_PASSWORD_SCHEMA,
-    PRIV_ACCESS_MODIFY, PRIV_SYS_AUDIT, SINGLE_LINE_COMMENT_SCHEMA,
+    Authid, ConfigDigest, DeletableUserProperty, Userid, PDM_PASSWORD_SCHEMA, PRIV_ACCESS_MODIFY,
+    PRIV_SYS_AUDIT,
 };
 
 fn new_user_with_tokens(user: User) -> UserWithTokens {
@@ -382,336 +378,59 @@ pub fn delete_user(userid: Userid, digest: Option<ConfigDigest>) -> Result<(), E
     Ok(())
 }
 
-#[api(
-    input: {
-        properties: {
-            userid: {
-                type: Userid,
-            },
-            "token-name": {
-                type: Tokenname,
-            },
-        },
-    },
-    returns: { type: ApiToken },
-    access: {
-        permission: &Permission::Or(&[
+const API_METHOD_READ_TOKEN_WITH_ACCESS: ApiMethod =
+    proxmox_access_control::api::API_METHOD_READ_TOKEN.access(
+        None,
+        &Permission::Or(&[
             &Permission::Privilege(&["access", "users"], PRIV_SYS_AUDIT, false),
             &Permission::UserParam("userid"),
         ]),
-    },
-)]
-/// Read user's API token metadata
-pub fn read_token(
-    userid: Userid,
-    token_name: Tokenname,
-    _info: &ApiMethod,
-    rpcenv: &mut dyn RpcEnvironment,
-) -> Result<ApiToken, Error> {
-    let (config, digest) = proxmox_access_control::user::config()?;
+    );
 
-    let tokenid = Authid::from((userid, Some(token_name)));
-
-    rpcenv["digest"] = hex::encode(digest).into();
-    config.lookup("token", &tokenid.to_string())
-}
-
-#[api(
-    protected: true,
-    input: {
-        properties: {
-            userid: {
-                type: Userid,
-            },
-            "token-name": {
-                type: Tokenname,
-            },
-            comment: {
-                optional: true,
-                schema: SINGLE_LINE_COMMENT_SCHEMA,
-            },
-            enable: {
-                schema: ENABLE_USER_SCHEMA,
-                optional: true,
-            },
-            expire: {
-                schema: EXPIRE_USER_SCHEMA,
-                optional: true,
-            },
-            digest: {
-                optional: true,
-                type: ConfigDigest,
-            },
-        },
-    },
-    access: {
-        permission: &Permission::Or(&[
+const API_METHOD_UPDATE_TOKEN_WITH_ACCESS: ApiMethod =
+    proxmox_access_control::api::API_METHOD_UPDATE_TOKEN.access(
+        None,
+        &Permission::Or(&[
             &Permission::Privilege(&["access", "users"], PRIV_ACCESS_MODIFY, false),
             &Permission::UserParam("userid"),
         ]),
-    },
-    returns: {
-        description: "API token identifier + generated secret.",
-        properties: {
-            value: {
-                type: String,
-                description: "The API token secret",
-            },
-            tokenid: {
-                type: String,
-                description: "The API token identifier",
-            },
-        },
-    },
-)]
-/// Generate a new API token with given metadata
-pub fn generate_token(
-    userid: Userid,
-    token_name: Tokenname,
-    comment: Option<String>,
-    enable: Option<bool>,
-    expire: Option<i64>,
-    digest: Option<ConfigDigest>,
-) -> Result<Value, Error> {
-    let _lock = proxmox_access_control::user::lock_config()?;
+    );
 
-    let (mut config, config_digest) = proxmox_access_control::user::config()?;
-    config_digest.detect_modification(digest.as_ref())?;
-
-    let tokenid = Authid::from((userid.clone(), Some(token_name.clone())));
-    let tokenid_string = tokenid.to_string();
-
-    if config.sections.contains_key(&tokenid_string) {
-        bail!(
-            "token '{}' for user '{}' already exists.",
-            token_name.as_str(),
-            userid
-        );
-    }
-
-    let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
-    token_shadow::set_secret(&tokenid, &secret)?;
-
-    let token = ApiToken {
-        tokenid,
-        comment,
-        enable,
-        expire,
-    };
-
-    config.set_data(&tokenid_string, "token", &token)?;
-
-    proxmox_access_control::user::save_config(&config)?;
-
-    Ok(json!({
-        "tokenid": tokenid_string,
-        "value": secret
-    }))
-}
-
-#[api(
-    protected: true,
-    input: {
-        properties: {
-            userid: {
-                type: Userid,
-            },
-            "token-name": {
-                type: Tokenname,
-            },
-            comment: {
-                optional: true,
-                schema: SINGLE_LINE_COMMENT_SCHEMA,
-            },
-            enable: {
-                schema: ENABLE_USER_SCHEMA,
-                optional: true,
-            },
-            expire: {
-                schema: EXPIRE_USER_SCHEMA,
-                optional: true,
-            },
-            digest: {
-                optional: true,
-                type: ConfigDigest,
-            },
-        },
-    },
-    access: {
-        permission: &Permission::Or(&[
+const API_METHOD_GENERATE_TOKEN_WITH_ACCESS: ApiMethod =
+    proxmox_access_control::api::API_METHOD_GENERATE_TOKEN.access(
+        None,
+        &Permission::Or(&[
             &Permission::Privilege(&["access", "users"], PRIV_ACCESS_MODIFY, false),
             &Permission::UserParam("userid"),
         ]),
-    },
-)]
-/// Update user's API token metadata
-pub fn update_token(
-    userid: Userid,
-    token_name: Tokenname,
-    comment: Option<String>,
-    enable: Option<bool>,
-    expire: Option<i64>,
-    digest: Option<ConfigDigest>,
-) -> Result<(), Error> {
-    let _lock = proxmox_access_control::user::lock_config()?;
+    );
 
-    let (mut config, config_digest) = proxmox_access_control::user::config()?;
-    config_digest.detect_modification(digest.as_ref())?;
-
-    let tokenid = Authid::from((userid, Some(token_name)));
-    let tokenid_string = tokenid.to_string();
-
-    let mut data: ApiToken = config.lookup("token", &tokenid_string)?;
-
-    if let Some(comment) = comment {
-        let comment = comment.trim().to_string();
-        if comment.is_empty() {
-            data.comment = None;
-        } else {
-            data.comment = Some(comment);
-        }
-    }
-
-    if let Some(enable) = enable {
-        data.enable = if enable { None } else { Some(false) };
-    }
-
-    if let Some(expire) = expire {
-        data.expire = if expire > 0 { Some(expire) } else { None };
-    }
-
-    config.set_data(&tokenid_string, "token", &data)?;
-
-    proxmox_access_control::user::save_config(&config)?;
-
-    Ok(())
-}
-
-#[api(
-    protected: true,
-    input: {
-        properties: {
-            userid: {
-                type: Userid,
-            },
-            "token-name": {
-                type: Tokenname,
-            },
-            digest: {
-                optional: true,
-                type: ConfigDigest,
-            },
-        },
-    },
-    access: {
-        permission: &Permission::Or(&[
+const API_METHOD_DELETE_TOKEN_WITH_ACCESS: ApiMethod =
+    proxmox_access_control::api::API_METHOD_DELETE_TOKEN.access(
+        None,
+        &Permission::Or(&[
             &Permission::Privilege(&["access", "users"], PRIV_ACCESS_MODIFY, false),
             &Permission::UserParam("userid"),
         ]),
-    },
-)]
-/// Delete a user's API token
-pub fn delete_token(
-    userid: Userid,
-    token_name: Tokenname,
-    digest: Option<ConfigDigest>,
-) -> Result<(), Error> {
-    let _lock = proxmox_access_control::user::lock_config()?;
+    );
 
-    let (mut config, config_digest) = proxmox_access_control::user::config()?;
-    config_digest.detect_modification(digest.as_ref())?;
-
-    let tokenid = Authid::from((userid.clone(), Some(token_name.clone())));
-    let tokenid_string = tokenid.to_string();
-
-    match config.sections.get(&tokenid_string) {
-        Some(_) => {
-            config.sections.remove(&tokenid_string);
-        }
-        None => bail!(
-            "token '{}' of user '{}' does not exist.",
-            token_name.as_str(),
-            userid
-        ),
-    }
-
-    token_shadow::delete_secret(&tokenid)?;
-
-    proxmox_access_control::user::save_config(&config)?;
-
-    Ok(())
-}
-
-#[api(
-    properties: {
-        "token-name": { type: Tokenname },
-        token: { type: ApiToken },
-    }
-)]
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "kebab-case")]
-/// A Token Entry that contains the token-name
-pub struct TokenApiEntry {
-    /// The Token name
-    pub token_name: Tokenname,
-    #[serde(flatten)]
-    pub token: ApiToken,
-}
-
-#[api(
-    input: {
-        properties: {
-            userid: {
-                type: Userid,
-            },
-        },
-    },
-    returns: {
-        description: "List user's API tokens (with config digest).",
-        type: Array,
-        items: { type: TokenApiEntry },
-    },
-    access: {
-        permission: &Permission::Or(&[
+const API_METHOD_LIST_TOKENS_WITH_ACCESS: ApiMethod =
+    proxmox_access_control::api::API_METHOD_LIST_TOKENS.access(
+        None,
+        &Permission::Or(&[
             &Permission::Privilege(&["access", "users"], PRIV_SYS_AUDIT, false),
             &Permission::UserParam("userid"),
         ]),
-    },
-)]
-/// List user's API tokens
-pub fn list_tokens(
-    userid: Userid,
-    _info: &ApiMethod,
-    rpcenv: &mut dyn RpcEnvironment,
-) -> Result<Vec<TokenApiEntry>, Error> {
-    let (config, digest) = proxmox_access_control::user::config()?;
-
-    let list: Vec<ApiToken> = config.convert_to_typed_array("token")?;
-
-    rpcenv["digest"] = hex::encode(digest).into();
-
-    let filter_by_owner = |token: ApiToken| {
-        if token.tokenid.is_token() && token.tokenid.user() == &userid {
-            let token_name = token.tokenid.tokenname().unwrap().to_owned();
-            Some(TokenApiEntry { token_name, token })
-        } else {
-            None
-        }
-    };
-
-    let res = list.into_iter().filter_map(filter_by_owner).collect();
-
-    Ok(res)
-}
+    );
 
 const TOKEN_ITEM_ROUTER: Router = Router::new()
-    .get(&API_METHOD_READ_TOKEN)
-    .put(&API_METHOD_UPDATE_TOKEN)
-    .post(&API_METHOD_GENERATE_TOKEN)
-    .delete(&API_METHOD_DELETE_TOKEN);
+    .get(&API_METHOD_READ_TOKEN_WITH_ACCESS)
+    .put(&API_METHOD_UPDATE_TOKEN_WITH_ACCESS)
+    .post(&API_METHOD_GENERATE_TOKEN_WITH_ACCESS)
+    .delete(&API_METHOD_DELETE_TOKEN_WITH_ACCESS);
 
 const TOKEN_ROUTER: Router = Router::new()
-    .get(&API_METHOD_LIST_TOKENS)
+    .get(&API_METHOD_LIST_TOKENS_WITH_ACCESS)
     .match_all("token-name", &TOKEN_ITEM_ROUTER);
 
 const USER_SUBDIRS: SubdirMap = &[("token", &TOKEN_ROUTER)];
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 3/3] server: clean up acl tree entries and api tokens when deleting users
  2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
                   ` (6 preceding siblings ...)
  2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 2/3] server: access: use token endpoints from proxmox-access-control Shannon Sterz
@ 2025-09-24 14:51 ` Shannon Sterz
  2025-09-26  9:18   ` Fabian Grünbichler
  7 siblings, 1 reply; 14+ messages in thread
From: Shannon Sterz @ 2025-09-24 14:51 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 server/src/api/access/users.rs | 39 +++++++++++++++++++++++++++++-----
 1 file changed, 34 insertions(+), 5 deletions(-)

diff --git a/server/src/api/access/users.rs b/server/src/api/access/users.rs
index da598d8..1d1accb 100644
--- a/server/src/api/access/users.rs
+++ b/server/src/api/access/users.rs
@@ -334,20 +334,19 @@ pub fn update_user(
 /// Remove a user from the configuration file.
 pub fn delete_user(userid: Userid, digest: Option<ConfigDigest>) -> Result<(), Error> {
     let _lock = proxmox_access_control::user::lock_config()?;
+    let _acl_lock = proxmox_access_control::acl::lock_config()?;
     let _tfa_lock = crate::auth::tfa::write_lock()?;
 
-    let (mut config, config_digest) = proxmox_access_control::user::config()?;
+    let (mut user_config, config_digest) = proxmox_access_control::user::config()?;
     config_digest.detect_modification(digest.as_ref())?;
 
-    match config.sections.get(userid.as_str()) {
+    match user_config.sections.get(userid.as_str()) {
         Some(_) => {
-            config.sections.remove(userid.as_str());
+            user_config.sections.remove(userid.as_str());
         }
         None => bail!("user '{}' does not exist.", userid),
     }
 
-    proxmox_access_control::user::save_config(&config)?;
-
     let authenticator = crate::auth::lookup_authenticator(userid.realm())?;
     match authenticator.remove_password(userid.name()) {
         Ok(()) => {}
@@ -375,6 +374,36 @@ pub fn delete_user(userid: Userid, digest: Option<ConfigDigest>) -> Result<(), E
         }
     }
 
+    let user_tokens: Vec<ApiToken> = user_config
+        .convert_to_typed_array::<ApiToken>("token")?
+        .into_iter()
+        .filter(|token| token.tokenid.user().eq(&userid))
+        .collect();
+
+    let (mut acl_config, _digest) = proxmox_access_control::acl::config()?;
+
+    let auth_id = userid.clone().into();
+    acl_config.delete_authid(&auth_id);
+
+    for token in user_tokens {
+        if let Some(token_name) = token.tokenid.tokenname() {
+            let tokenid = Authid::from((userid.clone(), Some(token_name.to_owned())));
+            let tokenid_string = tokenid.to_string();
+            if user_config.sections.remove(&tokenid_string).is_none() {
+                bail!(
+                    "token '{}' of user '{userid}' does not exist.",
+                    token_name.as_str()
+                );
+            }
+
+            proxmox_access_control::token_shadow::delete_secret(&tokenid)?;
+            acl_config.delete_authid(&tokenid);
+        }
+    }
+
+    proxmox_access_control::user::save_config(&user_config)?;
+    proxmox_access_control::acl::save_config(&acl_config)?;
+
     Ok(())
 }
 
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical Shannon Sterz
@ 2025-09-26  8:26   ` Dominik Csapak
  0 siblings, 0 replies; 14+ messages in thread
From: Dominik Csapak @ 2025-09-26  8:26 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Shannon Sterz



On 9/24/25 4:51 PM, Shannon Sterz wrote:
> this is mainly in preparation of factoring out more api endpoints
> related to access control. the refactoring is done in a way where
> users should not need to adapt.
> 
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
>   proxmox-access-control/src/{api.rs => api/acl.rs} | 0
>   proxmox-access-control/src/api/mod.rs             | 8 ++++++++
>   2 files changed, 8 insertions(+)
>   rename proxmox-access-control/src/{api.rs => api/acl.rs} (100%)
>   create mode 100644 proxmox-access-control/src/api/mod.rs
> 
> diff --git a/proxmox-access-control/src/api.rs b/proxmox-access-control/src/api/acl.rs
> similarity index 100%
> rename from proxmox-access-control/src/api.rs
> rename to proxmox-access-control/src/api/acl.rs
> diff --git a/proxmox-access-control/src/api/mod.rs b/proxmox-access-control/src/api/mod.rs
> new file mode 100644
> index 00000000..59dc32e2
> --- /dev/null
> +++ b/proxmox-access-control/src/api/mod.rs
> @@ -0,0 +1,8 @@
> +mod tokens;
> +pub use tokens::{
> +    API_METHOD_DELETE_TOKEN, API_METHOD_GENERATE_TOKEN, API_METHOD_LIST_TOKENS,
> +    API_METHOD_READ_TOKEN, API_METHOD_UPDATE_TOKEN,
> +};
> +
> +mod acl;
> +pub use acl::{ACL_ROUTER, API_METHOD_READ_ACL, API_METHOD_UPDATE_ACL, ROLE_ROUTER};

it seems the hunk here would belong into proxmox 3/3 since there is no 
'tokens' module yet?


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel
  2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel Shannon Sterz
@ 2025-09-26  8:50   ` Dominik Csapak
  0 siblings, 0 replies; 14+ messages in thread
From: Dominik Csapak @ 2025-09-26  8:50 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Shannon Sterz

a few nitpicks (inline) but nothing major, and no blocker from my side

On 9/24/25 4:52 PM, Shannon Sterz wrote:
> this is analogous to the user panel. the token panel allows adding,
> editing and remove api tokens. existing tokens can also be
> re-generated and their permissions can be displayed.
> 
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
> note that this could probably use several refinments:
> 
> - this could use the Clipboard api once an appropriate web_sys version is
>    packaged instead of NodeRef
> - use a base_url instead of hardcoding everything.
> 
> but i wanted some early feedback for now
> 
>   src/lib.rs         |   3 +
>   src/token_panel.rs | 569 +++++++++++++++++++++++++++++++++++++++++++++
>   2 files changed, 572 insertions(+)
>   create mode 100644 src/token_panel.rs
> 
> diff --git a/src/lib.rs b/src/lib.rs
> index 492326a..b6fcd81 100644
> --- a/src/lib.rs
> +++ b/src/lib.rs
> @@ -203,6 +203,9 @@ pub use wizard::{PwtWizard, Wizard, WizardPageRenderInfo};
>   mod user_panel;
>   pub use user_panel::UserPanel;
> 
> +mod token_panel;
> +pub use token_panel::TokenPanel;
> +
>   pub mod utils;
> 
>   mod xtermjs;
> diff --git a/src/token_panel.rs b/src/token_panel.rs
> new file mode 100644
> index 0000000..26e3575
> --- /dev/null
> +++ b/src/token_panel.rs
> @@ -0,0 +1,569 @@
> +use std::future::Future;
> +use std::pin::Pin;
> +use std::rc::Rc;
> +
> +use anyhow::Error;
> +use proxmox_access_control::types::{ApiToken, UserWithTokens};
> +use proxmox_auth_api::types::Authid;
> +use proxmox_client::ApiResponseData;
> +use serde_json::{json, Value};
> +
> +use yew::virtual_dom::{Key, VComp, VNode};
> +
> +use pwt::prelude::*;
> +use pwt::state::{Selection, Store};
> +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
> +use pwt::widget::form::{Checkbox, DisplayField, Field, FormContext, InputType};
> +use pwt::widget::{Button, Column, Container, Dialog, InputPanel, Toolbar};
> +
> +use crate::percent_encoding::percent_encode_component;
> +use crate::utils::{copy_to_clipboard, epoch_to_input_value, render_boolean, render_epoch_short};
> +use crate::{
> +    AuthidSelector, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
> +    LoadableComponentLink, LoadableComponentMaster, PermissionPanel,
> +};
> +
> +async fn load_api_tokens() -> Result<Vec<ApiToken>, Error> {
> +    let url = "/access/users/?include_tokens=1";
> +    let users: Vec<UserWithTokens> = crate::http_get(url, None).await?;
> +    let mut list: Vec<ApiToken> = Vec::new();
> +
> +    for user in users.into_iter() {
> +        list.extend(user.tokens)
> +    }
> +
> +    Ok(list)

could also be written as

Ok(users.into_iter().map(|user| user.tokens))

> +}
> +
> +async fn create_token(
> +    form_ctx: FormContext,
> +    link: LoadableComponentLink<ProxmoxTokenView>,
> +) -> Result<(), Error> {
> +    let mut data = form_ctx.get_submit_data();
> +
> +    let userid = form_ctx.read().get_field_text("userid");
> +    let tokenname = form_ctx.read().get_field_text("tokenname");
> +
> +    let url = token_api_url(&userid, &tokenname);
> +
> +    let expire = form_ctx.read().get_field_text("expire");
> +
> +    if let Ok(epoch) = proxmox_time::parse_rfc3339(&expire) {
> +        data["expire"] = epoch.into();
> +    }
> +
> +    let res: Value = crate::http_post(url, Some(data)).await?;
> +
> +    link.change_view(Some(ViewState::DisplayTokenSecret(res)));
> +
> +    Ok(())
> +}
> +
> +async fn load_token(tokenid: Key) -> Result<ApiResponseData<Value>, Error> {
> +    let tokenid: Authid = tokenid.parse().unwrap();
> +
> +    let userid = tokenid.user().to_string();
> +    let tokenname = tokenid.tokenname().map(|n| n.as_str().to_owned()).unwrap();
> +
> +    let url = token_api_url(&userid, &tokenname);
> +
> +    let mut resp: ApiResponseData<Value> = crate::http_get_full(&url, None).await?;
> +
> +    if let Value::Number(number) = &resp.data["expire"] {
> +        if let Some(epoch) = number.as_f64() {
> +            resp.data["expire"] = Value::String(epoch_to_input_value(epoch as i64));
> +        }
> +    }
> +    resp.data["userid"] = userid.into();
> +    resp.data["tokenname"] = tokenname.into();
> +
> +    Ok(resp)
> +}
> +
> +async fn update_token(form_ctx: FormContext) -> Result<(), Error> {
> +    let mut data = form_ctx.get_submit_data();
> +
> +    let userid = form_ctx.read().get_field_text("userid");
> +    let tokenname = form_ctx.read().get_field_text("tokenname");
> +
> +    let url = token_api_url(&userid, &tokenname);
> +
> +    let expire = form_ctx.read().get_field_text("expire");
> +    if let Ok(epoch) = proxmox_time::parse_rfc3339(&expire) {
> +        data["expire"] = epoch.into();
> +    } else {
> +        data["expire"] = 0.into();
> +    }
> +
> +    crate::http_put(url, Some(data)).await
> +}
> +
> +#[derive(PartialEq, Properties)]
> +pub struct TokenPanel {}
> +
> +impl TokenPanel {
> +    pub fn new() -> Self {
> +        yew::props!(Self {})
> +    }
> +}
> +
> +impl Default for TokenPanel {
> +    fn default() -> Self {
> +        Self::new()
> +    }
> +}
> +
> +#[derive(Clone, PartialEq)]
> +enum ViewState {
> +    AddToken,
> +    EditToken,
> +    ShowPermissions,
> +    DisplayTokenSecret(Value),
> +}
> +
> +enum Msg {
> +    Refresh,
> +    Remove,
> +    Regenerate,
> +}
> +
> +struct ProxmoxTokenView {
> +    selection: Selection,
> +    store: Store<ApiToken>,
> +    secret_node_ref: NodeRef,
> +    columns: Rc<Vec<DataTableHeader<ApiToken>>>,
> +}
> +
> +fn token_api_url(user: &str, tokenname: &str) -> String {
> +    format!(
> +        "/access/users/{}/token/{}",
> +        percent_encode_component(user),
> +        percent_encode_component(tokenname),
> +    )
> +}
> +
> +impl LoadableComponent for ProxmoxTokenView {
> +    type Properties = TokenPanel;
> +    type Message = Msg;
> +    type ViewState = ViewState;
> +
> +    fn create(ctx: &LoadableComponentContext<Self>) -> Self {
> +        let link = ctx.link();
> +        link.repeated_load(5000);
> +
> +        let selection = Selection::new().on_select(link.callback(|_| Msg::Refresh));
> +        let store =
> +            Store::with_extract_key(|record: &ApiToken| Key::from(record.tokenid.to_string()));
> +
> +        Self {
> +            selection,
> +            store,
> +            secret_node_ref: NodeRef::default(),
> +            columns: columns(),
> +        }
> +    }
> +
> +    fn load(
> +        &self,
> +        _ctx: &LoadableComponentContext<Self>,
> +    ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
> +        let store = self.store.clone();
> +        Box::pin(async move {
> +            let data = load_api_tokens().await?;
> +            store.write().set_data(data);
> +            Ok(())
> +        })
> +    }
> +
> +    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
> +        let selected_id = self.selection.selected_key().map(|k| k.to_string());
> +        let disabled = selected_id.is_none();
> +        let link = ctx.link();
> +
> +        let toolbar = Toolbar::new()
> +            .class("pwt-w-100")
> +            .class("pwt-overflow-hidden")
> +            .class("pwt-border-bottom")
> +            .border_top(true)
> +            .with_child(
> +                Button::new(tr!("Add"))
> +                    .on_activate(link.change_view_callback(|_| Some(ViewState::AddToken))),
> +            )
> +            .with_spacer()
> +            .with_child(
> +                Button::new(tr!("Edit"))
> +                    .disabled(disabled)
> +                    .on_activate(link.change_view_callback(|_| Some(ViewState::EditToken))),
> +            )
> +            .with_child(
> +                Button::new(tr!("Remove"))
> +                    .disabled(disabled)
> +                    .on_activate(link.callback(|_| Msg::Remove)),
> +            )
> +            .with_spacer()
> +            .with_child(
> +                ConfirmButton::new(tr!("Regenerate Secret"))
> +                    .confirm_message(tr!("
> +                        Regenerate the secret of the selected API token? All current use-sites will loose access!"
> +                    ))
> +                    .disabled(disabled)
> +                    .on_activate(link.callback(|_| Msg::Regenerate))
> +            )
> +            .with_spacer()
> +            .with_child(
> +                Button::new(tr!("Show Permissions"))
> +                    .disabled(disabled)
> +                    .on_activate(link.change_view_callback(|_| Some(ViewState::ShowPermissions))),
> +            );
> +
> +        Some(toolbar.into())
> +    }
> +
> +    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
> +        match msg {
> +            Msg::Refresh => true,
> +            Msg::Remove => {
> +                let record = match self.selection.selected_key() {
> +                    Some(selected_key) => self.store.read().lookup_record(&selected_key).cloned(),
> +                    None => None,
> +                };
> +                if let Some(record) = record {
> +                    let user = record.tokenid.user().to_string();
> +                    let tokenname = match record.tokenid.tokenname() {
> +                        Some(name) => name.as_str().to_owned(),
> +                        None => {
> +                            log::error!(
> +                                "ApiToken '{}' has no name - internal error",
> +                                record.tokenid
> +                            );
> +                            return true;
> +                        }
> +                    };
> +
> +                    let url = token_api_url(&user, &tokenname);
> +                    let link = ctx.link();
> +                    link.clone().spawn(async move {
> +                        match crate::http_delete(url, None).await {
> +                            Ok(()) => {
> +                                link.send_reload();
> +                            }
> +                            Err(err) => {
> +                                link.show_error("Removing API token failed", err, true);
> +                            }
> +                        }
> +                    });
> +                }
> +                false
> +            }
> +            Msg::Regenerate => {
> +                let record = match self.selection.selected_key() {
> +                    Some(selected_key) => self.store.read().lookup_record(&selected_key).cloned(),
> +                    None => None,
> +                };
> +                if let Some(record) = record {
> +                    let user = record.tokenid.user().to_string();
> +                    let tokenname = match record.tokenid.tokenname() {
> +                        Some(name) => name.as_str().to_owned(),
> +                        None => {
> +                            log::error!(
> +                                "ApiToken '{}' has no name - internal error",
> +                                record.tokenid
> +                            );
> +                            return true;
> +                        }
> +                    };
> +
> +                    let url = token_api_url(&user, &tokenname);
> +                    let link = ctx.link().clone();
> +                    ctx.link().spawn(async move {
> +                        match crate::http_put(url, Some(json!({"regenerate": true}))).await {
> +                            Ok(secret) => {
> +                                link.change_view(Some(ViewState::DisplayTokenSecret(secret)));
> +                            }
> +                            Err(err) => {
> +                                link.show_error("Regenerating API token failed", err, true);
> +                            }
> +                        }
> +                    });
> +                }
> +                false
> +            }
> +        }
> +    }
> +
> +    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
> +        let link = ctx.link();
> +
> +        DataTable::new(self.columns.clone(), self.store.clone())
> +            .class("pwt-flex-fit")
> +            .selection(self.selection.clone())
> +            .on_row_dblclick(move |_: &mut _| {
> +                link.change_view(Some(ViewState::EditToken));
> +            })
> +            .into()
> +    }
> +
> +    fn dialog_view(
> +        &self,
> +        ctx: &LoadableComponentContext<Self>,
> +        view_state: &Self::ViewState,
> +    ) -> Option<Html> {
> +        match view_state {
> +            ViewState::AddToken => Some(self.create_add_dialog(ctx)),
> +            ViewState::EditToken => self
> +                .selection
> +                .selected_key()
> +                .map(|key| self.create_edit_dialog(ctx, key)),
> +            ViewState::ShowPermissions => self
> +                .selection
> +                .selected_key()
> +                .map(|key| self.create_show_permissions_dialog(ctx, key)),
> +            ViewState::DisplayTokenSecret(secret) => Some(self.show_secret_dialog(ctx, secret)),
> +        }
> +    }
> +}
> +
> +impl ProxmoxTokenView {
> +    fn create_show_permissions_dialog(
> +        &self,
> +        ctx: &LoadableComponentContext<Self>,
> +        key: Key,
> +    ) -> Html {
> +        Dialog::new(key.to_string() + " - " + &tr!("Granted Permissions"))
> +            .resizable(true)
> +            .width(840)
> +            .height(600)
> +            .with_child(PermissionPanel::new().auth_id(key.to_string()))
> +            .on_close(ctx.link().change_view_callback(|_| None))
> +            .into()
> +    }
> +
> +    fn show_secret_dialog(&self, ctx: &LoadableComponentContext<Self>, secret: &Value) -> Html {
> +        let secret = secret.clone();
> +
> +        Dialog::new(tr!("Token Secret"))
> +            .with_child(
> +                Column::new()
> +                    .with_child(
> +                        InputPanel::new()
> +                            .padding(4)
> +                            .with_large_field(
> +                                tr!("Token ID"),
> +                                DisplayField::new()
> +                                    .value(AttrValue::from(
> +                                        secret["tokenid"].as_str().unwrap_or("").to_owned(),
> +                                    ))
> +                                    .border(true),
> +                            )
> +                            .with_large_field(
> +                                tr!("Secret"),
> +                                DisplayField::new()
> +                                    .value(AttrValue::from(
> +                                        secret["value"].as_str().unwrap_or("").to_owned(),
> +                                    ))
> +                                    .border(true),
> +                            ),
> +                    )
> +                    .with_child(
> +                        Container::new()
> +                            .style("opacity", "0")
> +                            .with_child(AttrValue::from(
> +                                secret["value"].as_str().unwrap_or("").to_owned(),
> +                            ))
> +                            .into_html_with_ref(self.secret_node_ref.clone()),

does this actually work as a copy input?

AFAICS: copy_to_clipboard wants to cast the noderef to a
HtmlInputElement, and i don't think a container qualifies for that?

couldn't we simply show the secret in a disabled textfield and use that 
for copying? then we don't have to add some extra 'hidden' container 
with the secret?

> +                    )
> +                    .with_child(
> +                        Container::new()
> +                            .padding(4)
> +                            .class(pwt::css::FlexFit)
> +                            .class("pwt-bg-color-warning-container")
> +                            .class("pwt-color-on-warning-container")
> +                            .with_child(tr!(
> +                                "Please record the API token secret - it will only be displayed now"
> +                            )),
> +                    )

i did not actually look at the result, but wouldn't it align more nicely
when adding these fields into the inputpanel?
(with_custom_child)

> +                    .with_child(
> +                        Toolbar::new()
> +                            .class("pwt-bg-color-surface")
> +                            .with_flex_spacer()
> +                            .with_child(
> +                                Button::new(tr!("Copy Secret Value"))
> +                                    .icon_class("fa fa-clipboard")
> +                                    .class("pwt-scheme-primary")
> +                                    .on_activate({
> +                                        let copy_ref = self.secret_node_ref.clone();
> +                                        move |_| copy_to_clipboard(&copy_ref)
> +                                    }),
> +                            ),
> +                    ),
> +            )
> +            .on_close(ctx.link().change_view_callback(|_| None))
> +            .into()
> +    }
> +
> +    fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Html {
> +        let link = ctx.link().clone();
> +        EditWindow::new(tr!("Add") + ": " + &tr!("Token"))
> +            .renderer(add_input_panel)
> +            .on_submit(move |form_ctx| {
> +                let link = link.clone();
> +                create_token(form_ctx, link)
> +            })
> +            .on_close(ctx.link().change_view_callback(|_| None))
> +            .into()
> +    }
> +
> +    fn create_edit_dialog(&self, ctx: &LoadableComponentContext<Self>, key: Key) -> Html {
> +        EditWindow::new(tr!("Edit") + ": " + &tr!("Token"))
> +            .renderer(edit_input_panel)
> +            .on_submit(update_token)
> +            .on_done(ctx.link().change_view_callback(|_| None))
> +            .loader(move || load_token(key.clone()))
> +            .into()
> +    }
> +}
> +
> +fn edit_input_panel(_form_ctx: &FormContext) -> Html {
> +    InputPanel::new()
> +        .padding(4)
> +        .with_field(
> +            tr!("User"),
> +            Field::new()
> +                .name("userid")
> +                .required(true)
> +                .disabled(true)
> +                .submit(false),
> +        )
> +        .with_right_field(
> +            tr!("Expire"),
> +            Field::new()
> +                .name("expire")
> +                .placeholder(tr!("never"))
> +                .input_type(InputType::DatetimeLocal),
> +        )
> +        .with_field(
> +            tr!("Token Name"),
> +            Field::new()
> +                .name("tokenname")
> +                .submit(false)
> +                .disabled(true)
> +                .required(true),
> +        )
> +        .with_right_field(tr!("Enabled"), Checkbox::new().name("enable").default(true))
> +        .with_large_field(
> +            tr!("Comment"),
> +            Field::new().name("comment").submit_empty(true),
> +        )
> +        .into()
> +}
> +
> +fn add_input_panel(_form_ctx: &FormContext) -> Html {
> +    InputPanel::new()
> +        .padding(4)
> +        .with_field(
> +            tr!("User"),
> +            AuthidSelector::new()
> +                .name("userid")
> +                .required(true)
> +                .submit(false)
> +                .include_tokens(false),
> +        )
> +        .with_right_field(
> +            tr!("Expire"),
> +            Field::new()
> +                .name("expire")
> +                .placeholder(tr!("never"))
> +                .input_type(InputType::DatetimeLocal),
> +        )
> +        .with_field(
> +            tr!("Token Name"),
> +            Field::new().name("tokenname").submit(false).required(true),
> +        )
> +        .with_right_field(tr!("Enabled"), Checkbox::new().name("enable").default(true))
> +        .with_large_field(tr!("Comment"), Field::new().name("comment"))
> +        .into()
> +}
> +
> +fn columns() -> Rc<Vec<DataTableHeader<ApiToken>>> {
> +    Rc::new(vec![
> +        DataTableColumn::new(tr!("User"))
> +            .width("200px")
> +            .render(|item: &ApiToken| {
> +                html! {&item.tokenid.user()}
> +            })
> +            .sorter(|a: &ApiToken, b: &ApiToken| a.tokenid.user().cmp(b.tokenid.user()))
> +            .sort_order(true)
> +            .into(),
> +        DataTableColumn::new(tr!("Token name"))
> +            .width("100px")
> +            .render(|item: &ApiToken| {
> +                let name = item
> +                    .tokenid
> +                    .tokenname()
> +                    .map(|name| name.as_str())
> +                    .unwrap_or("");
> +                html! {name}
> +            })
> +            .sorter(|a: &ApiToken, b: &ApiToken| {
> +                let a = a
> +                    .tokenid
> +                    .tokenname()
> +                    .map(|name| name.as_str())
> +                    .unwrap_or("");
> +                let b = b
> +                    .tokenid
> +                    .tokenname()
> +                    .map(|name| name.as_str())
> +                    .unwrap_or("");
> +                a.cmp(b)
> +            })
> +            .sort_order(true)
> +            .into(),
> +        DataTableColumn::new(tr!("Enable"))
> +            .width("80px")
> +            .render(|item: &ApiToken| {
> +                html! {render_boolean(item.enable.unwrap_or(true))}
> +            })
> +            .sorter(|a: &ApiToken, b: &ApiToken| a.enable.cmp(&b.enable))
> +            .into(),
> +        DataTableColumn::new(tr!("Expire"))
> +            .width("80px")
> +            .render({
> +                let never_text = tr!("never");
> +                move |item: &ApiToken| {
> +                    html! {
> +                        {
> +                            match item.expire {
> +                                Some(epoch) if epoch != 0 => render_epoch_short(epoch),
> +                                _ => never_text.clone(),
> +                            }
> +                        }
> +                    }
> +                }
> +            })
> +            .sorter(|a: &ApiToken, b: &ApiToken| {
> +                let a = if let Some(0) = a.expire {
> +                    None
> +                } else {
> +                    a.expire
> +                };
> +                let b = if let Some(0) = b.expire {
> +                    None
> +                } else {
> +                    b.expire
> +                };
> +                a.cmp(&b)
> +            })
> +            .into(),
> +        DataTableColumn::new("Comment")
> +            .flex(1)
> +            .render(|item: &ApiToken| item.comment.as_deref().unwrap_or_default().into())
> +            .into(),
> +    ])
> +}
> +
> +impl From<TokenPanel> for VNode {
> +    fn from(value: TokenPanel) -> Self {
> +        VComp::new::<LoadableComponentMaster<ProxmoxTokenView>>(Rc::new(value), None).into()
> +    }
> +}
> --
> 2.47.3
> 
> 
> 
> _______________________________________________
> pdm-devel mailing list
> pdm-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
> 
> 



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [pdm-devel] [PATCH proxmox 2/3] access-control: move `ApiTokenSecret` to types module
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 2/3] access-control: move `ApiTokenSecret` to types module Shannon Sterz
@ 2025-09-26  9:14   ` Fabian Grünbichler
  0 siblings, 0 replies; 14+ messages in thread
From: Fabian Grünbichler @ 2025-09-26  9:14 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion

On September 24, 2025 4:51 pm, Shannon Sterz wrote:
> this is technically a breaking change, but so far this type has no
> users
> 
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
>  proxmox-access-control/src/token_shadow.rs |  9 ---------
>  proxmox-access-control/src/types.rs        | 12 +++++++++++-
>  2 files changed, 11 insertions(+), 10 deletions(-)
> 
> diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
> index 60b71ac9..46397edb 100644
> --- a/proxmox-access-control/src/token_shadow.rs
> +++ b/proxmox-access-control/src/token_shadow.rs
> @@ -1,7 +1,6 @@
>  use std::collections::HashMap;
>  
>  use anyhow::{bail, format_err, Error};
> -use serde::{Deserialize, Serialize};
>  use serde_json::{from_value, Value};
>  
>  use proxmox_auth_api::types::Authid;
> @@ -9,14 +8,6 @@ use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
>  
>  use crate::init::{token_shadow, token_shadow_lock};
>  
> -#[derive(Serialize, Deserialize)]
> -#[serde(rename_all = "kebab-case")]
> -/// ApiToken id / secret pair
> -pub struct ApiTokenSecret {
> -    pub tokenid: Authid,
> -    pub secret: String,
> -}
> -
>  // Get exclusive lock
>  fn lock_config() -> Result<ApiLockGuard, Error> {
>      open_api_lockfile(token_shadow_lock(), None, true)
> diff --git a/proxmox-access-control/src/types.rs b/proxmox-access-control/src/types.rs
> index ea64d333..a146700d 100644
> --- a/proxmox-access-control/src/types.rs
> +++ b/proxmox-access-control/src/types.rs
> @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
>  
>  use const_format::concatcp;
>  
> -use proxmox_auth_api::types::{Authid, Userid, PROXMOX_TOKEN_ID_SCHEMA};
> +use proxmox_auth_api::types::{Authid, Tokenname, Userid, PROXMOX_TOKEN_ID_SCHEMA};
>  use proxmox_schema::{
>      api,
>      api_types::{COMMENT_SCHEMA, SAFE_ID_REGEX_STR, SINGLE_LINE_COMMENT_FORMAT},
> @@ -147,6 +147,16 @@ impl ApiToken {
>      }
>  }
>  
> +#[api]
> +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
> +#[serde(rename_all = "kebab-case")]
> +/// ApiToken id / secret pair
> +pub struct ApiTokenSecret {
> +    pub tokenid: Authid,
> +    /// The secret associated with the token.
> +    pub secret: String,
> +}
> +

almost missed the hunk that makes the serialization consistent with
PBS and old PDM, since it comes in a later patch ;)

>  #[api(
>      properties: {
>          userid: {
> -- 
> 2.47.3
> 
> 
> 
> _______________________________________________
> pdm-devel mailing list
> pdm-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
> 
> 
> 


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [pdm-devel] [PATCH proxmox 3/3] access-control: add api endpoints for handling tokens
  2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 3/3] access-control: add api endpoints for handling tokens Shannon Sterz
@ 2025-09-26  9:14   ` Fabian Grünbichler
  0 siblings, 0 replies; 14+ messages in thread
From: Fabian Grünbichler @ 2025-09-26  9:14 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion

some smaller nits/questions below

On September 24, 2025 4:51 pm, Shannon Sterz wrote:
> by adding most of the logic of token creation, updating and deleting
> here, users can more easily add api tokens to their api. this requires
> the api feature.
> 
> users of these api endpoints are expected to set the `access`
> parameters according to their needs. by default only super users can
> access any of them.
> 
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
>  proxmox-access-control/Cargo.toml        |   2 +
>  proxmox-access-control/src/api/tokens.rs | 306 +++++++++++++++++++++++
>  proxmox-access-control/src/types.rs      |  17 ++
>  3 files changed, 325 insertions(+)
>  create mode 100644 proxmox-access-control/src/api/tokens.rs
> 
> diff --git a/proxmox-access-control/Cargo.toml b/proxmox-access-control/Cargo.toml
> index dbcbeced..274c08a7 100644
> --- a/proxmox-access-control/Cargo.toml
> +++ b/proxmox-access-control/Cargo.toml
> @@ -31,12 +31,14 @@ proxmox-section-config = { workspace = true, optional = true }
>  proxmox-shared-memory = { workspace = true, optional = true }
>  proxmox-sys = { workspace = true, features = [ "crypt" ], optional = true }
>  proxmox-time = { workspace = true }
> +proxmox-uuid = { workspace = true, optional = true }
>  
>  [features]
>  default = []
>  api = [
>      "impl",
>      "dep:hex",
> +    "dep:proxmox-uuid"
>  ]
>  impl = [
>      "dep:nix",
> diff --git a/proxmox-access-control/src/api/tokens.rs b/proxmox-access-control/src/api/tokens.rs
> new file mode 100644
> index 00000000..1bde36c8
> --- /dev/null
> +++ b/proxmox-access-control/src/api/tokens.rs
> @@ -0,0 +1,306 @@
> +//! User Management
> +
> +use anyhow::{bail, Error};
> +use proxmox_config_digest::ConfigDigest;
> +use proxmox_schema::api_types::COMMENT_SCHEMA;
> +
> +use proxmox_auth_api::types::{Authid, Tokenname, Userid};
> +use proxmox_router::{ApiMethod, RpcEnvironment};
> +use proxmox_schema::api;
> +
> +use crate::token_shadow::{self};
> +use crate::types::{
> +    ApiToken, ApiTokenSecret, TokenApiEntry, ENABLE_USER_SCHEMA, EXPIRE_USER_SCHEMA,
> +};
> +
> +#[api(
> +    input: {
> +        properties: {
> +            userid: {
> +                type: Userid,
> +            },
> +            "token-name": {
> +                type: Tokenname,
> +            },
> +        },
> +    },
> +    returns: { type: ApiToken },
> +)]
> +/// Read user's API token metadata
> +pub fn read_token(
> +    userid: Userid,
> +    token_name: Tokenname,
> +    _info: &ApiMethod,
> +    rpcenv: &mut dyn RpcEnvironment,
> +) -> Result<ApiToken, Error> {
> +    let (config, digest) = crate::user::config()?;
> +
> +    let tokenid = Authid::from((userid, Some(token_name)));
> +
> +    rpcenv["digest"] = hex::encode(digest).into();
> +    config.lookup("token", &tokenid.to_string())
> +}
> +
> +#[api(
> +    protected: true,
> +    input: {
> +        properties: {
> +            userid: {
> +                type: Userid,
> +            },
> +            "token-name": {
> +                type: Tokenname,
> +            },
> +            comment: {
> +                optional: true,
> +                schema: COMMENT_SCHEMA,
> +            },
> +            enable: {
> +                schema: ENABLE_USER_SCHEMA,
> +                optional: true,
> +            },
> +            expire: {
> +                schema: EXPIRE_USER_SCHEMA,
> +                optional: true,
> +            },
> +            digest: {
> +                optional: true,
> +                type: ConfigDigest,
> +            },
> +        },
> +    },
> +    returns: { type: ApiTokenSecret },
> +)]
> +/// Generate a new API token with given metadata
> +pub fn generate_token(
> +    userid: Userid,
> +    token_name: Tokenname,
> +    comment: Option<String>,
> +    enable: Option<bool>,
> +    expire: Option<i64>,
> +    digest: Option<ConfigDigest>,
> +) -> Result<ApiTokenSecret, Error> {
> +    let _lock = crate::user::lock_config()?;
> +    let (mut config, config_digest) = crate::user::config()?;
> +
> +    config_digest.detect_modification(digest.as_ref())?;
> +
> +    let tokenid = Authid::from((userid.clone(), Some(token_name.clone())));
> +    let tokenid_string = tokenid.to_string();
> +
> +    if config.sections.contains_key(&tokenid_string) {
> +        bail!(
> +            "token '{}' for user '{userid}' already exists.",
> +            token_name.as_str(),
> +        );
> +    }
> +
> +    let secret = format!("{:x}", proxmox_uuid::Uuid::generate());

in PBS, we leave the generation part to token_shadow and don't expose
setting arbitrary secret, which seems like the "safer" interface..

any reason to not do the same here?

> +    token_shadow::set_secret(&tokenid, &secret)?;
> +
> +    let token = ApiToken {
> +        tokenid: tokenid.clone(),
> +        comment,
> +        enable,
> +        expire,
> +    };
> +
> +    config.set_data(&tokenid_string, "token", &token)?;
> +
> +    crate::user::save_config(&config)?;
> +
> +    Ok(ApiTokenSecret { tokenid, secret })
> +}
> +
> +#[api(
> +    protected: true,
> +    input: {
> +        properties: {
> +            userid: {
> +                type: Userid,
> +            },
> +            "token-name": {
> +                type: Tokenname,
> +            },
> +            comment: {
> +                optional: true,
> +                schema: COMMENT_SCHEMA,
> +            },
> +            enable: {
> +                schema: ENABLE_USER_SCHEMA,
> +                optional: true,
> +            },
> +            expire: {
> +                schema: EXPIRE_USER_SCHEMA,
> +                optional: true,
> +            },
> +            regenerate: {
> +                description: "Whether the token should be regenerated or not.",
> +                optional: true,
> +                type: bool,
> +                default: false,

this has a schema in PBS as well

> +            },

and we allow deleting comments via `delete` in PBS, should we allow it
here as well?

> +            digest: {
> +                optional: true,
> +                type: ConfigDigest,
> +            },
> +        },
> +    },
> +    returns: {
> +        type: ApiTokenSecret,
> +        optional: true
> +    }
> +)]
> +/// Update user's API token metadata. If regenerate is set to true, the token and it's new secret
> +/// will be returned.
> +pub fn update_token(
> +    userid: Userid,
> +    token_name: Tokenname,
> +    comment: Option<String>,
> +    enable: Option<bool>,
> +    expire: Option<i64>,
> +    regenerate: bool,
> +    digest: Option<ConfigDigest>,
> +) -> Result<Option<ApiTokenSecret>, Error> {
> +    let _lock = crate::user::lock_config()?;
> +
> +    let (mut config, config_digest) = crate::user::config()?;
> +    config_digest.detect_modification(digest.as_ref())?;
> +
> +    let tokenid = Authid::from((userid, Some(token_name)));
> +    let tokenid_string = tokenid.to_string();
> +
> +    let mut data: ApiToken = config.lookup("token", &tokenid_string)?;
> +
> +    if let Some(comment) = comment {
> +        let comment = comment.trim().to_string();
> +        if comment.is_empty() {
> +            data.comment = None;
> +        } else {
> +            data.comment = Some(comment);
> +        }
> +    }
> +
> +    if let Some(enable) = enable {
> +        data.enable = if enable { None } else { Some(false) };
> +    }
> +
> +    if let Some(expire) = expire {
> +        data.expire = if expire > 0 { Some(expire) } else { None };
> +    }
> +
> +    let new_secret = if regenerate {
> +        let secret = format!("{:x}", proxmox_uuid::Uuid::generate());
> +        crate::token_shadow::set_secret(&tokenid, &secret)?;

see comment above

> +
> +        Some(ApiTokenSecret { tokenid, secret })
> +    } else {
> +        None
> +    };
> +
> +    config.set_data(&tokenid_string, "token", &data)?;
> +
> +    crate::user::save_config(&config)?;
> +
> +    Ok(new_secret)
> +}
> +
> +#[api(
> +    protected: true,
> +    input: {
> +        properties: {
> +            userid: {
> +                type: Userid,
> +            },
> +            "token-name": {
> +                type: Tokenname,
> +            },
> +            digest: {
> +                optional: true,
> +                type: ConfigDigest,
> +            },
> +        },
> +    },
> +)]
> +/// Delete a user's API token
> +pub fn delete_token(
> +    userid: Userid,
> +    token_name: Tokenname,
> +    digest: Option<ConfigDigest>,
> +) -> Result<(), Error> {
> +    let _lock = crate::user::lock_config()?;
> +
> +    let (mut config, config_digest) = crate::user::config()?;
> +    config_digest.detect_modification(digest.as_ref())?;
> +
> +    let tokenid = Authid::from((userid.clone(), Some(token_name.clone())));
> +    let tokenid_string = tokenid.to_string();
> +
> +    match config.sections.get(&tokenid_string) {
> +        Some(_) => {
> +            config.sections.remove(&tokenid_string);
> +        }
> +        None => bail!(
> +            "token '{}' of user '{userid}' does not exist.",
> +            token_name.as_str(),
> +        ),
> +    }
> +
> +    token_shadow::delete_secret(&tokenid)?;
> +
> +    crate::user::save_config(&config)?;

should we clean up ACL entries for that token here as well?

> +
> +    Ok(())
> +}
> +
> +#[api(
> +    input: {
> +        properties: {
> +            userid: {
> +                type: Userid,
> +            },
> +        },
> +    },
> +    returns: {
> +        description: "List user's API tokens (with config digest).",
> +        type: Array,
> +        items: { type: TokenApiEntry },
> +    },
> +)]
> +/// List user's API tokens
> +pub fn list_tokens(
> +    userid: Userid,
> +    _info: &ApiMethod,
> +    rpcenv: &mut dyn RpcEnvironment,
> +) -> Result<Vec<TokenApiEntry>, Error> {
> +    let (config, digest) = crate::user::config()?;
> +
> +    let list: Vec<ApiToken> = config.convert_to_typed_array("token")?;
> +
> +    rpcenv["digest"] = hex::encode(digest).into();
> +
> +    let filter_by_owner = |token: ApiToken| {
> +        if token.tokenid.is_token() && token.tokenid.user() == &userid {
> +            let token_name = token.tokenid.tokenname().unwrap().to_owned();
> +            Some(TokenApiEntry { token_name, token })
> +        } else {
> +            None
> +        }
> +    };
> +
> +    let res = list.into_iter().filter_map(filter_by_owner).collect();
> +
> +    Ok(res)
> +}
> +
> +/*const TOKEN_ITEM_ROUTER: Router = Router::new()
> +    .get(&API_METHOD_READ_TOKEN)
> +    .put(&API_METHOD_UPDATE_TOKEN)
> +    .post(&API_METHOD_GENERATE_TOKEN)
> +    .delete(&API_METHOD_DELETE_TOKEN);
> +
> +const TOKEN_ROUTER: Router = Router::new()
> +    .get(&API_METHOD_LIST_TOKENS)
> +    .match_all("token-name", &TOKEN_ITEM_ROUTER);
> +
> +const USER_SUBDIRS: SubdirMap = &[("token", &TOKEN_ROUTER)];*/
> diff --git a/proxmox-access-control/src/types.rs b/proxmox-access-control/src/types.rs
> index a146700d..2771f3b8 100644
> --- a/proxmox-access-control/src/types.rs
> +++ b/proxmox-access-control/src/types.rs
> @@ -154,9 +154,26 @@ impl ApiToken {
>  pub struct ApiTokenSecret {
>      pub tokenid: Authid,
>      /// The secret associated with the token.
> +    // rename to `value` as that is what it is called in the api
> +    #[serde(rename = "value")]
>      pub secret: String,
>  }
>  
> +#[api(
> +    properties: {
> +        token: { type: ApiToken },
> +    }
> +)]
> +#[derive(Serialize, Deserialize)]
> +#[serde(rename_all = "kebab-case")]
> +/// A Token Entry that contains the token-name
> +pub struct TokenApiEntry {
> +    /// The Token name
> +    pub token_name: Tokenname,
> +    #[serde(flatten)]
> +    pub token: ApiToken,
> +}
> +
>  #[api(
>      properties: {
>          userid: {
> -- 
> 2.47.3
> 
> 
> 
> _______________________________________________
> pdm-devel mailing list
> pdm-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
> 
> 
> 


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

* Re: [pdm-devel] [PATCH datacenter-manager 3/3] server: clean up acl tree entries and api tokens when deleting users
  2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 3/3] server: clean up acl tree entries and api tokens when deleting users Shannon Sterz
@ 2025-09-26  9:18   ` Fabian Grünbichler
  0 siblings, 0 replies; 14+ messages in thread
From: Fabian Grünbichler @ 2025-09-26  9:18 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion

On September 24, 2025 4:51 pm, Shannon Sterz wrote:
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
>  server/src/api/access/users.rs | 39 +++++++++++++++++++++++++++++-----
>  1 file changed, 34 insertions(+), 5 deletions(-)
> 
> diff --git a/server/src/api/access/users.rs b/server/src/api/access/users.rs
> index da598d8..1d1accb 100644
> --- a/server/src/api/access/users.rs
> +++ b/server/src/api/access/users.rs
> @@ -334,20 +334,19 @@ pub fn update_user(
>  /// Remove a user from the configuration file.
>  pub fn delete_user(userid: Userid, digest: Option<ConfigDigest>) -> Result<(), Error> {
>      let _lock = proxmox_access_control::user::lock_config()?;
> +    let _acl_lock = proxmox_access_control::acl::lock_config()?;
>      let _tfa_lock = crate::auth::tfa::write_lock()?;
>  
> -    let (mut config, config_digest) = proxmox_access_control::user::config()?;
> +    let (mut user_config, config_digest) = proxmox_access_control::user::config()?;
>      config_digest.detect_modification(digest.as_ref())?;
>  
> -    match config.sections.get(userid.as_str()) {
> +    match user_config.sections.get(userid.as_str()) {
>          Some(_) => {
> -            config.sections.remove(userid.as_str());
> +            user_config.sections.remove(userid.as_str());
>          }
>          None => bail!("user '{}' does not exist.", userid),
>      }
>  
> -    proxmox_access_control::user::save_config(&config)?;
> -
>      let authenticator = crate::auth::lookup_authenticator(userid.realm())?;
>      match authenticator.remove_password(userid.name()) {
>          Ok(()) => {}
> @@ -375,6 +374,36 @@ pub fn delete_user(userid: Userid, digest: Option<ConfigDigest>) -> Result<(), E
>          }
>      }
>  
> +    let user_tokens: Vec<ApiToken> = user_config
> +        .convert_to_typed_array::<ApiToken>("token")?
> +        .into_iter()
> +        .filter(|token| token.tokenid.user().eq(&userid))
> +        .collect();

do we have any consistency checks between ACLs and users/tokens? if not,
then..

> +
> +    let (mut acl_config, _digest) = proxmox_access_control::acl::config()?;
> +
> +    let auth_id = userid.clone().into();
> +    acl_config.delete_authid(&auth_id);
> +
> +    for token in user_tokens {
> +        if let Some(token_name) = token.tokenid.tokenname() {
> +            let tokenid = Authid::from((userid.clone(), Some(token_name.to_owned())));
> +            let tokenid_string = tokenid.to_string();
> +            if user_config.sections.remove(&tokenid_string).is_none() {
> +                bail!(
> +                    "token '{}' of user '{userid}' does not exist.",
> +                    token_name.as_str()
> +                );
> +            }
> +
> +            proxmox_access_control::token_shadow::delete_secret(&tokenid)?;
> +            acl_config.delete_authid(&tokenid);

this is not enough to remove all ACLs, since removing a token via the
token API currently does not clean up its ACL entries..

> +        }
> +    }
> +
> +    proxmox_access_control::user::save_config(&user_config)?;
> +    proxmox_access_control::acl::save_config(&acl_config)?;
> +
>      Ok(())
>  }
>  
> -- 
> 2.47.3
> 
> 
> 
> _______________________________________________
> pdm-devel mailing list
> pdm-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
> 
> 
> 


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 14+ messages in thread

end of thread, other threads:[~2025-09-26  9:18 UTC | newest]

Thread overview: 14+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical Shannon Sterz
2025-09-26  8:26   ` Dominik Csapak
2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 2/3] access-control: move `ApiTokenSecret` to types module Shannon Sterz
2025-09-26  9:14   ` Fabian Grünbichler
2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 3/3] access-control: add api endpoints for handling tokens Shannon Sterz
2025-09-26  9:14   ` Fabian Grünbichler
2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 1/2] utils/user_panel: factor out epoch_to_input_value helper Shannon Sterz
2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel Shannon Sterz
2025-09-26  8:50   ` Dominik Csapak
2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 1/3] ui: add a token panel and a token acl edit menu in the permissions panel Shannon Sterz
2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 2/3] server: access: use token endpoints from proxmox-access-control Shannon Sterz
2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 3/3] server: clean up acl tree entries and api tokens when deleting users Shannon Sterz
2025-09-26  9:18   ` Fabian Grünbichler

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