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 v2 1/1] ldap: add types and sync features
Date: Mon, 22 Sep 2025 17:05:07 +0200	[thread overview]
Message-ID: <20250922150519.399573-2-s.sterz@proxmox.com> (raw)
In-Reply-To: <20250922150519.399573-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>
---
 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-22 15:05 UTC|newest]

Thread overview: 27+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-09-22 15:05 [pdm-devel] [PATCH datacenter-manager/proxmox/yew-comp v2 00/13] Add LDAP and AD realm support to Proxmox Datacenter Manager Shannon Sterz
2025-09-22 15:05 ` Shannon Sterz [this message]
2025-09-22 18:28   ` [pdm-devel] applied: [PATCH proxmox v2 1/1] ldap: add types and sync features Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH yew-comp v2 1/6] auth_view: add default column and allow setting ldap realms as default Shannon Sterz
2025-09-22 19:00   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH yew-comp v2 2/6] utils: add pdm realm to `get_auth_domain_info` Shannon Sterz
2025-09-22 19:00   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH yew-comp v2 3/6] auth_view/auth_edit_ldap: add support for active directory realms Shannon Sterz
2025-09-22 19:00   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH yew-comp v2 4/6] auth_edit_ldap: add helpers to properly edit ad & ldap realms Shannon Sterz
2025-09-22 19:00   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH yew-comp v2 5/6] auth_view: implement syncing ldap and ad realms Shannon Sterz
2025-09-22 19:00   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH yew-comp v2 6/6] auth_edit_ldap: improve form layout and placeholders Shannon Sterz
2025-09-22 19:00   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH datacenter-manager v2 1/6] config: add domain config plugins for ldap and ad realms Shannon Sterz
2025-09-22 19:03   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH datacenter-manager v2 2/6] server: add ldap and active directory authenticators Shannon Sterz
2025-09-22 19:03   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH datacenter-manager v2 3/6] server: api: add api endpoints for configuring ldap & ad realms Shannon Sterz
2025-09-22 19:03   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH datacenter-manager v2 4/6] api/auth: add endpoint to start ldap sync jobs Shannon Sterz
2025-09-22 19:03   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH datacenter-manager v2 5/6] ui: add a panel to allow handling realms Shannon Sterz
2025-09-22 19:03   ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-22 15:05 ` [pdm-devel] [PATCH datacenter-manager v2 6/6] ui: make the user tab reload when re-opened Shannon Sterz
2025-09-22 19:03   ` [pdm-devel] applied: " Thomas Lamprecht

Reply instructions:

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

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

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

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

  git send-email \
    --in-reply-to=20250922150519.399573-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