public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Shannon Sterz <s.sterz@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH proxmox 1/1] ldap: add types and sync features
Date: Tue, 16 Sep 2025 16:48:17 +0200	[thread overview]
Message-ID: <20250916144827.551806-2-s.sterz@proxmox.com> (raw)
In-Reply-To: <20250916144827.551806-1-s.sterz@proxmox.com>

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


  reply	other threads:[~2025-09-16 14:48 UTC|newest]

Thread overview: 13+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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 [this message]
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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20250916144827.551806-2-s.sterz@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is 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