public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager
@ 2025-09-16 14:48 Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH proxmox 1/1] ldap: add types and sync features Shannon Sterz
                   ` (11 more replies)
  0 siblings, 12 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

this patch series adds ldap and active directory (ad) support to proxmox
datacenter manager. the series first moves some of the sync logic for
ldap & ad realms out of proxmox backup manager into the proxmox-ldap
crate.

the next series of patches fixes up the existing proxmox-yew-comp
components for adding and editing realms to function as intended for
adding, editing, removing and syncing ad and ldap realms.

finally, we add the necessary backend infrastructure and api endpoints
to proxmox datacenter manager and expose the new ui components there.

this series does not yet move proxmox backup server to use the new
common crate. doing so would mean that proxmox backup server would also
need to start using proxmox-access-control, which would be a lot more
involved and is beste handled in a separate series in my opinion.

proxmox:

Shannon Sterz (1):
  ldap: add types and sync features

 Cargo.toml                    |   2 +-
 proxmox-ldap/Cargo.toml       |  22 ++
 proxmox-ldap/debian/control   |  31 ++-
 proxmox-ldap/debian/copyright |   2 +-
 proxmox-ldap/src/lib.rs       |   6 +
 proxmox-ldap/src/sync.rs      | 496 ++++++++++++++++++++++++++++++++++
 proxmox-ldap/src/types.rs     | 317 ++++++++++++++++++++++
 7 files changed, 873 insertions(+), 3 deletions(-)
 create mode 100644 proxmox-ldap/src/sync.rs
 create mode 100644 proxmox-ldap/src/types.rs


proxmox-yew-comp:

Shannon Sterz (5):
  auth_view: add default column and allow setting ldap realms as default
  utils: add pdm realm to `get_auth_domain_info`
  auth_view/auth_edit_ldap: add support for active directory realms
  auth_edit_ldap: add helpers to properly edit ad & ldap realms
  auth_view: implement syncing ldap and ad realms

 src/auth_edit_ldap.rs   | 165 ++++++++++++++++++++++++++++++++-----
 src/auth_view.rs        | 174 +++++++++++++++++++++++++++++++++++++---
 src/common_api_types.rs |   3 +
 src/utils.rs            |  18 ++---
 4 files changed, 315 insertions(+), 45 deletions(-)


proxmox-datacenter-manager:

Shannon Sterz (5):
  config: add domain config plugins for ldap and ad realms
  server: add ldap and active directory authenticators
  server: api: add api endpoints for configuring ldap & ad realms
  api/auth: add endpoint to start ldap sync jobs
  ui: add a panel to allow handling realms

 Cargo.toml                           |   1 +
 lib/pdm-api-types/src/acl.rs         |   3 +
 lib/pdm-api-types/src/lib.rs         |   7 +
 lib/pdm-config/Cargo.toml            |   1 +
 lib/pdm-config/src/domains.rs        |  35 +++
 server/Cargo.toml                    |   1 +
 server/src/api/access/domains.rs     |  90 ++++++-
 server/src/api/config/access/ad.rs   | 355 +++++++++++++++++++++++++
 server/src/api/config/access/ldap.rs | 372 +++++++++++++++++++++++++++
 server/src/api/config/access/mod.rs  |   8 +-
 server/src/auth/ldap.rs              | 315 +++++++++++++++++++++++
 server/src/auth/mod.rs               |  17 +-
 ui/src/configuration/mod.rs          |  14 +-
 13 files changed, 1210 insertions(+), 9 deletions(-)
 create mode 100644 server/src/api/config/access/ad.rs
 create mode 100644 server/src/api/config/access/ldap.rs
 create mode 100644 server/src/auth/ldap.rs


Summary over all repositories:
  24 files changed, 2398 insertions(+), 57 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] 13+ messages in thread

* [pdm-devel] [PATCH proxmox 1/1] ldap: add types and sync features
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 1/5] auth_view: add default column and allow setting ldap realms as default Shannon Sterz
                   ` (10 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

so that types can be shared between users of this crate instead of
always re-implementing them for each user specifically. the sync
feature also allows re-using the sync logic that was previously
implemented only for proxmox backup server.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---

not sure if we want to move the api infrastructure here too, but that
then would required some mechanism (like a trait object or similar) for
storing and editing the domain config. not sure if we want to also have
this here in this shared crate. if so, i'd suggest we feature-gate it
behind an `api` feature.

 Cargo.toml                    |   2 +-
 proxmox-ldap/Cargo.toml       |  22 ++
 proxmox-ldap/debian/control   |  31 ++-
 proxmox-ldap/debian/copyright |   2 +-
 proxmox-ldap/src/lib.rs       |   6 +
 proxmox-ldap/src/sync.rs      | 496 ++++++++++++++++++++++++++++++++++
 proxmox-ldap/src/types.rs     | 317 ++++++++++++++++++++++
 7 files changed, 873 insertions(+), 3 deletions(-)
 create mode 100644 proxmox-ldap/src/sync.rs
 create mode 100644 proxmox-ldap/src/types.rs

diff --git a/Cargo.toml b/Cargo.toml
index f149af65..6632ab0d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -133,7 +133,7 @@ walkdir = "2"
 zstd = "0.13"

 # workspace dependencies
-proxmox-access-control = { version = "0.2.5", path = "proxmox-access-control" }
+proxmox-access-control = { version = "1.1.1", path = "proxmox-access-control" }
 proxmox-acme = {  version = "1.0.0", path = "proxmox-acme", default-features = false }
 proxmox-api-macro = { version = "1.4.1", path = "proxmox-api-macro" }
 proxmox-apt-api-types = { version = "2.0.0", path = "proxmox-apt-api-types" }
diff --git a/proxmox-ldap/Cargo.toml b/proxmox-ldap/Cargo.toml
index 2b1a6029..0fe313eb 100644
--- a/proxmox-ldap/Cargo.toml
+++ b/proxmox-ldap/Cargo.toml
@@ -13,8 +13,30 @@ repository.workspace = true
 [dependencies]
 anyhow.workspace = true
 ldap3 = { workspace = true, default-features = false, features = ["tls"] }
+log = { workspace = true, optional = true }
 serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true, optional = true }
 native-tls.workspace = true

+proxmox-access-control = { workspace = true, optional = true, features = ["impl"] }
+proxmox-auth-api = { workspace = true, optional = true }
+proxmox-product-config = { workspace = true, optional = true }
+proxmox-schema = { workspace = true, optional = true }
+proxmox-section-config = { workspace = true, optional = true }
+
 [dev-dependencies]
 proxmox-async.workspace = true
+
+[features]
+default = []
+types = [ ]
+sync = [
+    "types",
+    "dep:log",
+    "dep:serde_json",
+    "dep:proxmox-access-control",
+    "dep:proxmox-auth-api",
+    "dep:proxmox-product-config",
+    "dep:proxmox-section-config",
+    "dep:proxmox-schema"
+]
diff --git a/proxmox-ldap/debian/control b/proxmox-ldap/debian/control
index 1a263613..12e7e5ba 100644
--- a/proxmox-ldap/debian/control
+++ b/proxmox-ldap/debian/control
@@ -29,13 +29,42 @@ Depends:
  librust-native-tls-0.2+default-dev,
  librust-serde-1+default-dev,
  librust-serde-1+derive-dev
+Suggests:
+ librust-proxmox-ldap+sync-dev (= ${binary:Version})
 Provides:
  librust-proxmox-ldap+default-dev (= ${binary:Version}),
+ librust-proxmox-ldap+types-dev (= ${binary:Version}),
  librust-proxmox-ldap-1-dev (= ${binary:Version}),
  librust-proxmox-ldap-1+default-dev (= ${binary:Version}),
+ librust-proxmox-ldap-1+types-dev (= ${binary:Version}),
  librust-proxmox-ldap-1.0-dev (= ${binary:Version}),
  librust-proxmox-ldap-1.0+default-dev (= ${binary:Version}),
+ librust-proxmox-ldap-1.0+types-dev (= ${binary:Version}),
  librust-proxmox-ldap-1.0.0-dev (= ${binary:Version}),
- librust-proxmox-ldap-1.0.0+default-dev (= ${binary:Version})
+ librust-proxmox-ldap-1.0.0+default-dev (= ${binary:Version}),
+ librust-proxmox-ldap-1.0.0+types-dev (= ${binary:Version})
 Description: Proxmox library for LDAP authentication/synchronization - Rust source code
  Source code for Debianized Rust crate "proxmox-ldap"
+
+Package: librust-proxmox-ldap+sync-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-proxmox-ldap-dev (= ${binary:Version}),
+ librust-proxmox-ldap+types-dev (= ${binary:Version}),
+ librust-log-0.4+default-dev (>= 0.4.17-~~),
+ librust-proxmox-access-control-1+default-dev (>= 1.1.1-~~),
+ librust-proxmox-access-control-1+impl-dev (>= 1.1.1-~~),
+ librust-proxmox-auth-api-1+default-dev,
+ librust-proxmox-product-config-1+default-dev,
+ librust-proxmox-schema-5+default-dev,
+ librust-proxmox-section-config-3+default-dev (>= 3.1.0-~~),
+ librust-serde-json-1+default-dev
+Provides:
+ librust-proxmox-ldap-1+sync-dev (= ${binary:Version}),
+ librust-proxmox-ldap-1.0+sync-dev (= ${binary:Version}),
+ librust-proxmox-ldap-1.0.0+sync-dev (= ${binary:Version})
+Description: Proxmox library for LDAP authentication/synchronization - feature "sync"
+ This metapackage enables feature "sync" for the Rust proxmox-ldap crate, by
+ pulling in any additional dependencies needed by that feature.
diff --git a/proxmox-ldap/debian/copyright b/proxmox-ldap/debian/copyright
index 0d9eab3e..1ea8a56b 100644
--- a/proxmox-ldap/debian/copyright
+++ b/proxmox-ldap/debian/copyright
@@ -2,7 +2,7 @@ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/

 Files:
  *
-Copyright: 2019 - 2023 Proxmox Server Solutions GmbH <support@proxmox.com>
+Copyright: 2019 - 2025 Proxmox Server Solutions GmbH <support@proxmox.com>
 License: AGPL-3.0-or-later
  This program is free software: you can redistribute it and/or modify it under
  the terms of the GNU Affero General Public License as published by the Free
diff --git a/proxmox-ldap/src/lib.rs b/proxmox-ldap/src/lib.rs
index 31f118ad..12d88291 100644
--- a/proxmox-ldap/src/lib.rs
+++ b/proxmox-ldap/src/lib.rs
@@ -14,6 +14,12 @@ use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntr
 use native_tls::{Certificate, TlsConnector, TlsConnectorBuilder};
 use serde::{Deserialize, Serialize};

+#[cfg(feature = "sync")]
+pub mod sync;
+
+#[cfg(feature = "types")]
+pub mod types;
+
 #[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Debug)]
 /// LDAP connection security
 pub enum ConnectionMode {
diff --git a/proxmox-ldap/src/sync.rs b/proxmox-ldap/src/sync.rs
new file mode 100644
index 00000000..3d5bd16b
--- /dev/null
+++ b/proxmox-ldap/src/sync.rs
@@ -0,0 +1,496 @@
+use std::collections::HashSet;
+
+use anyhow::{format_err, Context, Error};
+
+use proxmox_access_control::acl::AclTree;
+use proxmox_access_control::types::{
+    ApiToken, User, EMAIL_SCHEMA, FIRST_NAME_SCHEMA, LAST_NAME_SCHEMA,
+};
+use proxmox_auth_api::types::{Authid, Realm, Userid};
+use proxmox_product_config::ApiLockGuard;
+use proxmox_schema::{ApiType, Schema};
+use proxmox_section_config::SectionConfigData;
+
+use crate::types::{
+    AdRealmConfig, LdapRealmConfig, RemoveVanished, SyncAttributes, SyncDefaultsOptions,
+    REMOVE_VANISHED_ARRAY, USER_CLASSES_ARRAY,
+};
+use crate::{Config, Connection, SearchResult};
+
+/// Implementation for syncing Active Directory realms. Merely a thin wrapper over
+/// `LdapRealmSyncJob`, as AD is just LDAP with some special requirements.
+pub struct AdRealmSyncJob(LdapRealmSyncJob);
+
+impl AdRealmSyncJob {
+    pub fn new(
+        realm: Realm,
+        realm_config: AdRealmConfig,
+        ldap_config: Config,
+        override_settings: &GeneralSyncSettingsOverride,
+        dry_run: bool,
+    ) -> Result<Self, Error> {
+        let sync_settings = GeneralSyncSettings::default()
+            .apply_config(realm_config.sync_defaults_options.as_deref())?
+            .apply_override(override_settings)?;
+        let sync_attributes = LdapSyncSettings::new(
+            "sAMAccountName",
+            realm_config.sync_attributes.as_deref(),
+            realm_config.user_classes.as_deref(),
+            realm_config.filter.as_deref(),
+        )?;
+
+        Ok(Self(LdapRealmSyncJob {
+            realm,
+            general_sync_settings: sync_settings,
+            ldap_sync_settings: sync_attributes,
+            ldap_config,
+            dry_run,
+        }))
+    }
+
+    pub async fn sync(&self) -> Result<(), Error> {
+        self.0.sync().await
+    }
+}
+
+/// Implementation for syncing LDAP realms
+pub struct LdapRealmSyncJob {
+    realm: Realm,
+    general_sync_settings: GeneralSyncSettings,
+    ldap_sync_settings: LdapSyncSettings,
+    ldap_config: Config,
+    dry_run: bool,
+}
+
+impl LdapRealmSyncJob {
+    /// Create new LdapRealmSyncJob
+    pub fn new(
+        realm: Realm,
+        realm_config: LdapRealmConfig,
+        ldap_config: Config,
+        override_settings: &GeneralSyncSettingsOverride,
+        dry_run: bool,
+    ) -> Result<Self, Error> {
+        let general_sync_settings = GeneralSyncSettings::default()
+            .apply_config(realm_config.sync_defaults_options.as_deref())?
+            .apply_override(override_settings)?;
+
+        let ldap_sync_settings = LdapSyncSettings::new(
+            &realm_config.user_attr,
+            realm_config.sync_attributes.as_deref(),
+            realm_config.user_classes.as_deref(),
+            realm_config.filter.as_deref(),
+        )?;
+
+        Ok(Self {
+            realm,
+            general_sync_settings,
+            ldap_sync_settings,
+            ldap_config,
+            dry_run,
+        })
+    }
+
+    /// Perform realm synchronization
+    pub async fn sync(&self) -> Result<(), Error> {
+        if self.dry_run {
+            log::info!("this is a DRY RUN - changes will not be persisted");
+        }
+
+        let ldap = Connection::new(self.ldap_config.clone());
+
+        let parameters = crate::SearchParameters {
+            attributes: self.ldap_sync_settings.attributes.clone(),
+            user_classes: self.ldap_sync_settings.user_classes.clone(),
+            user_filter: self.ldap_sync_settings.user_filter.clone(),
+        };
+
+        let users = ldap.search_entities(&parameters).await?;
+        self.update_user_config(&users)?;
+
+        Ok(())
+    }
+
+    fn update_user_config(&self, users: &[SearchResult]) -> Result<(), Error> {
+        let user_lock = proxmox_access_control::user::lock_config()?;
+        let acl_lock = proxmox_access_control::acl::lock_config()?;
+
+        let (mut user_config, _digest) = proxmox_access_control::user::config()?;
+        let (mut tree, _) = proxmox_access_control::acl::config()?;
+
+        let retrieved_users = self.create_or_update_users(&mut user_config, &user_lock, users)?;
+
+        if self.general_sync_settings.should_remove_entries() {
+            let vanished_users =
+                self.compute_vanished_users(&user_config, &user_lock, &retrieved_users)?;
+
+            self.delete_users(
+                &mut user_config,
+                &user_lock,
+                &mut tree,
+                &acl_lock,
+                &vanished_users,
+            )?;
+        }
+
+        if !self.dry_run {
+            proxmox_access_control::user::save_config(&user_config)
+                .context("could not store user config")?;
+            proxmox_access_control::acl::save_config(&tree)
+                .context("could not store acl config")?;
+        }
+
+        Ok(())
+    }
+
+    fn create_or_update_users(
+        &self,
+        user_config: &mut SectionConfigData,
+        _user_lock: &ApiLockGuard,
+        users: &[SearchResult],
+    ) -> Result<HashSet<Userid>, Error> {
+        let mut retrieved_users = HashSet::new();
+
+        for result in users {
+            let user_id_attribute = &self.ldap_sync_settings.user_attr;
+
+            let result = {
+                let username = result
+                    .attributes
+                    .get(user_id_attribute)
+                    .ok_or_else(|| {
+                        format_err!(
+                            "userid attribute `{user_id_attribute}` not in LDAP search result"
+                        )
+                    })?
+                    .first()
+                    .context("userid attribute array is empty")?
+                    .clone();
+
+                let username = format!("{username}@{realm}", realm = self.realm.as_str());
+
+                let userid: Userid = username
+                    .parse()
+                    .map_err(|err| format_err!("could not parse username `{username}` - {err}"))?;
+                retrieved_users.insert(userid.clone());
+
+                self.create_or_update_user(user_config, &userid, result)?;
+                anyhow::Ok(())
+            };
+
+            if let Err(e) = result {
+                log::info!("could not create/update user: {e}");
+            }
+        }
+
+        Ok(retrieved_users)
+    }
+
+    fn create_or_update_user(
+        &self,
+        user_config: &mut SectionConfigData,
+        userid: &Userid,
+        result: &SearchResult,
+    ) -> Result<(), Error> {
+        let existing_user = user_config.lookup::<User>("user", userid.as_str()).ok();
+        let new_or_updated_user =
+            self.construct_or_update_user(result, userid, existing_user.as_ref());
+
+        if let Some(existing_user) = existing_user {
+            if existing_user != new_or_updated_user {
+                log::info!("updating user {}", new_or_updated_user.userid.as_str());
+            }
+        } else {
+            log::info!("creating user {}", new_or_updated_user.userid.as_str());
+        }
+
+        user_config.set_data(
+            new_or_updated_user.userid.as_str(),
+            "user",
+            &new_or_updated_user,
+        )?;
+        Ok(())
+    }
+
+    fn construct_or_update_user(
+        &self,
+        result: &SearchResult,
+        userid: &Userid,
+        existing_user: Option<&User>,
+    ) -> User {
+        let lookup = |attribute: &str, ldap_attribute: Option<&String>, schema: &'static Schema| {
+            let value = result.attributes.get(ldap_attribute?)?.first()?;
+            let schema = schema.unwrap_string_schema();
+
+            if let Err(e) = schema.check_constraints(value) {
+                log::warn!("{userid}: ignoring attribute `{attribute}`: {e}");
+
+                None
+            } else {
+                Some(value.clone())
+            }
+        };
+
+        User {
+            userid: userid.clone(),
+            comment: existing_user.as_ref().and_then(|u| u.comment.clone()),
+            enable: existing_user
+                .and_then(|o| o.enable)
+                .or(Some(self.general_sync_settings.enable_new)),
+            expire: existing_user.and_then(|u| u.expire).or(Some(0)),
+            firstname: lookup(
+                "firstname",
+                self.ldap_sync_settings.firstname_attr.as_ref(),
+                &FIRST_NAME_SCHEMA,
+            )
+            .or_else(|| {
+                if !self.general_sync_settings.should_remove_properties() {
+                    existing_user.and_then(|o| o.firstname.clone())
+                } else {
+                    None
+                }
+            }),
+            lastname: lookup(
+                "lastname",
+                self.ldap_sync_settings.lastname_attr.as_ref(),
+                &LAST_NAME_SCHEMA,
+            )
+            .or_else(|| {
+                if !self.general_sync_settings.should_remove_properties() {
+                    existing_user.and_then(|o| o.lastname.clone())
+                } else {
+                    None
+                }
+            }),
+            email: lookup(
+                "email",
+                self.ldap_sync_settings.email_attr.as_ref(),
+                &EMAIL_SCHEMA,
+            )
+            .or_else(|| {
+                if !self.general_sync_settings.should_remove_properties() {
+                    existing_user.and_then(|o| o.email.clone())
+                } else {
+                    None
+                }
+            }),
+        }
+    }
+
+    fn compute_vanished_users(
+        &self,
+        user_config: &SectionConfigData,
+        _user_lock: &ApiLockGuard,
+        synced_users: &HashSet<Userid>,
+    ) -> Result<Vec<Userid>, Error> {
+        Ok(user_config
+            .convert_to_typed_array::<User>("user")?
+            .into_iter()
+            .filter(|user| {
+                user.userid.realm() == self.realm && !synced_users.contains(&user.userid)
+            })
+            .map(|user| user.userid)
+            .collect())
+    }
+
+    fn delete_users(
+        &self,
+        user_config: &mut SectionConfigData,
+        _user_lock: &ApiLockGuard,
+        acl_config: &mut AclTree,
+        _acl_lock: &ApiLockGuard,
+        to_delete: &[Userid],
+    ) -> Result<(), Error> {
+        for userid in to_delete {
+            log::info!("deleting user {}", userid.as_str());
+
+            // Delete the user
+            user_config.sections.remove(userid.as_str());
+
+            if self.general_sync_settings.should_remove_acls() {
+                let auth_id = userid.clone().into();
+                // Delete the user's ACL entries
+                acl_config.delete_authid(&auth_id);
+            }
+
+            let user_tokens: Vec<ApiToken> = user_config
+                .convert_to_typed_array::<ApiToken>("token")?
+                .into_iter()
+                .filter(|token| token.tokenid.user().eq(userid))
+                .collect();
+
+            // Delete tokens, token secrets and ACLs corresponding to all tokens for a user
+            for token in user_tokens {
+                if let Some(name) = token.tokenid.tokenname() {
+                    let tokenid = Authid::from((userid.clone(), Some(name.to_owned())));
+                    let tokenid_string = tokenid.to_string();
+
+                    user_config.sections.remove(&tokenid_string);
+
+                    if !self.dry_run {
+                        if let Err(e) =
+                            proxmox_access_control::token_shadow::delete_secret(&tokenid)
+                        {
+                            log::warn!("could not delete token for user {userid}: {e}",)
+                        }
+                    }
+
+                    if self.general_sync_settings.should_remove_acls() {
+                        acl_config.delete_authid(&tokenid);
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+/// General realm sync settings - Override for manual invocation
+pub struct GeneralSyncSettingsOverride {
+    pub remove_vanished: Option<String>,
+    pub enable_new: Option<bool>,
+}
+
+/// General realm sync settings from the realm configuration
+struct GeneralSyncSettings {
+    remove_vanished: Vec<RemoveVanished>,
+    enable_new: bool,
+}
+
+/// LDAP-specific realm sync settings from the realm configuration
+struct LdapSyncSettings {
+    user_attr: String,
+    firstname_attr: Option<String>,
+    lastname_attr: Option<String>,
+    email_attr: Option<String>,
+    attributes: Vec<String>,
+    user_classes: Vec<String>,
+    user_filter: Option<String>,
+}
+
+impl LdapSyncSettings {
+    fn new(
+        user_attr: &str,
+        sync_attributes: Option<&str>,
+        user_classes: Option<&str>,
+        user_filter: Option<&str>,
+    ) -> Result<Self, Error> {
+        let mut attributes = vec![user_attr.to_owned()];
+
+        let mut email = None;
+        let mut firstname = None;
+        let mut lastname = None;
+
+        if let Some(sync_attributes) = &sync_attributes {
+            let value = SyncAttributes::API_SCHEMA.parse_property_string(sync_attributes)?;
+            let sync_attributes: SyncAttributes = serde_json::from_value(value)?;
+
+            email.clone_from(&sync_attributes.email);
+            firstname.clone_from(&sync_attributes.firstname);
+            lastname.clone_from(&sync_attributes.lastname);
+
+            if let Some(email_attr) = &sync_attributes.email {
+                attributes.push(email_attr.clone());
+            }
+
+            if let Some(firstname_attr) = &sync_attributes.firstname {
+                attributes.push(firstname_attr.clone());
+            }
+
+            if let Some(lastname_attr) = &sync_attributes.lastname {
+                attributes.push(lastname_attr.clone());
+            }
+        }
+
+        let user_classes = if let Some(user_classes) = &user_classes {
+            let a = USER_CLASSES_ARRAY.parse_property_string(user_classes)?;
+            serde_json::from_value(a)?
+        } else {
+            vec![
+                "posixaccount".into(),
+                "person".into(),
+                "inetorgperson".into(),
+                "user".into(),
+            ]
+        };
+
+        Ok(Self {
+            user_attr: user_attr.to_owned(),
+            firstname_attr: firstname,
+            lastname_attr: lastname,
+            email_attr: email,
+            attributes,
+            user_classes,
+            user_filter: user_filter.map(ToOwned::to_owned),
+        })
+    }
+}
+
+impl Default for GeneralSyncSettings {
+    fn default() -> Self {
+        Self {
+            remove_vanished: Default::default(),
+            enable_new: true,
+        }
+    }
+}
+
+impl GeneralSyncSettings {
+    fn apply_config(self, sync_defaults_options: Option<&str>) -> Result<Self, Error> {
+        let mut enable_new = None;
+        let mut remove_vanished = None;
+
+        if let Some(sync_defaults_options) = sync_defaults_options {
+            let sync_defaults_options = Self::parse_sync_defaults_options(sync_defaults_options)?;
+
+            enable_new = sync_defaults_options.enable_new;
+
+            if let Some(vanished) = sync_defaults_options.remove_vanished.as_deref() {
+                remove_vanished = Some(Self::parse_remove_vanished(vanished)?);
+            }
+        }
+
+        Ok(Self {
+            enable_new: enable_new.unwrap_or(self.enable_new),
+            remove_vanished: remove_vanished.unwrap_or(self.remove_vanished),
+        })
+    }
+
+    fn apply_override(self, override_config: &GeneralSyncSettingsOverride) -> Result<Self, Error> {
+        let enable_new = override_config.enable_new;
+        let remove_vanished = if let Some(s) = override_config.remove_vanished.as_deref() {
+            Some(Self::parse_remove_vanished(s)?)
+        } else {
+            None
+        };
+
+        Ok(Self {
+            enable_new: enable_new.unwrap_or(self.enable_new),
+            remove_vanished: remove_vanished.unwrap_or(self.remove_vanished),
+        })
+    }
+
+    fn parse_sync_defaults_options(s: &str) -> Result<SyncDefaultsOptions, Error> {
+        let value = SyncDefaultsOptions::API_SCHEMA.parse_property_string(s)?;
+        Ok(serde_json::from_value(value)?)
+    }
+
+    fn parse_remove_vanished(s: &str) -> Result<Vec<RemoveVanished>, Error> {
+        Ok(serde_json::from_value(
+            REMOVE_VANISHED_ARRAY.parse_property_string(s)?,
+        )?)
+    }
+
+    fn should_remove_properties(&self) -> bool {
+        self.remove_vanished.contains(&RemoveVanished::Properties)
+    }
+
+    fn should_remove_entries(&self) -> bool {
+        self.remove_vanished.contains(&RemoveVanished::Entry)
+    }
+
+    fn should_remove_acls(&self) -> bool {
+        self.remove_vanished.contains(&RemoveVanished::Acl)
+    }
+}
diff --git a/proxmox-ldap/src/types.rs b/proxmox-ldap/src/types.rs
new file mode 100644
index 00000000..8c7f2dca
--- /dev/null
+++ b/proxmox-ldap/src/types.rs
@@ -0,0 +1,317 @@
+use serde::{Deserialize, Serialize};
+
+use proxmox_schema::api_types::{COMMENT_SCHEMA, SAFE_ID_FORMAT};
+use proxmox_schema::{api, ApiStringFormat, ApiType, ArraySchema, Schema, StringSchema, Updater};
+
+pub const REALM_ID_SCHEMA: Schema = StringSchema::new("Realm name.")
+    .format(&SAFE_ID_FORMAT)
+    .min_length(2)
+    .max_length(32)
+    .schema();
+
+#[api()]
+#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+/// LDAP connection type
+pub enum LdapMode {
+    /// Plaintext LDAP connection
+    #[serde(rename = "ldap")]
+    #[default]
+    Ldap,
+    /// Secure STARTTLS connection
+    #[serde(rename = "ldap+starttls")]
+    StartTls,
+    /// Secure LDAPS connection
+    #[serde(rename = "ldaps")]
+    Ldaps,
+}
+
+#[api(
+    properties: {
+        "realm": {
+            schema: REALM_ID_SCHEMA,
+        },
+        "comment": {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+        "default": {
+            optional: true,
+            default: false,
+        },
+        "verify": {
+            optional: true,
+            default: false,
+        },
+        "sync-defaults-options": {
+            schema: SYNC_DEFAULTS_STRING_SCHEMA,
+            optional: true,
+        },
+        "sync-attributes": {
+            schema: SYNC_ATTRIBUTES_SCHEMA,
+            optional: true,
+        },
+        "user-classes" : {
+            optional: true,
+            schema: USER_CLASSES_SCHEMA,
+        },
+        "base-dn" : {
+            schema: LDAP_DOMAIN_SCHEMA,
+        },
+        "bind-dn" : {
+            schema: LDAP_DOMAIN_SCHEMA,
+            optional: true,
+        }
+    },
+)]
+#[derive(Serialize, Deserialize, Updater, Clone)]
+#[serde(rename_all = "kebab-case")]
+/// LDAP configuration properties.
+pub struct LdapRealmConfig {
+    #[updater(skip)]
+    pub realm: String,
+    /// LDAP server address
+    pub server1: String,
+    /// Fallback LDAP server address
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub server2: Option<String>,
+    /// Port
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub port: Option<u16>,
+    /// Base domain name. Users are searched under this domain using a `subtree search`.
+    pub base_dn: String,
+    /// Username attribute. Used to map a ``userid`` to LDAP to an LDAP ``dn``.
+    pub user_attr: String,
+    /// Comment
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+    /// True if you want this to be the default realm selected on login.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub default: Option<bool>,
+    /// Connection security
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mode: Option<LdapMode>,
+    /// Verify server certificate
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub verify: Option<bool>,
+    /// CA certificate to use for the server. The path can point to
+    /// either a file, or a directory. If it points to a file,
+    /// the PEM-formatted X.509 certificate stored at the path
+    /// will be added as a trusted certificate.
+    /// If the path points to a directory,
+    /// the directory replaces the system's default certificate
+    /// store at `/etc/ssl/certs` - Every file in the directory
+    /// will be loaded as a trusted certificate.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub capath: Option<String>,
+    /// Bind domain to use for looking up users
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub bind_dn: Option<String>,
+    /// Custom LDAP search filter for user sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub filter: Option<String>,
+    /// Default options for LDAP sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sync_defaults_options: Option<String>,
+    /// List of attributes to sync from LDAP to user config
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sync_attributes: Option<String>,
+    /// User ``objectClass`` classes to sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub user_classes: Option<String>,
+}
+
+#[api(
+    properties: {
+        "realm": {
+            schema: REALM_ID_SCHEMA,
+        },
+        "comment": {
+            optional: true,
+            schema: COMMENT_SCHEMA,
+        },
+        "default": {
+            optional: true,
+            default: false,
+        },
+        "verify": {
+            optional: true,
+            default: false,
+        },
+        "sync-defaults-options": {
+            schema: SYNC_DEFAULTS_STRING_SCHEMA,
+            optional: true,
+        },
+        "sync-attributes": {
+            schema: SYNC_ATTRIBUTES_SCHEMA,
+            optional: true,
+        },
+        "user-classes" : {
+            optional: true,
+            schema: USER_CLASSES_SCHEMA,
+        },
+        "base-dn" : {
+            schema: LDAP_DOMAIN_SCHEMA,
+            optional: true,
+        },
+        "bind-dn" : {
+            schema: LDAP_DOMAIN_SCHEMA,
+            optional: true,
+        }
+    },
+)]
+#[derive(Serialize, Deserialize, Updater, Clone)]
+#[serde(rename_all = "kebab-case")]
+/// AD realm configuration properties.
+pub struct AdRealmConfig {
+    #[updater(skip)]
+    pub realm: String,
+    /// AD server address
+    pub server1: String,
+    /// Fallback AD server address
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub server2: Option<String>,
+    /// AD server Port
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub port: Option<u16>,
+    /// Base domain name. Users are searched under this domain using a `subtree search`.
+    /// Expected to be set only internally to `defaultNamingContext` of the AD server, but can be
+    /// overridden if the need arises.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub base_dn: Option<String>,
+    /// Comment
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+    /// True if you want this to be the default realm selected on login.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub default: Option<bool>,
+    /// Connection security
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub mode: Option<LdapMode>,
+    /// Verify server certificate
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub verify: Option<bool>,
+    /// CA certificate to use for the server. The path can point to
+    /// either a file, or a directory. If it points to a file,
+    /// the PEM-formatted X.509 certificate stored at the path
+    /// will be added as a trusted certificate.
+    /// If the path points to a directory,
+    /// the directory replaces the system's default certificate
+    /// store at `/etc/ssl/certs` - Every file in the directory
+    /// will be loaded as a trusted certificate.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub capath: Option<String>,
+    /// Bind domain to use for looking up users
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub bind_dn: Option<String>,
+    /// Custom LDAP search filter for user sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub filter: Option<String>,
+    /// Default options for AD sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sync_defaults_options: Option<String>,
+    /// List of LDAP attributes to sync from AD to user config
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sync_attributes: Option<String>,
+    /// User ``objectClass`` classes to sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub user_classes: Option<String>,
+}
+
+#[api(
+    properties: {
+        "remove-vanished": {
+            optional: true,
+            schema: REMOVE_VANISHED_SCHEMA,
+        },
+    },
+
+)]
+#[derive(Serialize, Deserialize, Updater, Default, Debug)]
+#[serde(rename_all = "kebab-case")]
+/// Default options for LDAP synchronization runs
+pub struct SyncDefaultsOptions {
+    /// How to handle vanished properties/users
+    pub remove_vanished: Option<String>,
+    /// Enable new users after sync
+    pub enable_new: Option<bool>,
+}
+
+#[api()]
+#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+/// remove-vanished options
+pub enum RemoveVanished {
+    /// Delete ACLs for vanished users
+    Acl,
+    /// Remove vanished users
+    Entry,
+    /// Remove vanished properties from users (e.g. email)
+    Properties,
+}
+
+pub const LDAP_DOMAIN_SCHEMA: Schema = StringSchema::new("LDAP Domain").schema();
+
+pub const SYNC_DEFAULTS_STRING_SCHEMA: Schema = StringSchema::new("sync defaults options")
+    .format(&ApiStringFormat::PropertyString(
+        &SyncDefaultsOptions::API_SCHEMA,
+    ))
+    .schema();
+
+const REMOVE_VANISHED_DESCRIPTION: &str =
+    "A semicolon-separated list of things to remove when they or the user \
+vanishes during user synchronization. The following values are possible: ``entry`` removes the \
+user when not returned from the sync; ``properties`` removes any  \
+properties on existing user that do not appear in the source. \
+``acl`` removes ACLs when the user is not returned from the sync.";
+
+pub const REMOVE_VANISHED_SCHEMA: Schema = StringSchema::new(REMOVE_VANISHED_DESCRIPTION)
+    .format(&ApiStringFormat::PropertyString(&REMOVE_VANISHED_ARRAY))
+    .schema();
+
+pub const REMOVE_VANISHED_ARRAY: Schema = ArraySchema::new(
+    "Array of remove-vanished options",
+    &RemoveVanished::API_SCHEMA,
+)
+.min_length(1)
+.schema();
+
+#[api()]
+#[derive(Serialize, Deserialize, Updater, Default, Debug)]
+#[serde(rename_all = "kebab-case")]
+/// Determine which LDAP attributes should be synced to which user attributes
+pub struct SyncAttributes {
+    /// Name of the LDAP attribute containing the user's email address
+    pub email: Option<String>,
+    /// Name of the LDAP attribute containing the user's first name
+    pub firstname: Option<String>,
+    /// Name of the LDAP attribute containing the user's last name
+    pub lastname: Option<String>,
+}
+
+const SYNC_ATTRIBUTES_TEXT: &str = "Comma-separated list of key=value pairs for specifying \
+which LDAP attributes map to which peat user field. For example, \
+to map the LDAP attribute ``mail`` to peat's ``email``, write \
+``email=mail``.";
+
+pub const SYNC_ATTRIBUTES_SCHEMA: Schema = StringSchema::new(SYNC_ATTRIBUTES_TEXT)
+    .format(&ApiStringFormat::PropertyString(
+        &SyncAttributes::API_SCHEMA,
+    ))
+    .schema();
+
+pub const USER_CLASSES_ARRAY: Schema = ArraySchema::new(
+    "Array of user classes",
+    &StringSchema::new("user class").schema(),
+)
+.min_length(1)
+.schema();
+
+const USER_CLASSES_TEXT: &str = "Comma-separated list of allowed objectClass values for \
+user synchronization. For instance, if ``user-classes`` is set to ``person,user``, \
+then user synchronization will consider all LDAP entities \
+where ``objectClass: person`` `or` ``objectClass: user``.";
+
+pub const USER_CLASSES_SCHEMA: Schema = StringSchema::new(USER_CLASSES_TEXT)
+    .format(&ApiStringFormat::PropertyString(&USER_CLASSES_ARRAY))
+    .default("inetorgperson,posixaccount,person,user")
+    .schema();
--
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] 13+ messages in thread

* [pdm-devel] [PATCH yew-comp 1/5] auth_view: add default column and allow setting ldap realms as default
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH proxmox 1/1] ldap: add types and sync features Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 2/5] utils: add pdm realm to `get_auth_domain_info` Shannon Sterz
                   ` (9 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 src/auth_edit_ldap.rs   |  1 +
 src/auth_view.rs        | 16 +++++++++++++++-
 src/common_api_types.rs |  3 +++
 3 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/src/auth_edit_ldap.rs b/src/auth_edit_ldap.rs
index 9e5d3a2..4671a1e 100644
--- a/src/auth_edit_ldap.rs
+++ b/src/auth_edit_ldap.rs
@@ -182,6 +182,7 @@ fn render_general_form(form_ctx: FormContext, props: AuthEditLDAP) -> Html {
                 .required(true)
                 .placeholder("cn=Users,dc=company,dc=net"),
         )
+        .with_field(tr!("Default Realm"), Checkbox::new().name("default"));
         .with_right_field(tr!("Fallback Server"), Field::new().name("server2"))
         .with_field(
             tr!("User Attribute Name"),
diff --git a/src/auth_view.rs b/src/auth_view.rs
index 4a0f22f..4d6e143 100644
--- a/src/auth_view.rs
+++ b/src/auth_view.rs
@@ -12,7 +12,7 @@ use pwt::state::{Selection, Store};
 use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
 use pwt::widget::menu::{Menu, MenuButton, MenuItem};
 
-use pwt::widget::{Button, Toolbar};
+use pwt::widget::{Button, Fa, Toolbar};
 
 use pwt_macros::builder;
 
@@ -298,6 +298,20 @@ thread_local! {
                 a.ty.cmp(&b.ty)
             })
             .into(),
+        DataTableColumn::new(tr!("Default"))
+            .width("100px")
+            .render(|item: &BasicRealmInfo| {
+                if item.default.unwrap_or_default() {
+                    Fa::new("check").into()
+                } else {
+                    Fa::new("times").into()
+                }
+            })
+            .justify("center")
+            .sorter(|a: &BasicRealmInfo, b: &BasicRealmInfo| {
+                a.default.unwrap_or_default().cmp(&b.default.unwrap_or_default())
+            })
+            .into(),
         DataTableColumn::new("Comment")
             .flex(1)
             .render(|record: &BasicRealmInfo| {
diff --git a/src/common_api_types.rs b/src/common_api_types.rs
index 03f7707..c247569 100644
--- a/src/common_api_types.rs
+++ b/src/common_api_types.rs
@@ -14,6 +14,9 @@ pub struct BasicRealmInfo {
     pub realm: String,
     #[serde(rename = "type")]
     pub ty: String,
+    /// True if it is the default realm
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub default: Option<bool>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub comment: Option<String>,
 }
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH yew-comp 2/5] utils: add pdm realm to `get_auth_domain_info`
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH proxmox 1/1] ldap: add types and sync features Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 1/5] auth_view: add default column and allow setting ldap realms as default Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 3/5] auth_view/auth_edit_ldap: add support for active directory realms Shannon Sterz
                   ` (8 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

so that the edit and remove buttons in the `AuthView` are properly
disabled.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 src/utils.rs | 15 +++------------
 1 file changed, 3 insertions(+), 12 deletions(-)

diff --git a/src/utils.rs b/src/utils.rs
index 1a4ad40..bfdbccd 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -269,7 +269,8 @@ pub fn get_auth_domain_info(ty: &str) -> Option<AuthDomainInfo> {
             sync: false,
         });
     }
-    if ty == "pve" {
+
+    if matches!(ty, "pve" | "pbs" | "pdm") {
         return Some(AuthDomainInfo {
             ty: ty.to_string(),
             //description: tr!("Proxmox VE authentication server"),
@@ -280,17 +281,7 @@ pub fn get_auth_domain_info(ty: &str) -> Option<AuthDomainInfo> {
             sync: false,
         });
     }
-    if ty == "pbs" {
-        return Some(AuthDomainInfo {
-            ty: ty.to_string(),
-            //description: tr!("Proxmox Backup authentication server"),
-            add: false,
-            edit: false,
-            tfa: true,
-            pwchange: true,
-            sync: false,
-        });
-    }
+
     if ty == "openid" {
         return Some(AuthDomainInfo {
             ty: ty.to_string(),
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH yew-comp 3/5] auth_view/auth_edit_ldap: add support for active directory realms
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (2 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 2/5] utils: add pdm realm to `get_auth_domain_info` Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 4/5] auth_edit_ldap: add helpers to properly edit ad & ldap realms Shannon Sterz
                   ` (7 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

by adapting the existing AuthEditLdap component to allow editing AD
realms as well. after all, AD realms are just LDAP realms with some
peculiarities.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 src/auth_edit_ldap.rs | 56 ++++++++++++++++++++++++++++++-------------
 src/auth_view.rs      | 54 ++++++++++++++++++++++++++++++++++-------
 src/utils.rs          |  3 ++-
 3 files changed, 87 insertions(+), 26 deletions(-)

diff --git a/src/auth_edit_ldap.rs b/src/auth_edit_ldap.rs
index 4671a1e..162f828 100644
--- a/src/auth_edit_ldap.rs
+++ b/src/auth_edit_ldap.rs
@@ -34,6 +34,11 @@ pub struct AuthEditLDAP {
     #[builder(IntoPropValue, into_prop_value)]
     #[prop_or_default]
     pub realm: Option<AttrValue>,
+
+    /// Whether this panel is for an Active Directory realm
+    #[builder(IntoPropValue, into_prop_value)]
+    #[prop_or_default]
+    pub ad_realm: Option<bool>,
 }
 
 impl Default for AuthEditLDAP {
@@ -162,7 +167,7 @@ fn render_general_form(form_ctx: FormContext, props: AuthEditLDAP) -> Html {
         .map(|v| matches!(v.as_str(), Some("ldap+starttls") | Some("ldaps")))
         .unwrap_or(false);
 
-    InputPanel::new()
+    let mut input_panel = InputPanel::new()
         .class(Flex::Fill)
         .class(Overflow::Auto)
         .padding(4)
@@ -175,22 +180,28 @@ fn render_general_form(form_ctx: FormContext, props: AuthEditLDAP) -> Html {
                 .submit(!is_edit),
         )
         .with_right_field(tr!("Server"), Field::new().name("server1").required(true))
-        .with_field(
-            tr!("Base Domain Name"),
-            Field::new()
-                .name("base-dn")
-                .required(true)
-                .placeholder("cn=Users,dc=company,dc=net"),
-        )
         .with_field(tr!("Default Realm"), Checkbox::new().name("default"));
+
+    if !props.ad_realm.unwrap_or_default() {
+        input_panel = input_panel
+            .with_field(
+                tr!("Base Domain Name"),
+                Field::new()
+                    .name("base-dn")
+                    .required(true)
+                    .placeholder("cn=Users,dc=company,dc=net"),
+            )
+            .with_field(
+                tr!("User Attribute Name"),
+                Field::new()
+                    .name("user-attr")
+                    .required(true)
+                    .placeholder("uid / sAMAccountName"),
+            )
+    }
+
+    input_panel
         .with_right_field(tr!("Fallback Server"), Field::new().name("server2"))
-        .with_field(
-            tr!("User Attribute Name"),
-            Field::new()
-                .name("user-attr")
-                .required(true)
-                .placeholder("uid / sAMAccountName"),
-        )
         .with_right_field(
             tr!("Port"),
             Number::<u16>::new()
@@ -228,7 +239,12 @@ fn render_general_form(form_ctx: FormContext, props: AuthEditLDAP) -> Html {
                 .name("bind-dn")
                 .required(!anonymous_search)
                 .disabled(anonymous_search)
-                .placeholder("cn=user,dc=company,dc=net"),
+                .placeholder(
+                    props
+                        .ad_realm
+                        .map(|_| "user@company.net")
+                        .unwrap_or("cn=user,dc=company,dc=net"),
+                ),
         )
         .with_right_field(
             tr!("Verify Certificate"),
@@ -274,7 +290,13 @@ impl Component for ProxmoxAuthEditLDAP {
             }
         };
 
-        EditWindow::new(action + ": " + &tr!("LDAP Server"))
+        let title = if props.ad_realm.unwrap_or_default() {
+            tr!("Active Directory Server")
+        } else {
+            tr!("LDAP Server")
+        };
+
+        EditWindow::new(action + ": " + &title)
             .loader(
                 props
                     .realm
diff --git a/src/auth_view.rs b/src/auth_view.rs
index 4d6e143..a70e80b 100644
--- a/src/auth_view.rs
+++ b/src/auth_view.rs
@@ -42,6 +42,11 @@ pub struct AuthView {
     #[builder(IntoPropValue, into_prop_value)]
     #[prop_or_default]
     ldap_base_url: Option<AttrValue>,
+
+    /// Allow to add/edit LDAP entries
+    #[builder(IntoPropValue, into_prop_value)]
+    #[prop_or_default]
+    ad_base_url: Option<AttrValue>,
 }
 
 impl Default for AuthView {
@@ -58,10 +63,12 @@ impl AuthView {
 
 #[derive(PartialEq)]
 pub enum ViewState {
+    AddAd,
     AddLDAP,
     AddOpenID,
     EditOpenID(AttrValue),
     EditLDAP(AttrValue),
+    EditAd(AttrValue),
 }
 
 pub enum Msg {
@@ -146,14 +153,21 @@ impl LoadableComponent for ProxmoxAuthView {
                     Some(info) => info,
                     None => return true,
                 };
-                if props.openid_base_url.is_some() && info.ty == "openid" {
-                    ctx.link()
-                        .change_view(Some(ViewState::EditOpenID(info.realm.clone().into())));
-                }
-                if props.ldap_base_url.is_some() && info.ty == "ldap" {
-                    ctx.link()
-                        .change_view(Some(ViewState::EditLDAP(info.realm.into())));
-                }
+
+                let view = match info.ty.as_str() {
+                    "openid" if props.openid_base_url.is_some() => {
+                        Some(ViewState::EditOpenID(info.realm.into()))
+                    }
+                    "ldap" if props.ldap_base_url.is_some() => {
+                        Some(ViewState::EditLDAP(info.realm.into()))
+                    }
+                    "ad" if props.ad_base_url.is_some() => {
+                        Some(ViewState::EditAd(info.realm.into()))
+                    }
+                    _ => return true,
+                };
+
+                ctx.link().change_view(view);
                 true
             }
             Msg::Sync => {
@@ -182,6 +196,14 @@ impl LoadableComponent for ProxmoxAuthView {
 
         let mut add_menu = Menu::new();
 
+        if props.ad_base_url.is_some() {
+            add_menu.add_item(
+                MenuItem::new(tr!("Active Directory Server"))
+                    .icon_class("fa fa-fw fa-address-book-o")
+                    .on_select(ctx.link().change_view_callback(|_| Some(ViewState::AddAd))),
+            );
+        }
+
         if props.ldap_base_url.is_some() {
             add_menu.add_item(
                 MenuItem::new(tr!("LDAP Server"))
@@ -248,6 +270,22 @@ impl LoadableComponent for ProxmoxAuthView {
         let props = ctx.props();
 
         match view_state {
+            ViewState::AddAd => Some(
+                AuthEditLDAP::new()
+                    .base_url(props.ad_base_url.clone().unwrap())
+                    .on_close(ctx.link().change_view_callback(|_| None))
+                    .ad_realm(true)
+                    .into(),
+            ),
+            ViewState::EditAd(realm) => Some(
+                AuthEditLDAP::new()
+                    .base_url(props.ad_base_url.clone().unwrap())
+                    .realm(realm.clone())
+                    .on_close(ctx.link().change_view_callback(|_| None))
+                    .ad_realm(true)
+                    .into(),
+            ),
+
             ViewState::AddLDAP => Some(
                 AuthEditLDAP::new()
                     .base_url(props.ldap_base_url.clone().unwrap())
diff --git a/src/utils.rs b/src/utils.rs
index bfdbccd..544ed76 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -293,7 +293,8 @@ pub fn get_auth_domain_info(ty: &str) -> Option<AuthDomainInfo> {
             sync: false,
         });
     }
-    if ty == "ldap" {
+
+    if ty == "ldap" || ty == "ad" {
         return Some(AuthDomainInfo {
             ty: ty.to_string(),
             //description: tr!("LDAP Server"),
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH yew-comp 4/5] auth_edit_ldap: add helpers to properly edit ad & ldap realms
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (3 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 3/5] auth_view/auth_edit_ldap: add support for active directory realms Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 5/5] auth_view: implement syncing ldap and ad realms Shannon Sterz
                   ` (6 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

previously this view would run into issues when editing more complex
ldap and active directory realms. specifically:

- when editing a pre-existing realm and not opening the second tab of
the edit window, all attributes that would are configured through the
second tab would be removed. values for those fields would also not be
correctly loaded when opening that second tab. this issue stems from
how `FormContext` works and it requires rendering all tabs before
loading the form. fixed by specifying `force_render_all(true)` on the
TabPanel in the form

- properties specified via property strings (`sync-attributes` and
`sync-default-options`) where not properly formatted when submitting
them to the api, leading to errors warning about additional unknown
parameters. fixed by parsing the form and properly formatting the two
parameters.

- properties specified via property strings would not be correctly
loaded into the form as they weren't returned by the api in a way that
is compatible with how EditWindow's loader works. fixed by
implementing a loader that would properly parse these strings and
adapting them to the expected format.

- removing the last setting from the `sync-attributes` or
`sync-defaults-options` property strings would not get properly
removed as these two were missing from the appropriate
`delete_empty_values()` call.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 src/auth_edit_ldap.rs | 108 ++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 105 insertions(+), 3 deletions(-)

diff --git a/src/auth_edit_ldap.rs b/src/auth_edit_ldap.rs
index 162f828..70fb638 100644
--- a/src/auth_edit_ldap.rs
+++ b/src/auth_edit_ldap.rs
@@ -1,9 +1,11 @@
 use std::rc::Rc;
 
 use anyhow::Error;
+use proxmox_client::ApiResponseData;
 use pwt::css::{Flex, Overflow};
 
 use pwt::widget::form::{Checkbox, Combobox, FormContext, InputType, Number};
+use serde_json::Value;
 use yew::html::{IntoEventCallback, IntoPropValue};
 use yew::virtual_dom::{VComp, VNode};
 
@@ -53,13 +55,109 @@ impl AuthEditLDAP {
     }
 }
 
+async fn load_realm(url: impl Into<String>) -> Result<ApiResponseData<Value>, Error> {
+    let mut response: ApiResponseData<Value> = crate::http_get_full(url, None).await?;
+
+    response.data["anonymous_search"] = Value::Bool(!response.data["bind-dn"].is_string());
+
+    if let Value::String(sync_default_options) = response.data["sync-defaults-options"].take() {
+        let split = sync_default_options.split(",");
+
+        for part in split {
+            let mut part = part.split("=");
+
+            match part.next() {
+                Some("enable-new") => {
+                    response.data["enable-new"] = Value::Bool(part.next() == Some("true"))
+                }
+                Some("remove-vanished") => {
+                    if let Some(part) = part.next() {
+                        for vanished_opt in part.split(";") {
+                            response.data[&format!("remove-vanished-{vanished_opt}")] =
+                                Value::Bool(true)
+                        }
+                    }
+                }
+                _ => {}
+            }
+        }
+    }
+
+    if let Value::String(sync_attributes) = response.data["sync-attributes"].take() {
+        let split = sync_attributes.split(",");
+
+        for opt in split {
+            let mut opt = opt.split("=");
+            if let (Some(name), Some(val)) = (opt.next(), opt.next()) {
+                response.data[name] = Value::String(val.to_string());
+            }
+        }
+    }
+
+    Ok(response)
+}
+
+fn format_sync_and_default_options(data: &mut Value) -> Value {
+    let mut sync_default_options: Option<String> = None;
+
+    if let Value::Bool(val) = data["enable-new"].take() {
+        sync_default_options = Some(format!("enable-new={val}"))
+    }
+
+    let mut remove_vanished: Vec<&str> = Vec::new();
+
+    for prop in ["acl", "entry", "properties"] {
+        let prop_name = format!("remove-vanished-{prop}");
+        if data[&prop_name].take() == Value::Bool(true) {
+            remove_vanished.push(prop);
+        }
+    }
+
+    if !remove_vanished.is_empty() {
+        let vanished = format!("remove-vanished={}", remove_vanished.join(";"));
+
+        sync_default_options = sync_default_options
+            .map(|f| format!("{f},{vanished}"))
+            .or(Some(vanished));
+    }
+
+    if let Some(defaults) = sync_default_options {
+        data["sync-defaults-options"] = Value::String(defaults);
+    }
+
+    let mut sync_attributes = Vec::new();
+
+    for attribute in ["firstname", "lastname", "email"] {
+        if let Value::String(val) = &data[attribute].take() {
+            sync_attributes.push(format!("{attribute}={val}"));
+        }
+    }
+
+    if !sync_attributes.is_empty() {
+        data["sync-attributes"] = Value::String(sync_attributes.join(","));
+    }
+
+    let mut new = serde_json::json!({});
+
+    for (param, v) in data.as_object().unwrap().iter() {
+        if !v.is_null() {
+            new[param] = v.clone();
+        }
+    }
+
+    new
+}
+
 async fn create_item(form_ctx: FormContext, base_url: String) -> Result<(), Error> {
-    let data = form_ctx.get_submit_data();
+    let mut data = form_ctx.get_submit_data();
+    let data = format_sync_and_default_options(&mut data);
     crate::http_post(base_url, Some(data)).await
 }
 
 async fn update_item(form_ctx: FormContext, base_url: String) -> Result<(), Error> {
-    let data = form_ctx.get_submit_data();
+    let mut data = form_ctx.get_submit_data();
+
+    let data = format_sync_and_default_options(&mut data);
 
     let data = delete_empty_values(
         &data,
@@ -71,6 +169,8 @@ async fn update_item(form_ctx: FormContext, base_url: String) -> Result<(), Erro
             "comment",
             "user-classes",
             "filter",
+            "sync-attributes",
+            "sync-defaults-options",
         ],
         true,
     );
@@ -96,6 +196,7 @@ fn render_panel(form_ctx: FormContext, props: AuthEditLDAP) -> Html {
             TabBarItem::new().key("sync").label(tr!("Sync Options")),
             render_sync_form(form_ctx.clone(), props.clone()),
         )
+        .force_render_all(true)
         .into()
 }
 
@@ -301,7 +402,8 @@ impl Component for ProxmoxAuthEditLDAP {
                 props
                     .realm
                     .as_ref()
-                    .map(|realm| format!("{}/{}", props.base_url, percent_encode_component(realm))),
+                    .map(|realm| format!("{}/{}", props.base_url, percent_encode_component(realm)))
+                    .map(|url| move || load_realm(url.clone())),
             )
             .renderer({
                 let props = props.clone();
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH yew-comp 5/5] auth_view: implement syncing ldap and ad realms
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (4 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 4/5] auth_edit_ldap: add helpers to properly edit ad & ldap realms Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 1/5] config: add domain config plugins for " Shannon Sterz
                   ` (5 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

by adding an EditWindow that allows specifying the sync options and
then calling the specified sync endpoint.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 src/auth_view.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 102 insertions(+), 4 deletions(-)

diff --git a/src/auth_view.rs b/src/auth_view.rs
index a70e80b..a414ed6 100644
--- a/src/auth_view.rs
+++ b/src/auth_view.rs
@@ -4,6 +4,8 @@ use std::rc::Rc;
 
 use anyhow::Error;
 
+use pwt::widget::form::{Checkbox, FormContext, TristateBoolean};
+use serde_json::Value;
 use yew::html::IntoPropValue;
 use yew::virtual_dom::{VComp, VNode};
 
@@ -12,13 +14,13 @@ use pwt::state::{Selection, Store};
 use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
 use pwt::widget::menu::{Menu, MenuButton, MenuItem};
 
-use pwt::widget::{Button, Fa, Toolbar};
+use pwt::widget::{Button, Container, Fa, InputPanel, Toolbar};
 
 use pwt_macros::builder;
 
 use crate::{
-    AuthEditLDAP, AuthEditOpenID, LoadableComponent, LoadableComponentContext,
-    LoadableComponentMaster,
+    AuthEditLDAP, AuthEditOpenID, EditWindow, LoadableComponent, LoadableComponentContext,
+    LoadableComponentLink, LoadableComponentMaster,
 };
 
 use crate::common_api_types::BasicRealmInfo;
@@ -69,6 +71,7 @@ pub enum ViewState {
     EditOpenID(AttrValue),
     EditLDAP(AttrValue),
     EditAd(AttrValue),
+    Sync(AttrValue),
 }
 
 pub enum Msg {
@@ -89,6 +92,44 @@ async fn delete_item(base_url: AttrValue, realm: AttrValue) -> Result<(), Error>
     Ok(())
 }
 
+async fn sync_realm(
+    form_ctx: FormContext,
+    link: LoadableComponentLink<ProxmoxAuthView>,
+    url: impl Into<String>,
+) -> Result<(), Error> {
+    let mut data = form_ctx.get_submit_data();
+
+    let mut remove_vanished = Vec::new();
+
+    for prop in ["acl", "entry", "properties"] {
+        let prop_name = format!("remove-vanished-{prop}");
+        if data[&prop_name] == Value::Bool(true) {
+            remove_vanished.push(prop);
+        }
+
+        data[&prop_name] = Value::Null;
+    }
+
+    if !remove_vanished.is_empty() {
+        data["remove-vanished"] = Value::String(remove_vanished.join(";"));
+    }
+
+    let mut new = serde_json::json!({});
+
+    for (param, v) in data.as_object().unwrap().iter() {
+        if !v.is_null() {
+            new[param] = v.clone();
+        }
+    }
+
+    match crate::http_post::<String>(url, Some(new)).await {
+        Ok(upid) => link.show_task_log(upid, None),
+        Err(err) => link.show_error(tr!("Sync Failed"), err, true),
+    };
+
+    Ok(())
+}
+
 impl ProxmoxAuthView {
     fn get_selected_record(&self) -> Option<BasicRealmInfo> {
         let selected_key = self.selection.selected_key();
@@ -171,7 +212,18 @@ impl LoadableComponent for ProxmoxAuthView {
                 true
             }
             Msg::Sync => {
-                // fixme: do something
+                let info = match self.get_selected_record() {
+                    Some(info) => info,
+                    None => return true,
+                };
+
+                let view = match info.ty.as_str() {
+                    "ldap" if props.ldap_base_url.is_some() => info.realm,
+                    "ad" if props.ad_base_url.is_some() => info.realm,
+                    _ => return true,
+                };
+
+                ctx.link().change_view(Some(ViewState::Sync(view.into())));
                 true
             }
         }
@@ -312,6 +364,52 @@ impl LoadableComponent for ProxmoxAuthView {
                     .on_close(ctx.link().change_view_callback(|_| None))
                     .into(),
             ),
+            ViewState::Sync(realm) => {
+                let link = ctx.link();
+                let url = format!("/access/domains/{}/sync", percent_encode_component(realm));
+
+                Some(
+                    EditWindow::new(tr!("Realm Sync"))
+                        .renderer(|_form_ctx| {
+                            InputPanel::new()
+                                .padding(4)
+                                .with_field(tr!("Preview Only"), Checkbox::new().name("dry-run"))
+                                .with_field(
+                                    tr!("Enable new"),
+                                    TristateBoolean::new()
+                                        .name("enable-new")
+                                        .null_text(tr!("Default") + " (" + &tr!("Yes") + ")"),
+                                )
+                                .with_large_custom_child(
+                                    Container::new()
+                                        .key("remove-vanished-options")
+                                        .class("pwt-font-title-medium")
+                                        .padding_top(2)
+                                        .with_child(tr!("Remove Vanished Options")),
+                                )
+                                .with_field(
+                                    tr!("Remove ACLs of vanished users"),
+                                    Checkbox::new().name("remove-vanished-acl"),
+                                )
+                                .with_field(
+                                    tr!("Remove vanished user"),
+                                    Checkbox::new().name("remove-vanished-entry"),
+                                )
+                                .with_field(
+                                    tr!("Remove vanished properties"),
+                                    Checkbox::new().name("remove-vanished-properties"),
+                                )
+                                .into()
+                        })
+                        .on_close(link.change_view_callback(|_| None))
+                        .on_submit(move |form_context| {
+                            let link = link.clone();
+                            let url = url.clone();
+                            sync_realm(form_context, link, url)
+                        })
+                        .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] 13+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 1/5] config: add domain config plugins for ldap and ad realms
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (5 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 5/5] auth_view: implement syncing ldap and ad realms Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 2/5] server: add ldap and active directory authenticators Shannon Sterz
                   ` (4 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 Cargo.toml                    |  1 +
 lib/pdm-config/Cargo.toml     |  1 +
 lib/pdm-config/src/domains.rs | 15 +++++++++++++++
 3 files changed, 17 insertions(+)

diff --git a/Cargo.toml b/Cargo.toml
index 3146e7d..979069f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -43,6 +43,7 @@ proxmox-daemon = "1"
 proxmox-http = { version = "1", features = [ "client", "http-helpers", "websocket" ] } # see below
 proxmox-human-byte = "1"
 proxmox-io = "1.0.1" # tools and client use "tokio" feature
+proxmox-ldap = { version = "1.0", features = ["sync"] }
 proxmox-lang = "1.1"
 proxmox-log = "1"
 proxmox-login = "1"
diff --git a/lib/pdm-config/Cargo.toml b/lib/pdm-config/Cargo.toml
index 07f3a80..d39c2ad 100644
--- a/lib/pdm-config/Cargo.toml
+++ b/lib/pdm-config/Cargo.toml
@@ -15,6 +15,7 @@ serde.workspace = true
 
 proxmox-config-digest = { workspace = true, features = [ "openssl" ] }
 proxmox-http = { workspace = true, features = [ "http-helpers" ] }
+proxmox-ldap = { workspace = true, features = [ "types" ]}
 proxmox-product-config.workspace = true
 proxmox-schema.workspace = true
 proxmox-section-config.workspace = true
diff --git a/lib/pdm-config/src/domains.rs b/lib/pdm-config/src/domains.rs
index 11a0c82..d1eac54 100644
--- a/lib/pdm-config/src/domains.rs
+++ b/lib/pdm-config/src/domains.rs
@@ -3,6 +3,7 @@ use std::sync::LazyLock;
 
 use anyhow::Error;
 
+use proxmox_ldap::types::{AdRealmConfig, LdapRealmConfig};
 use proxmox_schema::{ApiType, Schema};
 use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
 
@@ -27,6 +28,20 @@ fn init() -> SectionConfig {
     let mut config = SectionConfig::new(&REALM_ID_SCHEMA);
     config.register_plugin(plugin);
 
+    let ldap_plugin = SectionConfigPlugin::new(
+        "ldap".to_string(),
+        Some("realm".to_string()),
+        LdapRealmConfig::API_SCHEMA.unwrap_object_schema(),
+    );
+    config.register_plugin(ldap_plugin);
+
+    let ad_plugin = SectionConfigPlugin::new(
+        "ad".to_string(),
+        Some("realm".to_string()),
+        AdRealmConfig::API_SCHEMA.unwrap_object_schema(),
+    );
+    config.register_plugin(ad_plugin);
+
     config
 }
 
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 2/5] server: add ldap and active directory authenticators
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (6 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 1/5] config: add domain config plugins for " Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 3/5] server: api: add api endpoints for configuring ldap & ad realms Shannon Sterz
                   ` (3 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

so that these types of realms could be used to login.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 lib/pdm-api-types/src/lib.rs |   7 ++
 server/Cargo.toml            |   1 +
 server/src/auth/ldap.rs      | 202 +++++++++++++++++++++++++++++++++++
 server/src/auth/mod.rs       |  17 ++-
 4 files changed, 226 insertions(+), 1 deletion(-)
 create mode 100644 server/src/auth/ldap.rs

diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs
index a7eaa0d..a356614 100644
--- a/lib/pdm-api-types/src/lib.rs
+++ b/lib/pdm-api-types/src/lib.rs
@@ -349,8 +349,15 @@ pub enum RealmType {
     Pdm,
     /// An OpenID Connect realm
     OpenId,
+    /// An Active Directory realm
+    Ad,
+    /// An LDAP realm
+    Ldap,
 }
 
+serde_plain::derive_display_from_serialize!(RealmType);
+serde_plain::derive_fromstr_from_deserialize!(RealmType);
+
 #[api(
     properties: {
         realm: {
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 9eefa0f..0dfcb6c 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -41,6 +41,7 @@ proxmox-base64.workspace = true
 proxmox-daemon.workspace = true
 proxmox-http = { workspace = true, features = [ "client-trait", "proxmox-async" ] } # pbs-client doesn't use these
 proxmox-lang.workspace = true
+proxmox-ldap.workspace = true
 proxmox-log.workspace = true
 proxmox-login.workspace = true
 proxmox-rest-server = { workspace = true, features = [ "templates" ] }
diff --git a/server/src/auth/ldap.rs b/server/src/auth/ldap.rs
new file mode 100644
index 0000000..8f2e57e
--- /dev/null
+++ b/server/src/auth/ldap.rs
@@ -0,0 +1,202 @@
+use std::future::Future;
+use std::net::IpAddr;
+use std::path::PathBuf;
+use std::pin::Pin;
+
+use anyhow::Error;
+use pdm_buildcfg::configdir;
+use proxmox_auth_api::api::Authenticator;
+use proxmox_ldap::types::{AdRealmConfig, LdapMode, LdapRealmConfig};
+use proxmox_ldap::{Config, Connection, ConnectionMode};
+use proxmox_router::http_bail;
+use serde_json::json;
+
+use pdm_api_types::UsernameRef;
+
+const LDAP_PASSWORDS_FILENAME: &str = configdir!("/ldap_passwords.json");
+
+#[allow(clippy::upper_case_acronyms)]
+pub(crate) struct LdapAuthenticator {
+    config: LdapRealmConfig,
+}
+
+impl LdapAuthenticator {
+    pub(crate) fn new(config: LdapRealmConfig) -> Self {
+        Self { config }
+    }
+}
+
+impl Authenticator for LdapAuthenticator {
+    /// Authenticate user in LDAP realm
+    fn authenticate_user<'a>(
+        &'a self,
+        username: &'a UsernameRef,
+        password: &'a str,
+        _client_ip: Option<&'a IpAddr>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
+        Box::pin(async move {
+            let ldap_config = Self::api_type_to_config(&self.config)?;
+            let ldap = Connection::new(ldap_config);
+            ldap.authenticate_user(username.as_str(), password).await?;
+            Ok(())
+        })
+    }
+
+    fn store_password(
+        &self,
+        _username: &UsernameRef,
+        _password: &str,
+        _client_ip: Option<&IpAddr>,
+    ) -> Result<(), Error> {
+        http_bail!(
+            NOT_IMPLEMENTED,
+            "storing passwords is not implemented for LDAP realms"
+        );
+    }
+
+    fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> {
+        http_bail!(
+            NOT_IMPLEMENTED,
+            "removing passwords is not implemented for LDAP realms"
+        );
+    }
+}
+
+impl LdapAuthenticator {
+    pub fn api_type_to_config(config: &LdapRealmConfig) -> Result<Config, Error> {
+        Self::api_type_to_config_with_password(config, get_ldap_bind_password(&config.realm)?)
+    }
+
+    pub fn api_type_to_config_with_password(
+        config: &LdapRealmConfig,
+        password: Option<String>,
+    ) -> Result<Config, Error> {
+        let mut servers = vec![config.server1.clone()];
+        if let Some(server) = &config.server2 {
+            servers.push(server.clone());
+        }
+
+        let (ca_store, trusted_cert) = lookup_ca_store_or_cert_path(config.capath.as_deref());
+
+        Ok(Config {
+            servers,
+            port: config.port,
+            user_attr: config.user_attr.clone(),
+            base_dn: config.base_dn.clone(),
+            bind_dn: config.bind_dn.clone(),
+            bind_password: password,
+            tls_mode: ldap_to_conn_mode(config.mode.unwrap_or_default()),
+            verify_certificate: config.verify.unwrap_or_default(),
+            additional_trusted_certificates: trusted_cert,
+            certificate_store_path: ca_store,
+        })
+    }
+}
+
+pub struct AdAuthenticator {
+    config: AdRealmConfig,
+}
+
+impl AdAuthenticator {
+    pub(crate) fn new(config: AdRealmConfig) -> Self {
+        Self { config }
+    }
+
+    pub fn api_type_to_config(config: &AdRealmConfig) -> Result<Config, Error> {
+        Self::api_type_to_config_with_password(config, get_ldap_bind_password(&config.realm)?)
+    }
+
+    pub fn api_type_to_config_with_password(
+        config: &AdRealmConfig,
+        password: Option<String>,
+    ) -> Result<Config, Error> {
+        let mut servers = vec![config.server1.clone()];
+        if let Some(server) = &config.server2 {
+            servers.push(server.clone());
+        }
+
+        let (ca_store, trusted_cert) = lookup_ca_store_or_cert_path(config.capath.as_deref());
+
+        Ok(Config {
+            servers,
+            port: config.port,
+            user_attr: "sAMAccountName".to_owned(),
+            base_dn: config.base_dn.clone().unwrap_or_default(),
+            bind_dn: config.bind_dn.clone(),
+            bind_password: password,
+            tls_mode: ldap_to_conn_mode(config.mode.unwrap_or_default()),
+            verify_certificate: config.verify.unwrap_or_default(),
+            additional_trusted_certificates: trusted_cert,
+            certificate_store_path: ca_store,
+        })
+    }
+}
+
+impl Authenticator for AdAuthenticator {
+    /// Authenticate user in AD realm
+    fn authenticate_user<'a>(
+        &'a self,
+        username: &'a UsernameRef,
+        password: &'a str,
+        _client_ip: Option<&'a IpAddr>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
+        Box::pin(async move {
+            let ldap_config = Self::api_type_to_config(&self.config)?;
+            let ldap = Connection::new(ldap_config);
+            ldap.authenticate_user(username.as_str(), password).await?;
+            Ok(())
+        })
+    }
+
+    fn store_password(
+        &self,
+        _username: &UsernameRef,
+        _password: &str,
+        _client_ip: Option<&IpAddr>,
+    ) -> Result<(), Error> {
+        http_bail!(
+            NOT_IMPLEMENTED,
+            "storing passwords is not implemented for Active Directory realms"
+        );
+    }
+
+    fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> {
+        http_bail!(
+            NOT_IMPLEMENTED,
+            "removing passwords is not implemented for Active Directory realms"
+        );
+    }
+}
+
+fn ldap_to_conn_mode(mode: LdapMode) -> ConnectionMode {
+    match mode {
+        LdapMode::Ldap => ConnectionMode::Ldap,
+        LdapMode::StartTls => ConnectionMode::StartTls,
+        LdapMode::Ldaps => ConnectionMode::Ldaps,
+    }
+}
+
+fn lookup_ca_store_or_cert_path(capath: Option<&str>) -> (Option<PathBuf>, Option<Vec<PathBuf>>) {
+    if let Some(capath) = capath {
+        let path = PathBuf::from(capath);
+        if path.is_dir() {
+            (Some(path), None)
+        } else {
+            (None, Some(vec![path]))
+        }
+    } else {
+        (None, None)
+    }
+}
+
+/// Retrieve stored LDAP bind password
+pub(super) fn get_ldap_bind_password(realm: &str) -> Result<Option<String>, Error> {
+    let data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?;
+
+    let password = data
+        .get(realm)
+        .and_then(|s| s.as_str())
+        .map(|s| s.to_owned());
+
+    Ok(password)
+}
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index a0e0a34..532350d 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -8,11 +8,13 @@ use std::sync::OnceLock;
 use anyhow::{bail, Error};
 
 use const_format::concatcp;
+use ldap::{AdAuthenticator, LdapAuthenticator};
 use proxmox_access_control::CachedUserInfo;
 use proxmox_auth_api::api::{Authenticator, LockedTfaConfig};
 use proxmox_auth_api::ticket::Ticket;
 use proxmox_auth_api::types::Authid;
 use proxmox_auth_api::{HMACKey, Keyring};
+use proxmox_ldap::types::{AdRealmConfig, LdapRealmConfig};
 use proxmox_rest_server::AuthError;
 use proxmox_router::UserInformation;
 use proxmox_tfa::api::{OpenUserChallengeData, TfaConfig};
@@ -22,6 +24,7 @@ use pdm_api_types::{RealmRef, Userid};
 pub mod certs;
 pub mod csrf;
 pub mod key;
+pub(crate) mod ldap;
 pub mod tfa;
 
 pub const TERM_PREFIX: &str = "PDMTERM";
@@ -182,7 +185,19 @@ pub(crate) fn lookup_authenticator(
             config_filename: pdm_buildcfg::configdir!("/access/shadow.json"),
             lock_filename: pdm_buildcfg::configdir!("/access/shadow.json.lock"),
         })),
-        realm => bail!("unknown realm '{}'", realm),
+        realm => {
+            if let Ok((domains, _digest)) = pdm_config::domains::config() {
+                if let Ok(config) = domains.lookup::<LdapRealmConfig>("ldap", realm) {
+                    return Ok(Box::new(LdapAuthenticator::new(config)));
+                }
+
+                if let Ok(config) = domains.lookup::<AdRealmConfig>("ad", realm) {
+                    return Ok(Box::new(AdAuthenticator::new(config)));
+                }
+            }
+
+            bail!("unknwon realm {realm}");
+        }
     }
 }
 
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 3/5] server: api: add api endpoints for configuring ldap & ad realms
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (7 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 2/5] server: add ldap and active directory authenticators Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 4/5] api/auth: add endpoint to start ldap sync jobs Shannon Sterz
                   ` (2 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 lib/pdm-api-types/src/acl.rs         |   3 +
 lib/pdm-config/src/domains.rs        |  20 ++
 server/src/api/config/access/ad.rs   | 355 +++++++++++++++++++++++++
 server/src/api/config/access/ldap.rs | 372 +++++++++++++++++++++++++++
 server/src/api/config/access/mod.rs  |   8 +-
 server/src/auth/ldap.rs              |  30 +++
 6 files changed, 787 insertions(+), 1 deletion(-)
 create mode 100644 server/src/api/config/access/ad.rs
 create mode 100644 server/src/api/config/access/ldap.rs

diff --git a/lib/pdm-api-types/src/acl.rs b/lib/pdm-api-types/src/acl.rs
index f30b41f..9e69c2f 100644
--- a/lib/pdm-api-types/src/acl.rs
+++ b/lib/pdm-api-types/src/acl.rs
@@ -44,6 +44,9 @@ constnamedbitmap! {
         PRIV_ACCESS_AUDIT("Access.Audit");
         /// `Access.Modify` allows modifying permissions and users.
         PRIV_ACCESS_MODIFY("Access.Modify");
+
+        /// Realm.Allocate allows viewing, creating, modifying and deleting realms
+        PRIV_REALM_ALLOCATE("Realm.Allocate");
     }
 }
 
diff --git a/lib/pdm-config/src/domains.rs b/lib/pdm-config/src/domains.rs
index d1eac54..dcde65b 100644
--- a/lib/pdm-config/src/domains.rs
+++ b/lib/pdm-config/src/domains.rs
@@ -90,3 +90,23 @@ pub fn complete_openid_realm_name(_arg: &str, _param: &HashMap<String, String>)
         Err(_) => Vec::new(),
     }
 }
+
+/// Unsets the default login realm for users by deleting the `default` property
+/// from the respective realm.
+///
+/// This only updates the configuration as given in `config`, making it
+/// permanent is left to the caller.
+pub fn unset_default_realm(config: &mut SectionConfigData) -> Result<(), Error> {
+    for (_, data) in &mut config.sections.values_mut() {
+        if let Some(obj) = data.as_object_mut() {
+            obj.remove("default");
+        }
+    }
+
+    Ok(())
+}
+
+/// Check if a realm with the given name exists
+pub fn exists(domains: &SectionConfigData, realm: &str) -> bool {
+    domains.sections.contains_key(realm)
+}
diff --git a/server/src/api/config/access/ad.rs b/server/src/api/config/access/ad.rs
new file mode 100644
index 0000000..8167082
--- /dev/null
+++ b/server/src/api/config/access/ad.rs
@@ -0,0 +1,355 @@
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use proxmox_ldap::types::{AdRealmConfig, AdRealmConfigUpdater, REALM_ID_SCHEMA};
+use proxmox_ldap::{Config as LdapConfig, Connection};
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::{api, param_bail};
+
+use pdm_api_types::{ConfigDigest, PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT};
+use pdm_config::domains;
+
+use crate::auth::ldap;
+use crate::auth::ldap::AdAuthenticator;
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of configured AD realms.",
+        type: Array,
+        items: { type: AdRealmConfig },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// List configured AD realms
+pub fn list_ad_realms(
+    _param: Value,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<AdRealmConfig>, Error> {
+    let (config, digest) = domains::config()?;
+
+    let list = config.convert_to_typed_array("ad")?;
+
+    rpcenv["digest"] = digest.to_hex().into();
+
+    Ok(list)
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            config: {
+                type: AdRealmConfig,
+                flatten: true,
+            },
+            password: {
+                description: "AD bind password",
+                optional: true,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Create a new AD realm
+pub async fn create_ad_realm(
+    mut config: AdRealmConfig,
+    password: Option<String>,
+) -> Result<(), Error> {
+    let domain_config_lock = domains::lock_config()?;
+
+    let (mut domains, _digest) = domains::config()?;
+
+    if domains::exists(&domains, &config.realm) {
+        param_bail!("realm", "realm '{}' already exists.", config.realm);
+    }
+
+    let mut ldap_config =
+        AdAuthenticator::api_type_to_config_with_password(&config, password.clone())?;
+
+    if config.base_dn.is_none() {
+        ldap_config.base_dn = retrieve_default_naming_context(&ldap_config).await?;
+        config.base_dn = Some(ldap_config.base_dn.clone());
+    }
+
+    let conn = Connection::new(ldap_config);
+    conn.check_connection()
+        .await
+        .map_err(|e| format_err!("{e:#}"))?;
+
+    if let Some(password) = password {
+        ldap::store_ldap_bind_password(&config.realm, &password, &domain_config_lock)?;
+    }
+
+    if let Some(true) = config.default {
+        domains::unset_default_realm(&mut domains)?;
+    }
+
+    domains.set_data(&config.realm, "ad", &config)?;
+
+    domains::save_config(&domains)
+}
+
+#[api(
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+        },
+    },
+    returns: { type: AdRealmConfig },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Read the AD realm configuration
+pub fn read_ad_realm(
+    realm: String,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<AdRealmConfig, Error> {
+    let (domains, digest) = domains::config()?;
+
+    let config = domains.lookup("ad", &realm)?;
+
+    rpcenv["digest"] = digest.to_hex().into();
+
+    Ok(config)
+}
+
+#[api()]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Deletable property name
+pub enum DeletableProperty {
+    /// Fallback AD server address
+    Server2,
+    /// Port
+    Port,
+    /// Comment
+    Comment,
+    /// Is default realm
+    Default,
+    /// Verify server certificate
+    Verify,
+    /// Mode (ldap, ldap+starttls or ldaps),
+    Mode,
+    /// Bind Domain
+    BindDn,
+    /// LDAP bind passwort
+    Password,
+    /// User filter
+    Filter,
+    /// Default options for user sync
+    SyncDefaultsOptions,
+    /// user attributes to sync with AD attributes
+    SyncAttributes,
+    /// User classes
+    UserClasses,
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+            update: {
+                type: AdRealmConfigUpdater,
+                flatten: true,
+            },
+            password: {
+                description: "AD bind password",
+                optional: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeletableProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                type: ConfigDigest,
+            },
+        },
+    },
+    returns:  { type: AdRealmConfig },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Update an AD realm configuration
+pub async fn update_ad_realm(
+    realm: String,
+    update: AdRealmConfigUpdater,
+    password: Option<String>,
+    delete: Option<Vec<DeletableProperty>>,
+    digest: Option<ConfigDigest>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let domain_config_lock = domains::lock_config()?;
+
+    let (mut domains, expected_digest) = domains::config()?;
+    expected_digest.detect_modification(digest.as_ref())?;
+
+    let mut config: AdRealmConfig = domains.lookup("ad", &realm)?;
+
+    if let Some(delete) = delete {
+        for delete_prop in delete {
+            match delete_prop {
+                DeletableProperty::Server2 => {
+                    config.server2 = None;
+                }
+                DeletableProperty::Comment => {
+                    config.comment = None;
+                }
+                DeletableProperty::Default => {
+                    config.default = None;
+                }
+                DeletableProperty::Port => {
+                    config.port = None;
+                }
+                DeletableProperty::Verify => {
+                    config.verify = None;
+                }
+                DeletableProperty::Mode => {
+                    config.mode = None;
+                }
+                DeletableProperty::BindDn => {
+                    config.bind_dn = None;
+                }
+                DeletableProperty::Password => {
+                    ldap::remove_ldap_bind_password(&realm, &domain_config_lock)?;
+                }
+                DeletableProperty::Filter => {
+                    config.filter = None;
+                }
+                DeletableProperty::SyncDefaultsOptions => {
+                    config.sync_defaults_options = None;
+                }
+                DeletableProperty::SyncAttributes => {
+                    config.sync_attributes = None;
+                }
+                DeletableProperty::UserClasses => {
+                    config.user_classes = None;
+                }
+            }
+        }
+    }
+
+    if let Some(server1) = update.server1 {
+        config.server1 = server1;
+    }
+
+    if let Some(server2) = update.server2 {
+        config.server2 = Some(server2);
+    }
+
+    if let Some(port) = update.port {
+        config.port = Some(port);
+    }
+
+    if let Some(base_dn) = update.base_dn {
+        config.base_dn = Some(base_dn);
+    }
+
+    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 {
+        domains::unset_default_realm(&mut domains)?;
+        config.default = Some(true);
+    } else {
+        config.default = None;
+    }
+
+    if let Some(mode) = update.mode {
+        config.mode = Some(mode);
+    }
+
+    if let Some(verify) = update.verify {
+        config.verify = Some(verify);
+    }
+
+    if let Some(bind_dn) = update.bind_dn {
+        config.bind_dn = Some(bind_dn);
+    }
+
+    if let Some(filter) = update.filter {
+        config.filter = Some(filter);
+    }
+
+    if let Some(sync_defaults_options) = update.sync_defaults_options {
+        config.sync_defaults_options = Some(sync_defaults_options);
+    }
+
+    if let Some(sync_attributes) = update.sync_attributes {
+        config.sync_attributes = Some(sync_attributes);
+    }
+
+    if let Some(user_classes) = update.user_classes {
+        config.user_classes = Some(user_classes);
+    }
+
+    let mut ldap_config = if password.is_some() {
+        AdAuthenticator::api_type_to_config_with_password(&config, password.clone())?
+    } else {
+        AdAuthenticator::api_type_to_config(&config)?
+    };
+
+    if config.base_dn.is_none() {
+        ldap_config.base_dn = retrieve_default_naming_context(&ldap_config).await?;
+        config.base_dn = Some(ldap_config.base_dn.clone());
+    }
+
+    let conn = Connection::new(ldap_config);
+    conn.check_connection()
+        .await
+        .map_err(|e| format_err!("{e:#}"))?;
+
+    if let Some(password) = password {
+        ldap::store_ldap_bind_password(&realm, &password, &domain_config_lock)?;
+    }
+
+    domains.set_data(&realm, "ad", &config)?;
+
+    domains::save_config(&domains)?;
+
+    Ok(())
+}
+
+async fn retrieve_default_naming_context(ldap_config: &LdapConfig) -> Result<String, Error> {
+    let conn = Connection::new(ldap_config.clone());
+    match conn.retrieve_root_dse_attr("defaultNamingContext").await {
+        Ok(base_dn) if !base_dn.is_empty() => Ok(base_dn[0].clone()),
+        Ok(_) => bail!("server did not provide `defaultNamingContext`"),
+        Err(err) => bail!("failed to determine base_dn: {err}"),
+    }
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_READ_AD_REALM)
+    .put(&API_METHOD_UPDATE_AD_REALM)
+    .delete(&super::ldap::API_METHOD_DELETE_LDAP_REALM);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_AD_REALMS)
+    .post(&API_METHOD_CREATE_AD_REALM)
+    .match_all("realm", &ITEM_ROUTER);
diff --git a/server/src/api/config/access/ldap.rs b/server/src/api/config/access/ldap.rs
new file mode 100644
index 0000000..c5e7732
--- /dev/null
+++ b/server/src/api/config/access/ldap.rs
@@ -0,0 +1,372 @@
+use anyhow::{format_err, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use proxmox_config_digest::ConfigDigest;
+use proxmox_ldap::types::{LdapRealmConfig, LdapRealmConfigUpdater, REALM_ID_SCHEMA};
+use proxmox_ldap::Connection;
+use proxmox_router::{http_bail, Permission, Router, RpcEnvironment};
+use proxmox_schema::{api, param_bail};
+
+use pdm_api_types::{PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT};
+use pdm_config::domains;
+
+use crate::auth::ldap;
+use crate::auth::ldap::LdapAuthenticator;
+
+#[api(
+    input: {
+        properties: {},
+    },
+    returns: {
+        description: "List of configured LDAP realms.",
+        type: Array,
+        items: { type: LdapRealmConfig },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// List configured LDAP realms
+pub fn list_ldap_realms(
+    _param: Value,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<LdapRealmConfig>, Error> {
+    let (config, digest) = domains::config()?;
+
+    let list = config.convert_to_typed_array("ldap")?;
+
+    rpcenv["digest"] = digest.to_hex().into();
+
+    Ok(list)
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            config: {
+                type: LdapRealmConfig,
+                flatten: true,
+            },
+            password: {
+                description: "LDAP bind password",
+                optional: true,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Create a new LDAP realm
+pub fn create_ldap_realm(config: LdapRealmConfig, password: Option<String>) -> Result<(), Error> {
+    let domain_config_lock = domains::lock_config()?;
+
+    let (mut domains, _digest) = domains::config()?;
+
+    if domains::exists(&domains, &config.realm) {
+        param_bail!("realm", "realm '{}' already exists.", config.realm);
+    }
+
+    let ldap_config =
+        LdapAuthenticator::api_type_to_config_with_password(&config, password.clone())?;
+
+    let conn = Connection::new(ldap_config);
+    proxmox_async::runtime::block_on(conn.check_connection()).map_err(|e| format_err!("{e:#}"))?;
+
+    if let Some(password) = password {
+        ldap::store_ldap_bind_password(&config.realm, &password, &domain_config_lock)?;
+    }
+
+    if let Some(true) = config.default {
+        domains::unset_default_realm(&mut domains)?;
+    }
+
+    domains.set_data(&config.realm, "ldap", &config)?;
+
+    domains::save_config(&domains)
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+            digest: {
+                optional: true,
+                type: ConfigDigest,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Remove an LDAP realm configuration
+pub fn delete_ldap_realm(
+    realm: String,
+    digest: Option<ConfigDigest>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let domain_config_lock = domains::lock_config()?;
+
+    let (mut domains, expected_digest) = domains::config()?;
+    expected_digest.detect_modification(digest.as_ref())?;
+
+    if domains.sections.remove(&realm).is_none() {
+        http_bail!(NOT_FOUND, "realm '{realm}' does not exist.");
+    }
+
+    domains::save_config(&domains)?;
+
+    if ldap::remove_ldap_bind_password(&realm, &domain_config_lock).is_err() {
+        log::error!("Could not remove stored LDAP bind password for realm {realm}");
+    }
+
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+        },
+    },
+    returns:  { type: LdapRealmConfig },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Read the LDAP realm configuration
+pub fn read_ldap_realm(
+    realm: String,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<LdapRealmConfig, Error> {
+    let (domains, digest) = domains::config()?;
+
+    let config = domains.lookup("ldap", &realm)?;
+
+    rpcenv["digest"] = digest.to_hex().into();
+
+    Ok(config)
+}
+
+#[api()]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Deletable property name
+pub enum DeletableProperty {
+    /// Fallback LDAP server address
+    Server2,
+    /// Port
+    Port,
+    /// Comment
+    Comment,
+    /// Is default realm
+    Default,
+    /// Verify server certificate
+    Verify,
+    /// Mode (ldap, ldap+starttls or ldaps),
+    Mode,
+    /// Bind Domain
+    BindDn,
+    /// LDAP bind password
+    Password,
+    /// User filter
+    Filter,
+    /// Default options for user sync
+    SyncDefaultsOptions,
+    /// user attributes to sync with LDAP attributes
+    SyncAttributes,
+    /// User classes
+    UserClasses,
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+            update: {
+                type: LdapRealmConfigUpdater,
+                flatten: true,
+            },
+            password: {
+                description: "LDAP bind password",
+                optional: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeletableProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                type: ConfigDigest,
+            },
+        },
+    },
+    returns:  { type: LdapRealmConfig },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Update an LDAP realm configuration
+pub fn update_ldap_realm(
+    realm: String,
+    update: LdapRealmConfigUpdater,
+    password: Option<String>,
+    delete: Option<Vec<DeletableProperty>>,
+    digest: Option<ConfigDigest>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let domain_config_lock = domains::lock_config()?;
+
+    let (mut domains, expected_digest) = domains::config()?;
+    expected_digest.detect_modification(digest.as_ref())?;
+
+    let mut config: LdapRealmConfig = domains.lookup("ldap", &realm)?;
+
+    if let Some(delete) = delete {
+        for delete_prop in delete {
+            match delete_prop {
+                DeletableProperty::Server2 => {
+                    config.server2 = None;
+                }
+                DeletableProperty::Comment => {
+                    config.comment = None;
+                }
+                DeletableProperty::Default => {
+                    config.default = None;
+                }
+                DeletableProperty::Port => {
+                    config.port = None;
+                }
+                DeletableProperty::Verify => {
+                    config.verify = None;
+                }
+                DeletableProperty::Mode => {
+                    config.mode = None;
+                }
+                DeletableProperty::BindDn => {
+                    config.bind_dn = None;
+                }
+                DeletableProperty::Password => {
+                    ldap::remove_ldap_bind_password(&realm, &domain_config_lock)?;
+                }
+                DeletableProperty::Filter => {
+                    config.filter = None;
+                }
+                DeletableProperty::SyncDefaultsOptions => {
+                    config.sync_defaults_options = None;
+                }
+                DeletableProperty::SyncAttributes => {
+                    config.sync_attributes = None;
+                }
+                DeletableProperty::UserClasses => {
+                    config.user_classes = None;
+                }
+            }
+        }
+    }
+
+    if let Some(server1) = update.server1 {
+        config.server1 = server1;
+    }
+
+    if let Some(server2) = update.server2 {
+        config.server2 = Some(server2);
+    }
+
+    if let Some(port) = update.port {
+        config.port = Some(port);
+    }
+
+    if let Some(base_dn) = update.base_dn {
+        config.base_dn = base_dn;
+    }
+
+    if let Some(user_attr) = update.user_attr {
+        config.user_attr = user_attr;
+    }
+
+    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 {
+        domains::unset_default_realm(&mut domains)?;
+        config.default = Some(true);
+    } else {
+        config.default = None;
+    }
+
+    if let Some(mode) = update.mode {
+        config.mode = Some(mode);
+    }
+
+    if let Some(verify) = update.verify {
+        config.verify = Some(verify);
+    }
+
+    if let Some(bind_dn) = update.bind_dn {
+        config.bind_dn = Some(bind_dn);
+    }
+
+    if let Some(filter) = update.filter {
+        config.filter = Some(filter);
+    }
+    if let Some(sync_defaults_options) = update.sync_defaults_options {
+        config.sync_defaults_options = Some(sync_defaults_options);
+    }
+    if let Some(sync_attributes) = update.sync_attributes {
+        config.sync_attributes = Some(sync_attributes);
+    }
+    if let Some(user_classes) = update.user_classes {
+        config.user_classes = Some(user_classes);
+    }
+
+    let ldap_config = if password.is_some() {
+        LdapAuthenticator::api_type_to_config_with_password(&config, password.clone())?
+    } else {
+        LdapAuthenticator::api_type_to_config(&config)?
+    };
+
+    let conn = Connection::new(ldap_config);
+    proxmox_async::runtime::block_on(conn.check_connection()).map_err(|e| format_err!("{e:#}"))?;
+
+    if let Some(password) = password {
+        ldap::store_ldap_bind_password(&realm, &password, &domain_config_lock)?;
+    }
+
+    domains.set_data(&realm, "ldap", &config)?;
+
+    domains::save_config(&domains)?;
+
+    Ok(())
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_READ_LDAP_REALM)
+    .put(&API_METHOD_UPDATE_LDAP_REALM)
+    .delete(&API_METHOD_DELETE_LDAP_REALM);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_LDAP_REALMS)
+    .post(&API_METHOD_CREATE_LDAP_REALM)
+    .match_all("realm", &ITEM_ROUTER);
diff --git a/server/src/api/config/access/mod.rs b/server/src/api/config/access/mod.rs
index 6bb1e33..7454f53 100644
--- a/server/src/api/config/access/mod.rs
+++ b/server/src/api/config/access/mod.rs
@@ -2,10 +2,16 @@ use proxmox_router::list_subdirs_api_method;
 use proxmox_router::{Router, SubdirMap};
 use proxmox_sortable_macro::sortable;
 
+mod ad;
+mod ldap;
 pub mod tfa;
 
 #[sortable]
-const SUBDIRS: SubdirMap = &sorted!([("tfa", &tfa::ROUTER),]);
+const SUBDIRS: SubdirMap = &sorted!([
+    ("tfa", &tfa::ROUTER),
+    ("ldap", &ldap::ROUTER),
+    ("ad", &ad::ROUTER),
+]);
 
 pub const ROUTER: Router = Router::new()
     .get(&list_subdirs_api_method!(SUBDIRS))
diff --git a/server/src/auth/ldap.rs b/server/src/auth/ldap.rs
index 8f2e57e..fddb3f9 100644
--- a/server/src/auth/ldap.rs
+++ b/server/src/auth/ldap.rs
@@ -8,6 +8,7 @@ use pdm_buildcfg::configdir;
 use proxmox_auth_api::api::Authenticator;
 use proxmox_ldap::types::{AdRealmConfig, LdapMode, LdapRealmConfig};
 use proxmox_ldap::{Config, Connection, ConnectionMode};
+use proxmox_product_config::ApiLockGuard;
 use proxmox_router::http_bail;
 use serde_json::json;
 
@@ -189,6 +190,35 @@ fn lookup_ca_store_or_cert_path(capath: Option<&str>) -> (Option<PathBuf>, Optio
     }
 }
 
+/// Store LDAP bind passwords in protected file. The domain config must be locked while this
+/// function is executed.
+pub(crate) fn store_ldap_bind_password(
+    realm: &str,
+    password: &str,
+    _domain_lock: &ApiLockGuard,
+) -> Result<(), Error> {
+    let mut data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?;
+    data[realm] = password.into();
+    let data = serde_json::to_vec_pretty(&data)?;
+
+    proxmox_product_config::replace_secret_config(LDAP_PASSWORDS_FILENAME, &data)
+}
+
+/// Remove stored LDAP bind password. The domain config must be locked while this
+/// function is executed.
+pub(crate) fn remove_ldap_bind_password(
+    realm: &str,
+    _domain_lock: &ApiLockGuard,
+) -> Result<(), Error> {
+    let mut data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?;
+    if let Some(map) = data.as_object_mut() {
+        map.remove(realm);
+    }
+    let data = serde_json::to_vec_pretty(&data)?;
+
+    proxmox_product_config::replace_secret_config(LDAP_PASSWORDS_FILENAME, &data)
+}
+
 /// Retrieve stored LDAP bind password
 pub(super) fn get_ldap_bind_password(realm: &str) -> Result<Option<String>, Error> {
     let data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?;
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 4/5] api/auth: add endpoint to start ldap sync jobs
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (8 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 3/5] server: api: add api endpoints for configuring ldap & ad realms Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 5/5] ui: add a panel to allow handling realms Shannon Sterz
  2025-09-19 10:02 ` [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Christoph Heiss
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 server/src/api/access/domains.rs | 90 +++++++++++++++++++++++++++++---
 server/src/auth/ldap.rs          | 87 +++++++++++++++++++++++++++++-
 2 files changed, 169 insertions(+), 8 deletions(-)

diff --git a/server/src/api/access/domains.rs b/server/src/api/access/domains.rs
index cdfbee1..2aef6d9 100644
--- a/server/src/api/access/domains.rs
+++ b/server/src/api/access/domains.rs
@@ -1,13 +1,16 @@
 //! List Authentication domains/realms.
 
-use anyhow::Error;
-use serde_json::Value;
+use anyhow::{bail, format_err, Error};
+use serde_json::{json, Value};
 
-use pdm_api_types::{BasicRealmInfo, RealmType};
-
-use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_auth_api::types::Realm;
+use proxmox_ldap::types::REMOVE_VANISHED_SCHEMA;
+use proxmox_router::{Permission, Router, RpcEnvironment, RpcEnvironmentType, SubdirMap};
 use proxmox_schema::api;
 
+use pbs_api_types::PRIV_PERMISSIONS_MODIFY;
+use pdm_api_types::{Authid, BasicRealmInfo, RealmRef, RealmType, UPID_SCHEMA};
+
 #[api(
     returns: {
         description: "List of realms with basic info.",
@@ -52,4 +55,79 @@ fn list_domains(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<BasicRealmInfo>,
     Ok(list)
 }
 
-pub const ROUTER: Router = Router::new().get(&API_METHOD_LIST_DOMAINS);
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            realm: {
+                type: Realm,
+            },
+            "dry-run": {
+                type: bool,
+                description: "If set, do not create/delete anything",
+                default: false,
+                optional: true,
+            },
+            "remove-vanished": {
+                optional: true,
+                schema: REMOVE_VANISHED_SCHEMA,
+            },
+            "enable-new": {
+                description: "Enable newly synced users immediately",
+                optional: true,
+            }
+         },
+    },
+    returns: {
+        schema: UPID_SCHEMA,
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
+    },
+)]
+/// Synchronize users of a given realm
+pub fn sync_realm(
+    realm: Realm,
+    dry_run: bool,
+    remove_vanished: Option<String>,
+    enable_new: Option<bool>,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Value, Error> {
+    let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+    let to_stdout = rpcenv.env_type() == RpcEnvironmentType::CLI;
+
+    let upid_str = crate::auth::ldap::do_realm_sync_job(
+        realm.clone(),
+        realm_type_from_name(&realm)?,
+        &auth_id,
+        to_stdout,
+        dry_run,
+        remove_vanished,
+        enable_new,
+    )
+    .map_err(|err| format_err!("unable to start realm sync job on realm {realm} - {err:#}"))?;
+
+    Ok(json!(upid_str))
+}
+
+fn realm_type_from_name(realm: &RealmRef) -> Result<RealmType, Error> {
+    let config = pdm_config::domains::config()?.0;
+
+    for (name, (section_type, _)) in config.sections.iter() {
+        if name == realm.as_str() {
+            return Ok(section_type.parse()?);
+        }
+    }
+
+    bail!("unable to find realm {realm}")
+}
+
+const SYNC_ROUTER: Router = Router::new().post(&API_METHOD_SYNC_REALM);
+const SYNC_SUBDIRS: SubdirMap = &[("sync", &SYNC_ROUTER)];
+
+const REALM_ROUTER: Router = Router::new().subdirs(SYNC_SUBDIRS);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_DOMAINS)
+    .match_all("realm", &REALM_ROUTER);
diff --git a/server/src/auth/ldap.rs b/server/src/auth/ldap.rs
index fddb3f9..b42a81e 100644
--- a/server/src/auth/ldap.rs
+++ b/server/src/auth/ldap.rs
@@ -3,16 +3,19 @@ use std::net::IpAddr;
 use std::path::PathBuf;
 use std::pin::Pin;
 
-use anyhow::Error;
+use anyhow::{bail, Error};
 use pdm_buildcfg::configdir;
 use proxmox_auth_api::api::Authenticator;
+use proxmox_ldap::sync::{AdRealmSyncJob, GeneralSyncSettingsOverride, LdapRealmSyncJob};
 use proxmox_ldap::types::{AdRealmConfig, LdapMode, LdapRealmConfig};
 use proxmox_ldap::{Config, Connection, ConnectionMode};
 use proxmox_product_config::ApiLockGuard;
+use proxmox_rest_server::WorkerTask;
 use proxmox_router::http_bail;
 use serde_json::json;
 
-use pdm_api_types::UsernameRef;
+use pdm_api_types::{Authid, Realm, RealmType, UsernameRef};
+use pdm_config::domains;
 
 const LDAP_PASSWORDS_FILENAME: &str = configdir!("/ldap_passwords.json");
 
@@ -230,3 +233,83 @@ pub(super) fn get_ldap_bind_password(realm: &str) -> Result<Option<String>, Erro
 
     Ok(password)
 }
+
+/// Runs a realm sync job
+#[allow(clippy::too_many_arguments)]
+pub fn do_realm_sync_job(
+    realm: Realm,
+    realm_type: RealmType,
+    auth_id: &Authid,
+    to_stdout: bool,
+    dry_run: bool,
+    remove_vanished: Option<String>,
+    enable_new: Option<bool>,
+) -> Result<String, Error> {
+    let upid_str = WorkerTask::spawn(
+        "realm-sync",
+        Some(realm.as_str().to_owned()),
+        auth_id.to_string(),
+        to_stdout,
+        move |_worker| {
+            log::info!("starting realm sync for {realm}");
+
+            let override_settings = GeneralSyncSettingsOverride {
+                remove_vanished,
+                enable_new,
+            };
+
+            async move {
+                match realm_type {
+                    RealmType::Ldap => {
+                        let (domains, _digest) = domains::config()?;
+                        let config = if let Ok(config) =
+                            domains.lookup::<LdapRealmConfig>("ldap", realm.as_str())
+                        {
+                            config
+                        } else {
+                            bail!("unknown LDAP realm '{realm}'");
+                        };
+
+                        let ldap_config = LdapAuthenticator::api_type_to_config(&config)?;
+
+                        LdapRealmSyncJob::new(
+                            realm,
+                            config,
+                            ldap_config,
+                            &override_settings,
+                            dry_run,
+                        )?
+                        .sync()
+                        .await
+                    }
+                    RealmType::Ad => {
+                        let (domains, _digest) = domains::config()?;
+                        let config = if let Ok(config) =
+                            domains.lookup::<AdRealmConfig>("ad", realm.as_str())
+                        {
+                            config
+                        } else {
+                            bail!("unknown Active Directory realm '{realm}'");
+                        };
+
+                        let ldap_config = AdAuthenticator::api_type_to_config(&config)?;
+
+                        AdRealmSyncJob::new(
+                            realm,
+                            config,
+                            ldap_config,
+                            &override_settings,
+                            dry_run,
+                        )?
+                        .sync()
+                        .await
+                    }
+
+                    _ => bail!("cannot sync realm {realm} of type {realm_type}"),
+                }
+            }
+        },
+    )?;
+
+    Ok(upid_str)
+}
-- 
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] 13+ messages in thread

* [pdm-devel] [PATCH datacenter-manager 5/5] ui: add a panel to allow handling realms
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (9 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 4/5] api/auth: add endpoint to start ldap sync jobs Shannon Sterz
@ 2025-09-16 14:48 ` Shannon Sterz
  2025-09-19 10:02 ` [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Christoph Heiss
  11 siblings, 0 replies; 13+ messages in thread
From: Shannon Sterz @ 2025-09-16 14:48 UTC (permalink / raw)
  To: pdm-devel

this allows adding, removing and editing new realms. specifically ldap
and active directory realms.

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

diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs
index 2a8b991..6ddc5f2 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, UserPanel};
+use proxmox_yew_comp::{AclEdit, AclView, AuthView, UserPanel};
 
 mod permission_path_selector;
 mod webauthn;
@@ -92,6 +92,18 @@ pub fn access_control() -> Html {
                     ))
                     .into()
             },
+        )
+        .with_item_builder(
+            TabBarItem::new()
+                .key("realms")
+                .icon_class("fa fa-address-book-o")
+                .label(tr!("Realms")),
+            |_| {
+                AuthView::new()
+                    .ldap_base_url("/config/access/ldap")
+                    .ad_base_url("/config/access/ad")
+                    .into()
+            },
         );
 
     NavigationContainer::new().with_child(panel).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] 13+ messages in thread

* Re: [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager
  2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
                   ` (10 preceding siblings ...)
  2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 5/5] ui: add a panel to allow handling realms Shannon Sterz
@ 2025-09-19 10:02 ` Christoph Heiss
  11 siblings, 0 replies; 13+ messages in thread
From: Christoph Heiss @ 2025-09-19 10:02 UTC (permalink / raw)
  To: Shannon Sterz; +Cc: Proxmox Datacenter Manager development discussion

Applied the series and did some testing.

Tested with:

- LDAP with OpenLDAP, slapd 2.5.13+dfsg-5
- LDAP and AD with Samba 4.22.3
- LDAP and AD with Windows Server 2022 Active Directory

Some things I noticed:

- No default realm is set by default. E.g. with this series applied, by
  default all realms show an "X" in the default column. Should set PAM
  as default realm if none is set yet.

- After syncing a realm, the changes are not immediately reflected in the
  "User Management" tab, one has to refresh manually.

- I know the form layout is pretty much taken from PVE/PBS, but at least
  the "Base Domain Name" and "Bind Domain Name" could use some longer
  fields, or the dialog window more width as a whole.

  These are usually pretty long, 50+ characters for the bind DN are not
  unusual and currently these fields fit ~25 characters.

- Unless I missed it, but the `proxmox-datacenter-manager-client` does
  not (yet) have a `realm` subcommand to manage them from the shell?

- If "Enable new users" is set to "No", this isn't reflected/respected
  in the sync panel. E.g. set it to "No", the sync panel will still show
  "Default (Yes)" for the "Enable new" field.
  Also, I'd also name it "Enable new users" in the sync panel for
  consistency.

- Continuing from the last one, the setting does not even seem to be
  respected? Setting it to "No" in both the realm settings and the sync
  panel _still_ creates new users.

- Similarly, the "Remove vanished X" options are not reflected in the
  sync panel, but _are_ respected if not touched.

- The placeholder for the bind DN should probably use @example.com, as
  that domain is explicitly reserved by RFC 2606 for such purposes.

- When anonymous search is enabled, the bind DN field still shows
  "user@company.net", which /might/ suggest to users that this is used
  as default. Very much a nit tho.

- PVE/PBS have the ability to also sync the `comment` field from some
  attribute (e.g. `sync-attributes comment=userPrincipal`), for PDM
  this currently is not allowed by the schema. But this can be part of a
  future improvement IMO.

On Tue Sep 16, 2025 at 4:48 PM CEST, Shannon Sterz wrote:
> this patch series adds ldap and active directory (ad) support to proxmox
> datacenter manager. the series first moves some of the sync logic for
> ldap & ad realms out of proxmox backup manager into the proxmox-ldap
> crate.
>
> the next series of patches fixes up the existing proxmox-yew-comp
> components for adding and editing realms to function as intended for
> adding, editing, removing and syncing ad and ldap realms.
>
> finally, we add the necessary backend infrastructure and api endpoints
> to proxmox datacenter manager and expose the new ui components there.
>
> this series does not yet move proxmox backup server to use the new
> common crate. doing so would mean that proxmox backup server would also
> need to start using proxmox-access-control, which would be a lot more
> involved and is beste handled in a separate series in my opinion.
>
> proxmox:
>
> Shannon Sterz (1):
>   ldap: add types and sync features
>
>  Cargo.toml                    |   2 +-
>  proxmox-ldap/Cargo.toml       |  22 ++
>  proxmox-ldap/debian/control   |  31 ++-
>  proxmox-ldap/debian/copyright |   2 +-
>  proxmox-ldap/src/lib.rs       |   6 +
>  proxmox-ldap/src/sync.rs      | 496 ++++++++++++++++++++++++++++++++++
>  proxmox-ldap/src/types.rs     | 317 ++++++++++++++++++++++
>  7 files changed, 873 insertions(+), 3 deletions(-)
>  create mode 100644 proxmox-ldap/src/sync.rs
>  create mode 100644 proxmox-ldap/src/types.rs
>
>
> proxmox-yew-comp:
>
> Shannon Sterz (5):
>   auth_view: add default column and allow setting ldap realms as default
>   utils: add pdm realm to `get_auth_domain_info`
>   auth_view/auth_edit_ldap: add support for active directory realms
>   auth_edit_ldap: add helpers to properly edit ad & ldap realms
>   auth_view: implement syncing ldap and ad realms
>
>  src/auth_edit_ldap.rs   | 165 ++++++++++++++++++++++++++++++++-----
>  src/auth_view.rs        | 174 +++++++++++++++++++++++++++++++++++++---
>  src/common_api_types.rs |   3 +
>  src/utils.rs            |  18 ++---
>  4 files changed, 315 insertions(+), 45 deletions(-)
>
>
> proxmox-datacenter-manager:
>
> Shannon Sterz (5):
>   config: add domain config plugins for ldap and ad realms
>   server: add ldap and active directory authenticators
>   server: api: add api endpoints for configuring ldap & ad realms
>   api/auth: add endpoint to start ldap sync jobs
>   ui: add a panel to allow handling realms
>
>  Cargo.toml                           |   1 +
>  lib/pdm-api-types/src/acl.rs         |   3 +
>  lib/pdm-api-types/src/lib.rs         |   7 +
>  lib/pdm-config/Cargo.toml            |   1 +
>  lib/pdm-config/src/domains.rs        |  35 +++
>  server/Cargo.toml                    |   1 +
>  server/src/api/access/domains.rs     |  90 ++++++-
>  server/src/api/config/access/ad.rs   | 355 +++++++++++++++++++++++++
>  server/src/api/config/access/ldap.rs | 372 +++++++++++++++++++++++++++
>  server/src/api/config/access/mod.rs  |   8 +-
>  server/src/auth/ldap.rs              | 315 +++++++++++++++++++++++
>  server/src/auth/mod.rs               |  17 +-
>  ui/src/configuration/mod.rs          |  14 +-
>  13 files changed, 1210 insertions(+), 9 deletions(-)
>  create mode 100644 server/src/api/config/access/ad.rs
>  create mode 100644 server/src/api/config/access/ldap.rs
>  create mode 100644 server/src/auth/ldap.rs
>
>
> Summary over all repositories:
>   24 files changed, 2398 insertions(+), 57 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



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


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

end of thread, other threads:[~2025-09-19 10:02 UTC | newest]

Thread overview: 13+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-09-16 14:48 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH proxmox 1/1] ldap: add types and sync features Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 1/5] auth_view: add default column and allow setting ldap realms as default Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 2/5] utils: add pdm realm to `get_auth_domain_info` Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 3/5] auth_view/auth_edit_ldap: add support for active directory realms Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 4/5] auth_edit_ldap: add helpers to properly edit ad & ldap realms Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH yew-comp 5/5] auth_view: implement syncing ldap and ad realms Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 1/5] config: add domain config plugins for " Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 2/5] server: add ldap and active directory authenticators Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 3/5] server: api: add api endpoints for configuring ldap & ad realms Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 4/5] api/auth: add endpoint to start ldap sync jobs Shannon Sterz
2025-09-16 14:48 ` [pdm-devel] [PATCH datacenter-manager 5/5] ui: add a panel to allow handling realms Shannon Sterz
2025-09-19 10:02 ` [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp 00/11] Add LDAP and AD realm support to Proxmox Datacenter Manager Christoph Heiss

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal