From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 87DCB1FF13F for ; Thu, 18 Jun 2026 12:22:10 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 050B712B0A; Thu, 18 Jun 2026 12:22:10 +0200 (CEST) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager 1/3] server: api: access: add endpoints for configuring pdm and pam realms Date: Thu, 18 Jun 2026 12:21:24 +0200 Message-ID: <20260618102126.177217-2-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260618102126.177217-1-s.sterz@proxmox.com> References: <20260618102126.177217-1-s.sterz@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1781778033148 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.107 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: GYNEJP2NE2XGYMJB3UOSGER7C3SSR2W7 X-Message-ID-Hash: GYNEJP2NE2XGYMJB3UOSGER7C3SSR2W7 X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: this allows users to set those realms as default realms and also allows editing their comments. also makes sure that the pam and pdm realms exist in the domains.cfg Signed-off-by: Shannon Sterz --- lib/pdm-api-types/src/lib.rs | 96 ++++++++++++++ lib/pdm-config/src/domains.rs | 37 +++++- server/src/api/access/domains.rs | 16 +-- server/src/api/config/access/mod.rs | 4 + server/src/api/config/access/pam.rs | 119 ++++++++++++++++++ server/src/api/config/access/pdm.rs | 119 ++++++++++++++++++ .../bin/proxmox-datacenter-privileged-api.rs | 1 + 7 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 server/src/api/config/access/pam.rs create mode 100644 server/src/api/config/access/pdm.rs diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs index b9cc3234..89d1b4ad 100644 --- a/lib/pdm-api-types/src/lib.rs +++ b/lib/pdm-api-types/src/lib.rs @@ -391,6 +391,102 @@ pub struct BasicRealmInfo { pub comment: Option, } +#[api( + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + "type": { + type: RealmType, + }, + comment: { + optional: true, + schema: SINGLE_LINE_COMMENT_SCHEMA, + }, + "default": { + optional: true, + default: false, + }, + } +)] +#[derive(Serialize, Deserialize, Updater, Clone)] +#[serde(rename_all = "kebab-case")] +/// Built-in PAM realm configuration properties. +pub struct PamRealmConfig { + /// Realm name. Always "pam". + #[updater(skip)] + pub realm: String, + /// Realm type. Always [`RealmType::Pam`]. + #[updater(skip)] + #[serde(rename = "type")] + pub ty: RealmType, + /// Comment for this realm + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + /// True if you want this to be the default realm selected on login. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl Default for PamRealmConfig { + fn default() -> Self { + Self { + realm: "pam".to_owned(), + ty: RealmType::Pam, + comment: Some("Linux PAM standard authentication".to_owned()), + default: None, + } + } +} + +#[api( + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + "type": { + type: RealmType, + }, + comment: { + optional: true, + schema: SINGLE_LINE_COMMENT_SCHEMA, + }, + "default": { + optional: true, + default: false, + }, + } +)] +#[derive(Serialize, Deserialize, Updater, Clone)] +#[serde(rename_all = "kebab-case")] +/// Built-in Proxmox Datacenter Manager realm configuration properties. +pub struct PdmRealmConfig { + /// Realm name. Always "pdm". + #[updater(skip)] + pub realm: String, + /// Realm type. Always [`RealmType::Pdm`]. + #[updater(skip)] + #[serde(rename = "type")] + pub ty: RealmType, + /// Comment for this realm + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + /// True if you want this to be the default realm selected on login. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + +impl Default for PdmRealmConfig { + fn default() -> Self { + Self { + realm: "pdm".to_owned(), + ty: RealmType::Pdm, + comment: Some("Proxmox Datacenter Manager authentication server".to_owned()), + default: None, + } + } +} + #[api] /// Guest configuration access. #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Updater)] diff --git a/lib/pdm-config/src/domains.rs b/lib/pdm-config/src/domains.rs index fa9c6e72..7b4b75e5 100644 --- a/lib/pdm-config/src/domains.rs +++ b/lib/pdm-config/src/domains.rs @@ -7,7 +7,9 @@ use proxmox_ldap::types::{AdRealmConfig, LdapRealmConfig}; use proxmox_schema::{ApiType, Schema}; use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}; -use pdm_api_types::{ConfigDigest, OpenIdRealmConfig, REALM_ID_SCHEMA}; +use pdm_api_types::{ + ConfigDigest, OpenIdRealmConfig, PamRealmConfig, PdmRealmConfig, REALM_ID_SCHEMA, +}; use proxmox_product_config::{ApiLockGuard, open_api_lockfile, replace_privileged_config}; use pdm_buildcfg::configdir; @@ -15,6 +17,22 @@ use pdm_buildcfg::configdir; pub static CONFIG: LazyLock = LazyLock::new(init); fn init() -> SectionConfig { + let mut config = SectionConfig::new(&REALM_ID_SCHEMA); + + let plugin = SectionConfigPlugin::new( + "pam".to_owned(), + Some("realm".to_owned()), + PamRealmConfig::API_SCHEMA.unwrap_object_schema(), + ); + config.register_plugin(plugin); + + let plugin = SectionConfigPlugin::new( + "pdm".to_owned(), + Some("realm".to_owned()), + PdmRealmConfig::API_SCHEMA.unwrap_object_schema(), + ); + config.register_plugin(plugin); + let obj_schema = match OpenIdRealmConfig::API_SCHEMA { Schema::Object(ref obj_schema) => obj_schema, _ => unreachable!(), @@ -25,7 +43,6 @@ fn init() -> SectionConfig { Some(String::from("realm")), obj_schema, ); - let mut config = SectionConfig::new(&REALM_ID_SCHEMA); config.register_plugin(plugin); let ldap_plugin = SectionConfigPlugin::new( @@ -110,3 +127,19 @@ pub fn unset_default_realm(config: &mut SectionConfigData) -> Result<(), Error> pub fn exists(domains: &SectionConfigData, realm: &str) -> bool { domains.sections.contains_key(realm) } + +/// Add the pam and pdm realms to the config if they don't exist. These should always be added. +pub fn add_default_realms() -> Result<(), Error> { + let _lock = lock_config()?; + let (mut domains, _) = config()?; + + if !exists(&domains, "pam") { + domains.set_data("pam", "pam", PamRealmConfig::default())?; + } + + if !exists(&domains, "pdm") { + domains.set_data("pdm", "pdm", PdmRealmConfig::default())?; + } + + save_config(&domains) +} diff --git a/server/src/api/access/domains.rs b/server/src/api/access/domains.rs index 06abfb92..18adaa4e 100644 --- a/server/src/api/access/domains.rs +++ b/server/src/api/access/domains.rs @@ -27,21 +27,7 @@ use pdm_api_types::{Authid, BasicRealmInfo, RealmRef, RealmType, UPID_SCHEMA}; )] /// Authentication domain/realm index. fn list_domains(rpcenv: &mut dyn RpcEnvironment) -> Result, Error> { - let mut list = vec![ - BasicRealmInfo { - realm: "pam".to_string(), - ty: RealmType::Pam, - default: None, - comment: Some("Linux PAM standard authentication".to_string()), - }, - BasicRealmInfo { - realm: "pdm".to_string(), - ty: RealmType::Pdm, - default: None, - comment: Some("Proxmox Datacenter Manager authentication".to_string()), - }, - ]; - + let mut list = Vec::new(); let (config, digest) = pdm_config::domains::config()?; for (_, (section_type, v)) in config.sections.iter() { diff --git a/server/src/api/config/access/mod.rs b/server/src/api/config/access/mod.rs index 57761522..ba6d5e32 100644 --- a/server/src/api/config/access/mod.rs +++ b/server/src/api/config/access/mod.rs @@ -5,6 +5,8 @@ use proxmox_sortable_macro::sortable; mod ad; mod ldap; mod openid; +mod pam; +mod pdm; pub mod tfa; #[sortable] @@ -13,6 +15,8 @@ const SUBDIRS: SubdirMap = &sorted!([ ("ldap", &ldap::ROUTER), ("openid", &openid::ROUTER), ("ad", &ad::ROUTER), + ("pam", &pam::ROUTER), + ("pdm", &pdm::ROUTER), ]); pub const ROUTER: Router = Router::new() diff --git a/server/src/api/config/access/pam.rs b/server/src/api/config/access/pam.rs new file mode 100644 index 00000000..93ab1910 --- /dev/null +++ b/server/src/api/config/access/pam.rs @@ -0,0 +1,119 @@ +use anyhow::Error; +use serde::{Deserialize, Serialize}; + +use proxmox_config_digest::ConfigDigest; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::api; + +use pdm_api_types::{PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT, PamRealmConfig, PamRealmConfigUpdater}; +use pdm_config::domains; + +#[api( + returns: { + type: PamRealmConfig, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false), + }, +)] +/// Read the PAM realm configuration +pub fn read_pam_realm(rpcenv: &mut dyn RpcEnvironment) -> Result { + let (domains, digest) = domains::config()?; + + let config = domains.lookup("pam", "pam")?; + + rpcenv["digest"] = digest.to_hex().into(); + + Ok(config) +} + +#[api] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Deletable property name +pub enum DeletableProperty { + /// Delete the comment property. + Comment, + /// Delete the default property. + Default, +} + +#[api( + protected: true, + input: { + properties: { + update: { + type: PamRealmConfigUpdater, + flatten: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeletableProperty, + } + }, + digest: { + optional: true, + type: ConfigDigest, + }, + }, + }, + returns: { + type: PamRealmConfig, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Update the PAM realm configuration +pub fn update_pam_realm( + update: PamRealmConfigUpdater, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = domains::lock_config()?; + + let (mut domains, expected_digest) = domains::config()?; + + expected_digest.detect_modification(digest.as_ref())?; + + let mut config: PamRealmConfig = domains.lookup("pam", "pam")?; + + if let Some(delete) = delete { + for delete_prop in delete { + match delete_prop { + DeletableProperty::Comment => config.comment = None, + DeletableProperty::Default => config.default = None, + } + } + } + + if let Some(comment) = update.comment { + let comment = comment.trim().to_string(); + if comment.is_empty() { + config.comment = None; + } else { + config.comment = Some(comment); + } + } + + if let Some(true) = update.default { + pdm_config::domains::unset_default_realm(&mut domains)?; + config.default = Some(true); + } else { + config.default = None; + } + + domains.set_data("pam", "pam", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_READ_PAM_REALM) + .put(&API_METHOD_UPDATE_PAM_REALM); diff --git a/server/src/api/config/access/pdm.rs b/server/src/api/config/access/pdm.rs new file mode 100644 index 00000000..e35cba0c --- /dev/null +++ b/server/src/api/config/access/pdm.rs @@ -0,0 +1,119 @@ +use ::serde::{Deserialize, Serialize}; +use anyhow::Error; + +use proxmox_config_digest::ConfigDigest; +use proxmox_router::{Permission, Router, RpcEnvironment}; +use proxmox_schema::api; + +use pdm_api_types::{PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT, PdmRealmConfig, PdmRealmConfigUpdater}; +use pdm_config::domains; + +#[api( + returns: { + type: PdmRealmConfig, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false), + }, +)] +/// Read the Proxmox Datacenter Manager authentication server realm configuration +pub fn read_pdm_realm(rpcenv: &mut dyn RpcEnvironment) -> Result { + let (domains, digest) = domains::config()?; + + let config = domains.lookup("pdm", "pdm")?; + + rpcenv["digest"] = digest.to_hex().into(); + + Ok(config) +} + +#[api] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Deletable property name +pub enum DeletableProperty { + /// Delete the comment property. + Comment, + /// Delete the default property. + Default, +} + +#[api( + protected: true, + input: { + properties: { + update: { + type: PdmRealmConfigUpdater, + flatten: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeletableProperty, + } + }, + digest: { + optional: true, + type: ConfigDigest, + }, + }, + }, + returns: { + type: PdmRealmConfig, + }, + access: { + permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false), + }, +)] +/// Update the Proxmox Datacenter Manager authentication server realm configuration +pub fn update_pdm_realm( + update: PdmRealmConfigUpdater, + delete: Option>, + digest: Option, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + let _lock = domains::lock_config()?; + + let (mut domains, expected_digest) = domains::config()?; + + expected_digest.detect_modification(digest.as_ref())?; + + let mut config: PdmRealmConfig = domains.lookup("pdm", "pdm")?; + + if let Some(delete) = delete { + for delete_prop in delete { + match delete_prop { + DeletableProperty::Comment => config.comment = None, + DeletableProperty::Default => config.default = None, + } + } + } + + if let Some(comment) = update.comment { + let comment = comment.trim().to_string(); + if comment.is_empty() { + config.comment = None; + } else { + config.comment = Some(comment); + } + } + + if let Some(true) = update.default { + pdm_config::domains::unset_default_realm(&mut domains)?; + config.default = Some(true); + } else { + config.default = None; + } + + domains.set_data("pdm", "pdm", &config)?; + + domains::save_config(&domains)?; + + Ok(()) +} + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_READ_PDM_REALM) + .put(&API_METHOD_UPDATE_PDM_REALM); diff --git a/server/src/bin/proxmox-datacenter-privileged-api.rs b/server/src/bin/proxmox-datacenter-privileged-api.rs index fdc4e8a9..59d30513 100644 --- a/server/src/bin/proxmox-datacenter-privileged-api.rs +++ b/server/src/bin/proxmox-datacenter-privileged-api.rs @@ -118,6 +118,7 @@ async fn run() -> Result<(), Error> { auth::init(true); proxmox_acme_api::init(configdir!("/acme"), true)?; + pdm_config::domains::add_default_realms()?; let api_user = pdm_config::api_user()?; let mut command_sock = proxmox_daemon::command_socket::CommandSocket::new(api_user.gid); -- 2.47.3