public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support
@ 2023-01-03 14:22 Lukas Wagner
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 01/17] pbs-config: add delete_authid to ACL-tree Lukas Wagner
                   ` (16 more replies)
  0 siblings, 17 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

This patch-series adds support for adding LDAP realms, including user sync.

The configuration scheme in `pbs-api-types` is based on the one from PVE,
with some slight differences:
  * consistent use of kebab-case for properties
  * only support `mode` instead of the deprecated `secure` property
  * `certificate-path` is used to directly point to a root CA that should be
    trusted, in addition to the default ones in `/etc/ssl/certs`.
    In PVE, the `capath` parameter is a directory that replaces the default
    of `/etc/ssl/certs`.

The GUI is mostly based on the implementation from PVE, with some slight
adaptations - for details, please refer to the commit messages.
The GUI components were added to the widget-toolkit repo, at some point PVE
could be adapted to use the same implemention as PBS.

The patches require the `ldap3` and `lber` (a dependency of ldap3, contained in
the same repo) to be packaged.
Packaging should hopefully be unproblematic - some dependencies of ldap3/lber
need to be patched to a lower version though, or, alternatively, newer versions
of the dependencies need to be packaged.

In this version of this patch series, we depend on `ldap3` v0.11-beta1.
v0.11 is a direct response to my inquiry to upstream to update the `nom`
dependency to a more recent version, so we don't have to pull in a second
version of `nom` into our graph of dependencies. I hope, upstream will
release v0.11 soon.

The implementation was tested against the following LDAP servers:
  * slapd 2.5.13 on Ubuntu Server 22.04 (LDAP, LDAPS, STARTTLS)
  * Windows Server 2022 Active Directory (LDAP)
  * glauth 2.1.0 (LDAP, LDAPS)

Some notes for patch reviewers/testers:
  * For testing of this patch series before both aforementioned crates are
    packaged, I've created a fork of ldap3 at https://github.com/lwagner94/ldap3
    The fork can be cloned and added as a local override in Cargo.toml to make
    the project compile, e.g.

    ldap3 = { path = "../ldap3"}

    The fork is based on `0.11-beta1`, and has
    its dependencies patched so that it is compatible with our package versions.

  * I can recommend `glauth` for testing: It is an LDAP server implementation
    in a statically-compiled Go binary that can be configured using a single,
    simple to understand configuration file. I can share my config if needed.


Note: This patch series includes a cherry-picked commit from Hannes' series from
https://lists.proxmox.com/pipermail/pbs-devel/2022-December/005774.html .
The functionality was needed for user sync.


proxmox-backup:

Hannes Laimer (1):
  pbs-config: add delete_authid to ACL-tree

Lukas Wagner (12):
  ui: add 'realm' field in user edit
  api-types: add LDAP configuration type
  api: add routes for managing LDAP realms
  auth: add LDAP module
  auth: add LDAP realm authenticator
  api-types: add config options for LDAP user sync
  server: add LDAP realm sync job
  manager: add LDAP commands
  manager: add sync command for LDAP realms
  docs: add configuration file reference for domains.cfg
  docs: add documentation for LDAP realms
  auth ldap: add `certificate-path` option

 Cargo.toml                             |   4 +
 docs/Makefile                          |   6 +-
 docs/command-syntax.rst                |   1 +
 docs/conf.py                           |   1 +
 docs/config/domains/format.rst         |  27 ++
 docs/config/domains/man5.rst           |  21 ++
 docs/configuration-files.rst           |  16 +
 docs/user-management.rst               |  58 +++
 pbs-api-types/src/ldap.rs              | 196 +++++++++++
 pbs-api-types/src/lib.rs               |   5 +
 pbs-api-types/src/user.rs              |   2 +-
 pbs-config/src/acl.rs                  |  71 ++++
 pbs-config/src/domains.rs              |  28 +-
 src/api2/access/domain.rs              |  85 ++++-
 src/api2/config/access/ldap.rs         | 353 +++++++++++++++++++
 src/api2/config/access/mod.rs          |   7 +-
 src/auth.rs                            |  72 +++-
 src/auth_helpers.rs                    |  51 +++
 src/bin/docgen.rs                      |   1 +
 src/bin/proxmox-backup-manager.rs      |   1 +
 src/bin/proxmox_backup_manager/ldap.rs | 178 ++++++++++
 src/bin/proxmox_backup_manager/mod.rs  |   2 +
 src/server/ldap.rs                     | 348 ++++++++++++++++++
 src/server/mod.rs                      |   5 +
 src/server/realm_sync_job.rs           | 469 +++++++++++++++++++++++++
 www/OnlineHelpInfo.js                  |   8 +
 www/Utils.js                           |   4 +-
 www/window/UserEdit.js                 |  95 ++++-
 28 files changed, 2085 insertions(+), 30 deletions(-)
 create mode 100644 docs/config/domains/format.rst
 create mode 100644 docs/config/domains/man5.rst
 create mode 100644 pbs-api-types/src/ldap.rs
 create mode 100644 src/api2/config/access/ldap.rs
 create mode 100644 src/bin/proxmox_backup_manager/ldap.rs
 create mode 100644 src/server/ldap.rs
 create mode 100644 src/server/realm_sync_job.rs

proxmox-widget-toolkit:

Lukas Wagner (4):
  auth ui: add LDAP realm edit panel
  auth ui: add LDAP sync UI
  auth ui: add `onlineHelp` for AuthEditLDAP
  auth ui: add `firstname` and `lastname` sync-attribute fields

 src/Makefile               |   2 +
 src/Schema.js              |  12 ++
 src/panel/AuthView.js      |  24 +++
 src/window/AuthEditLDAP.js | 367 +++++++++++++++++++++++++++++++++++++
 src/window/SyncWindow.js   | 192 +++++++++++++++++++
 5 files changed, 597 insertions(+)
 create mode 100644 src/window/AuthEditLDAP.js
 create mode 100644 src/window/SyncWindow.js

-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 01/17] pbs-config: add delete_authid to ACL-tree
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-04 10:23   ` Wolfgang Bumiller
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 02/17] ui: add 'realm' field in user edit Lukas Wagner
                   ` (15 subsequent siblings)
  16 siblings, 1 reply; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

From: Hannes Laimer <h.laimer@proxmox.com>

... allows the deletion of an authid from the whole tree. Needed
for removing deleted users/tokens.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pbs-config/src/acl.rs | 71 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 71 insertions(+)

diff --git a/pbs-config/src/acl.rs b/pbs-config/src/acl.rs
index 89a54dfc..a4a79755 100644
--- a/pbs-config/src/acl.rs
+++ b/pbs-config/src/acl.rs
@@ -280,6 +280,13 @@ impl AclTreeNode {
         roles.remove(role);
     }
 
+    fn delete_authid(&mut self, auth_id: &Authid) {
+        for (_name, node) in self.children.iter_mut() {
+            node.delete_authid(auth_id);
+        }
+        self.users.remove(auth_id);
+    }
+
     fn insert_group_role(&mut self, group: String, role: String, propagate: bool) {
         let map = self.groups.entry(group).or_default();
         if role == ROLE_NAME_NO_ACCESS {
@@ -411,6 +418,14 @@ impl AclTree {
         }
     }
 
+    /// Deletes a user or token from the ACL-tree
+    ///
+    /// Traverses the tree in-order and removes the given user/token by their Authid
+    /// from every node in the tree.
+    pub fn delete_authid(&mut self, auth_id: &Authid) {
+        self.root.delete_authid(auth_id);
+    }
+
     /// Inserts the specified `role` into the `group` ACL on `path`.
     ///
     /// The [`AclTreeNode`] representing `path` will be created and inserted into the tree if
@@ -1010,4 +1025,60 @@ acl:1:/storage/store1:user1@pbs:DatastoreBackup
 
         Ok(())
     }
+
+    #[test]
+    fn test_delete_authid() -> Result<(), Error> {
+        let mut tree = AclTree::new();
+
+        let user1: Authid = "user1@pbs".parse()?;
+        let user2: Authid = "user2@pbs".parse()?;
+
+        let user1_paths = vec![
+            "/",
+            "/storage",
+            "/storage/a",
+            "/storage/a/b",
+            "/storage/b",
+            "/storage/b/a",
+            "/storage/b/b",
+            "/storage/a/a",
+        ];
+        let user2_paths = vec!["/", "/storage", "/storage/a/b", "/storage/a/a"];
+
+        for path in &user1_paths {
+            tree.insert_user_role(path, &user1, "NoAccess", true);
+        }
+        for path in &user2_paths {
+            tree.insert_user_role(path, &user2, "NoAccess", true);
+        }
+
+        tree.delete_authid(&user1);
+
+        for path in &user1_paths {
+            let node = tree.find_node(path);
+            assert!(node.is_some());
+            if let Some(node) = node {
+                assert!(node.users.get(&user1).is_none());
+            }
+        }
+        for path in &user2_paths {
+            let node = tree.find_node(path);
+            assert!(node.is_some());
+            if let Some(node) = node {
+                assert!(node.users.get(&user2).is_some());
+            }
+        }
+
+        tree.delete_authid(&user2);
+
+        for path in &user2_paths {
+            let node = tree.find_node(path);
+            assert!(node.is_some());
+            if let Some(node) = node {
+                assert!(node.users.get(&user2).is_none());
+            }
+        }
+
+        Ok(())
+    }
 }
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 02/17] ui: add 'realm' field in user edit
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 01/17] pbs-config: add delete_authid to ACL-tree Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 03/17] api-types: add LDAP configuration type Lukas Wagner
                   ` (14 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

This allows specifying a user's realm when adding a new user.
For now, adding users to the PAM realm is explicitely disabled

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 www/window/UserEdit.js | 95 +++++++++++++++++++++++++++++++++++-------
 1 file changed, 80 insertions(+), 15 deletions(-)

diff --git a/www/window/UserEdit.js b/www/window/UserEdit.js
index 06ec5377..092ff31f 100644
--- a/www/window/UserEdit.js
+++ b/www/window/UserEdit.js
@@ -1,3 +1,27 @@
+Ext.define('PBS.window.UserEditViewModel', {
+    extend: 'Ext.app.ViewModel',
+
+    alias: 'viewmodel.pbsUserEdit',
+
+    data: {
+	realm: 'pbs',
+    },
+
+    formulas: {
+	maySetPassword: function(get) {
+	    // Dummy read, so that ExtJS will update the formula when
+	    // the combobox changes
+	    let _dummy = get('realm');
+
+	    // All in all a bit hacky, is there a nicer way to do this?
+	    let realm_type = this.data.realmComboBox.selection?.data.type
+		? this.data.realmComboBox.selection?.data.type : 'pbs';
+
+	    return Proxmox.Schema.authDomains[realm_type].pwchange && this.config.view.isCreate;
+	},
+    },
+});
+
 Ext.define('PBS.window.UserEdit', {
     extend: 'Proxmox.window.Edit',
     alias: 'widget.pbsUserEdit',
@@ -13,6 +37,10 @@ Ext.define('PBS.window.UserEdit', {
 
     fieldDefaults: { labelWidth: 120 },
 
+    viewModel: {
+	type: 'pbsUserEdit',
+    },
+
     cbindData: function(initialConfig) {
 	var me = this;
 
@@ -43,6 +71,43 @@ Ext.define('PBS.window.UserEdit', {
 		    editable: '{isCreate}',
 		},
 	    },
+	    {
+		xtype: 'pmxRealmComboBox',
+		name: 'realm',
+		fieldLabel: gettext('Realm'),
+		allowBlank: false,
+		matchFieldWidth: false,
+		listConfig: { width: 300 },
+		reference: 'realmComboBox',
+		bind: '{realm}',
+		cbind: {
+		    hidden: '{!isCreate}',
+		    disabled: '{!isCreate}',
+		},
+
+		submitValue: true,
+		// Let's override the default controller so that we can
+		// remove the PAM realm. We don't want to manually add users
+		// for the PAM realm.
+		controller: {
+		    xclass: 'Ext.app.ViewController',
+
+		    init: function(view) {
+			view.store.on('load', this.onLoad, view);
+		    },
+
+		    onLoad: function(store, records, success) {
+			if (!success) {
+			    return;
+			}
+
+			let pamRecord = this.store.findRecord('realm', 'pam', 0, false, true, true);
+
+			this.store.remove(pamRecord);
+			this.setValue('pbs');
+		    },
+		},
+	    },
 	    {
 		xtype: 'textfield',
 		inputType: 'password',
@@ -51,16 +116,16 @@ Ext.define('PBS.window.UserEdit', {
 		allowBlank: false,
 		name: 'password',
 		listeners: {
-                    change: function(field) {
+		    change: function(field) {
 			field.next().validate();
-                    },
-                    blur: function(field) {
+		    },
+		    blur: function(field) {
 			field.next().validate();
-                    },
+		    },
 		},
-		cbind: {
-		    hidden: '{!isCreate}',
-		    disabled: '{!isCreate}',
+		bind: {
+		    disabled: '{!maySetPassword}',
+		    hidden: '{!maySetPassword}',
 		},
 	    },
 	    {
@@ -72,19 +137,19 @@ Ext.define('PBS.window.UserEdit', {
 		initialPassField: 'password',
 		allowBlank: false,
 		submitValue: false,
-		cbind: {
-		    hidden: '{!isCreate}',
-		    disabled: '{!isCreate}',
+		bind: {
+		    disabled: '{!maySetPassword}',
+		    hidden: '{!maySetPassword}',
 		},
 	    },
 	    {
-                xtype: 'datefield',
-                name: 'expire',
+		xtype: 'datefield',
+		name: 'expire',
 		emptyText: Proxmox.Utils.neverText,
 		format: 'Y-m-d',
 		submitFormat: 'U',
-                fieldLabel: gettext('Expire'),
-            },
+		fieldLabel: gettext('Expire'),
+	    },
 	    {
 		xtype: 'proxmoxcheckbox',
 		fieldLabel: gettext('Enabled'),
@@ -146,7 +211,7 @@ Ext.define('PBS.window.UserEdit', {
 	}
 
 	if (me.isCreate) {
-	    values.userid = values.userid + '@pbs';
+	    values.userid = values.userid + '@' + values.realm;
 	}
 
 	delete values.username;
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 03/17] api-types: add LDAP configuration type
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 01/17] pbs-config: add delete_authid to ACL-tree Lukas Wagner
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 02/17] ui: add 'realm' field in user edit Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 04/17] api: add routes for managing LDAP realms Lukas Wagner
                   ` (13 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

The properties are mainly based on the ones from PVE, except:
  * consistent use of kebab-cases
  * `mode` replaces deprecated `secure`


Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pbs-api-types/src/ldap.rs | 71 +++++++++++++++++++++++++++++++++++++++
 pbs-api-types/src/lib.rs  |  5 +++
 2 files changed, 76 insertions(+)
 create mode 100644 pbs-api-types/src/ldap.rs

diff --git a/pbs-api-types/src/ldap.rs b/pbs-api-types/src/ldap.rs
new file mode 100644
index 00000000..a08e124b
--- /dev/null
+++ b/pbs-api-types/src/ldap.rs
@@ -0,0 +1,71 @@
+use serde::{Deserialize, Serialize};
+
+use proxmox_schema::{api, Updater};
+
+use super::{REALM_ID_SCHEMA, SINGLE_LINE_COMMENT_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: SINGLE_LINE_COMMENT_SCHEMA,
+        },
+        "verify": {
+            optional: true,
+            default: false,
+        }
+    },
+)]
+#[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>,
+    /// 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>,
+    /// Bind domain to use for looking up users
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub bind_dn: Option<String>,
+    /// Bind password for the given bind-dn
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub password: Option<String>,
+}
diff --git a/pbs-api-types/src/lib.rs b/pbs-api-types/src/lib.rs
index 5e043954..0479b637 100644
--- a/pbs-api-types/src/lib.rs
+++ b/pbs-api-types/src/lib.rs
@@ -108,6 +108,9 @@ pub mod file_restore;
 mod openid;
 pub use openid::*;
 
+mod ldap;
+pub use ldap::*;
+
 mod remote;
 pub use remote::*;
 
@@ -502,6 +505,8 @@ pub enum RealmType {
     Pbs,
     /// An OpenID Connect realm
     OpenId,
+    /// An LDAP realm
+    Ldap,
 }
 
 #[api(
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 04/17] api: add routes for managing LDAP realms
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (2 preceding siblings ...)
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 03/17] api-types: add LDAP configuration type Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-04 11:16   ` Wolfgang Bumiller
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module Lukas Wagner
                   ` (12 subsequent siblings)
  16 siblings, 1 reply; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

Note: bind-passwords set via the API  are not stored in `domains.cfg`,
but in a separate `ldap_passwords.json` file located in
`/etc/proxmox-backup/`.
Similar to the already existing `shadow.json`, the file is
stored with 0600 permissions and is owned by root.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pbs-config/src/domains.rs      |  16 +-
 src/api2/config/access/ldap.rs | 316 +++++++++++++++++++++++++++++++++
 src/api2/config/access/mod.rs  |   7 +-
 src/auth_helpers.rs            |  51 ++++++
 4 files changed, 387 insertions(+), 3 deletions(-)
 create mode 100644 src/api2/config/access/ldap.rs

diff --git a/pbs-config/src/domains.rs b/pbs-config/src/domains.rs
index 12d4543d..6cef3ec5 100644
--- a/pbs-config/src/domains.rs
+++ b/pbs-config/src/domains.rs
@@ -7,13 +7,15 @@ use proxmox_schema::{ApiType, Schema};
 use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
 
 use crate::{open_backup_lockfile, replace_backup_config, BackupLockGuard};
-use pbs_api_types::{OpenIdRealmConfig, REALM_ID_SCHEMA};
+use pbs_api_types::{LdapRealmConfig, OpenIdRealmConfig, REALM_ID_SCHEMA};
 
 lazy_static! {
     pub static ref CONFIG: SectionConfig = init();
 }
 
 fn init() -> SectionConfig {
+    let mut config = SectionConfig::new(&REALM_ID_SCHEMA);
+
     let obj_schema = match OpenIdRealmConfig::API_SCHEMA {
         Schema::Object(ref obj_schema) => obj_schema,
         _ => unreachable!(),
@@ -24,7 +26,17 @@ fn init() -> SectionConfig {
         Some(String::from("realm")),
         obj_schema,
     );
-    let mut config = SectionConfig::new(&REALM_ID_SCHEMA);
+
+    config.register_plugin(plugin);
+
+    let obj_schema = match LdapRealmConfig::API_SCHEMA {
+        Schema::Object(ref obj_schema) => obj_schema,
+        _ => unreachable!(),
+    };
+
+    let plugin =
+        SectionConfigPlugin::new("ldap".to_string(), Some(String::from("realm")), obj_schema);
+
     config.register_plugin(plugin);
 
     config
diff --git a/src/api2/config/access/ldap.rs b/src/api2/config/access/ldap.rs
new file mode 100644
index 00000000..14bbf9ea
--- /dev/null
+++ b/src/api2/config/access/ldap.rs
@@ -0,0 +1,316 @@
+use ::serde::{Deserialize, Serialize};
+use anyhow::Error;
+use hex::FromHex;
+use serde_json::Value;
+
+use proxmox_router::{http_bail, Permission, Router, RpcEnvironment};
+use proxmox_schema::{api, param_bail};
+
+use pbs_api_types::{
+    LdapRealmConfig, LdapRealmConfigUpdater, PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT,
+    PROXMOX_CONFIG_DIGEST_SCHEMA, REALM_ID_SCHEMA,
+};
+
+use pbs_config::domains;
+
+use crate::auth_helpers;
+
+#[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"] = hex::encode(digest).into();
+
+    Ok(list)
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            config: {
+                type: LdapRealmConfig,
+                flatten: true,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Create a new LDAP realm
+pub fn create_ldap_realm(mut config: LdapRealmConfig) -> Result<(), Error> {
+    let _lock = domains::lock_config()?;
+
+    let (mut domains, _digest) = domains::config()?;
+
+    if config.realm == "pbs"
+        || config.realm == "pam"
+        || domains.sections.get(&config.realm).is_some()
+    {
+        param_bail!("realm", "realm '{}' already exists.", config.realm);
+    }
+
+    // If a bind password is set, take it out of the config struct and
+    // save it separately with proper protection
+    if let Some(password) = config.password.take() {
+        auth_helpers::store_ldap_bind_password(&config.realm, &password)?;
+    }
+
+    debug_assert!(config.password.is_none());
+
+    domains.set_data(&config.realm, "ldap", &config)?;
+
+    domains::save_config(&domains)?;
+
+    Ok(())
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Remove an LDAP realm configuration
+pub fn delete_ldap_realm(
+    realm: String,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = domains::lock_config()?;
+
+    let (mut domains, expected_digest) = domains::config()?;
+
+    if let Some(ref digest) = digest {
+        let digest = <[u8; 32]>::from_hex(digest)?;
+        crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
+    }
+
+    if domains.sections.remove(&realm).is_none() {
+        http_bail!(NOT_FOUND, "realm '{}' does not exist.", realm);
+    }
+
+    domains::save_config(&domains)?;
+
+    auth_helpers::remove_ldap_bind_password(&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"] = hex::encode(digest).into();
+
+    Ok(config)
+}
+
+#[api()]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+#[allow(non_camel_case_types)]
+/// Deletable property name
+pub enum DeletableProperty {
+    /// Fallback LDAP server address
+    server2,
+    /// Port
+    port,
+    /// Comment
+    comment,
+    /// Verify server certificate
+    verify,
+    /// Mode (ldap, ldap+starttls or ldaps),
+    mode,
+    /// Bind Domain
+    bind_dn,
+    /// Bind password
+    password,
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+            update: {
+                type: LdapRealmConfigUpdater,
+                flatten: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeletableProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+            },
+        },
+    },
+    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,
+    delete: Option<Vec<DeletableProperty>>,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let _lock = domains::lock_config()?;
+
+    let (mut domains, expected_digest) = domains::config()?;
+
+    if let Some(ref digest) = digest {
+        let digest = <[u8; 32]>::from_hex(digest)?;
+        crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
+    }
+
+    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::port => {
+                    config.port = None;
+                }
+                DeletableProperty::verify => {
+                    config.verify = None;
+                }
+                DeletableProperty::mode => {
+                    config.mode = None;
+                }
+                DeletableProperty::bind_dn => {
+                    config.bind_dn = None;
+                }
+                DeletableProperty::password => {
+                    auth_helpers::remove_ldap_bind_password(&realm)?;
+                }
+            }
+        }
+    }
+
+    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(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(password) = update.password {
+        auth_helpers::store_ldap_bind_password(&realm, &password)?;
+    }
+
+    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/src/api2/config/access/mod.rs b/src/api2/config/access/mod.rs
index a813646c..a75d89b4 100644
--- a/src/api2/config/access/mod.rs
+++ b/src/api2/config/access/mod.rs
@@ -2,11 +2,16 @@ use proxmox_router::list_subdirs_api_method;
 use proxmox_router::{Router, SubdirMap};
 use proxmox_sys::sortable;
 
+pub mod ldap;
 pub mod openid;
 pub mod tfa;
 
 #[sortable]
-const SUBDIRS: SubdirMap = &sorted!([("openid", &openid::ROUTER), ("tfa", &tfa::ROUTER),]);
+const SUBDIRS: SubdirMap = &sorted!([
+    ("ldap", &ldap::ROUTER),
+    ("openid", &openid::ROUTER),
+    ("tfa", &tfa::ROUTER),
+]);
 
 pub const ROUTER: Router = Router::new()
     .get(&list_subdirs_api_method!(SUBDIRS))
diff --git a/src/auth_helpers.rs b/src/auth_helpers.rs
index 57e02900..79128811 100644
--- a/src/auth_helpers.rs
+++ b/src/auth_helpers.rs
@@ -11,6 +11,7 @@ use proxmox_sys::fs::{file_get_contents, replace_file, CreateOptions};
 
 use pbs_api_types::Userid;
 use pbs_buildcfg::configdir;
+use serde_json::json;
 
 fn compute_csrf_secret_digest(timestamp: i64, secret: &[u8], userid: &Userid) -> String {
     let mut hasher = sha::Sha256::new();
@@ -180,3 +181,53 @@ pub fn private_auth_key() -> &'static PKey<Private> {
 
     &KEY
 }
+
+const LDAP_PASSWORDS_FILENAME: &str = configdir!("/ldap_passwords.json");
+
+/// Store LDAP bind passwords in protected file
+pub fn store_ldap_bind_password(realm: &str, password: &str) -> Result<(), Error> {
+    let mut data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?;
+    data[realm] = password.into();
+
+    let mode = nix::sys::stat::Mode::from_bits_truncate(0o0600);
+    let options = proxmox_sys::fs::CreateOptions::new()
+        .perm(mode)
+        .owner(nix::unistd::ROOT)
+        .group(nix::unistd::Gid::from_raw(0));
+
+    let data = serde_json::to_vec_pretty(&data)?;
+    proxmox_sys::fs::replace_file(LDAP_PASSWORDS_FILENAME, &data, options, true)?;
+
+    Ok(())
+}
+
+/// Remove stored LDAP bind password
+pub fn remove_ldap_bind_password(realm: &str) -> 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 mode = nix::sys::stat::Mode::from_bits_truncate(0o0600);
+    let options = proxmox_sys::fs::CreateOptions::new()
+        .perm(mode)
+        .owner(nix::unistd::ROOT)
+        .group(nix::unistd::Gid::from_raw(0));
+
+    let data = serde_json::to_vec_pretty(&data)?;
+    proxmox_sys::fs::replace_file(LDAP_PASSWORDS_FILENAME, &data, options, true)?;
+
+    Ok(())
+}
+
+/// Retrieve stored LDAP bind password
+pub 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)
+}
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (3 preceding siblings ...)
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 04/17] api: add routes for managing LDAP realms Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-04 13:23   ` Wolfgang Bumiller
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator Lukas Wagner
                   ` (11 subsequent siblings)
  16 siblings, 1 reply; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

The module is an abstraction over the ldap3 crate. It uses
its own configuration structs to prevent strongly coupling it
to pbs-api-types.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml         |   2 +
 src/server/ldap.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++
 src/server/mod.rs  |   2 +
 3 files changed, 178 insertions(+)
 create mode 100644 src/server/ldap.rs

diff --git a/Cargo.toml b/Cargo.toml
index 2639b4b1..c9f1f185 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -118,6 +118,7 @@ hex = "0.4.3"
 http = "0.2"
 hyper = { version = "0.14", features = [ "full" ] }
 lazy_static = "1.4"
+ldap3 = { version = "0.11.0-beta.1", default_features=false, features=["tls"]}
 libc = "0.2"
 log = "0.4.17"
 nix = "0.24"
@@ -169,6 +170,7 @@ hex.workspace = true
 http.workspace = true
 hyper.workspace = true
 lazy_static.workspace = true
+ldap3.workspace = true
 libc.workspace = true
 log.workspace = true
 nix.workspace = true
diff --git a/src/server/ldap.rs b/src/server/ldap.rs
new file mode 100644
index 00000000..a8b7a79d
--- /dev/null
+++ b/src/server/ldap.rs
@@ -0,0 +1,174 @@
+use std::time::Duration;
+
+use anyhow::{bail, Error};
+use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry};
+
+#[derive(PartialEq, Eq)]
+/// LDAP connection security
+pub enum LdapConnectionMode {
+    /// unencrypted connection
+    Ldap,
+    /// upgrade to TLS via STARTTLS
+    StartTls,
+    /// TLS via LDAPS
+    Ldaps,
+}
+
+/// Configuration for LDAP connections
+pub struct LdapConfig {
+    /// Array of servers that will be tried in order
+    pub servers: Vec<String>,
+    /// Port
+    pub port: Option<u16>,
+    /// LDAP attribute containing the user id. Will be used to look up the user's domain
+    pub user_attr: String,
+    /// LDAP base domain
+    pub base_dn: String,
+    /// LDAP bind domain, will be used for user lookup/sync if set
+    pub bind_dn: Option<String>,
+    /// LDAP bind password, will be used for user lookup/sync if set
+    pub bind_password: Option<String>,
+    /// Connection security
+    pub tls_mode: LdapConnectionMode,
+    /// Verify the server's TLS certificate
+    pub verify_certificate: bool,
+}
+
+pub struct LdapConnection {
+    config: LdapConfig,
+}
+
+impl LdapConnection {
+    const LDAP_DEFAULT_PORT: u16 = 389;
+    const LDAPS_DEFAULT_PORT: u16 = 636;
+    const LDAP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
+
+    pub fn new(config: LdapConfig) -> Self {
+        Self { config }
+    }
+
+    /// Authenticate a user with username/password.
+    ///
+    /// The user's domain is queried is by performing an LDAP search with the configured bind_dn
+    /// and bind_password. If no bind_dn is provided, an anonymous search is attempted.
+    pub async fn authenticate_user(&self, username: &str, password: &str) -> Result<(), Error> {
+        let user_dn = self.search_user_dn(username).await?;
+
+        let mut ldap = self.create_connection().await?;
+
+        // Perform actual user authentication by binding.
+        let _: LdapResult = ldap.simple_bind(&user_dn, password).await?.success()?;
+
+        // We are already authenticated, so don't fail if terminating the connection
+        // does not work for some reason.
+        let _: Result<(), _> = ldap.unbind().await;
+
+        Ok(())
+    }
+
+    /// Retrive port from LDAP configuration, otherwise use the appropriate default
+    fn port_from_config(&self) -> u16 {
+        self.config.port.unwrap_or_else(|| {
+            if self.config.tls_mode == LdapConnectionMode::Ldaps {
+                Self::LDAPS_DEFAULT_PORT
+            } else {
+                Self::LDAP_DEFAULT_PORT
+            }
+        })
+    }
+
+    /// Determine correct URL scheme from LDAP config
+    fn scheme_from_config(&self) -> &'static str {
+        if self.config.tls_mode == LdapConnectionMode::Ldaps {
+            "ldaps"
+        } else {
+            "ldap"
+        }
+    }
+
+    /// Construct URL from LDAP config
+    fn ldap_url_from_config(&self, server: &str) -> String {
+        let port = self.port_from_config();
+        let scheme = self.scheme_from_config();
+        format!("{scheme}://{server}:{port}")
+    }
+
+    async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> {
+        let starttls = self.config.tls_mode == LdapConnectionMode::StartTls;
+
+        LdapConnAsync::with_settings(
+            LdapConnSettings::new()
+                .set_no_tls_verify(!self.config.verify_certificate)
+                .set_starttls(starttls)
+                .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT),
+            url,
+        )
+        .await
+        .map_err(|e| e.into())
+    }
+
+    /// Create LDAP connection
+    ///
+    /// If a connection to the server cannot be established, the fallbacks
+    /// are tried.
+    async fn create_connection(&self) -> Result<Ldap, Error> {
+        let mut last_error = None;
+
+        for server in &self.config.servers {
+            match self.try_connect(&self.ldap_url_from_config(server)).await {
+                Ok((connection, ldap)) => {
+                    ldap3::drive!(connection);
+                    return Ok(ldap);
+                }
+                Err(e) => {
+                    last_error = Some(e);
+                }
+            }
+        }
+
+        Err(last_error.unwrap())
+    }
+
+    /// Search a user's domain.
+    async fn search_user_dn(&self, username: &str) -> Result<String, Error> {
+        let mut ldap = self.create_connection().await?;
+
+        if let Some(bind_dn) = self.config.bind_dn.as_deref() {
+            let password = self.config.bind_password.as_deref().unwrap_or_default();
+            let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?;
+
+            let user_dn = self.do_search_user_dn(username, &mut ldap).await;
+
+            ldap.unbind().await?;
+
+            user_dn
+        } else {
+            self.do_search_user_dn(username, &mut ldap).await
+        }
+    }
+
+    async fn do_search_user_dn(&self, username: &str, ldap: &mut Ldap) -> Result<String, Error> {
+        let query = format!("(&({}={}))", self.config.user_attr, username);
+
+        let (entries, _res) = ldap
+            .search(&self.config.base_dn, Scope::Subtree, &query, vec!["dn"])
+            .await?
+            .success()?;
+
+        if entries.len() > 1 {
+            bail!(
+                "found multiple users with attribute `{}={}`",
+                self.config.user_attr,
+                username
+            )
+        }
+
+        if let Some(entry) = entries.into_iter().next() {
+            let entry = SearchEntry::construct(entry);
+
+            return Ok(entry.dn);
+        }
+
+        bail!("user not found")
+    }
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 06dcb867..649c1c51 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -13,6 +13,8 @@ use pbs_buildcfg;
 
 pub mod jobstate;
 
+pub mod ldap;
+
 mod verify_job;
 pub use verify_job::*;
 
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (4 preceding siblings ...)
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-04 13:32   ` Wolfgang Bumiller
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 07/17] api-types: add config options for LDAP user sync Lukas Wagner
                   ` (10 subsequent siblings)
  16 siblings, 1 reply; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/auth.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 63 insertions(+), 2 deletions(-)

diff --git a/src/auth.rs b/src/auth.rs
index f1d5c0a1..101bec0e 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -8,9 +8,12 @@ use std::process::{Command, Stdio};
 use anyhow::{bail, format_err, Error};
 use serde_json::json;
 
-use pbs_api_types::{RealmRef, Userid, UsernameRef};
+use pbs_api_types::{LdapMode, LdapRealmConfig, RealmRef, Userid, UsernameRef};
 use pbs_buildcfg::configdir;
 
+use crate::auth_helpers;
+use crate::server::ldap::{LdapConfig, LdapConnection, LdapConnectionMode};
+
 pub trait ProxmoxAuthenticator {
     fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error>;
     fn store_password(&self, username: &UsernameRef, password: &str) -> Result<(), Error>;
@@ -122,12 +125,45 @@ impl ProxmoxAuthenticator for PBS {
     }
 }
 
+#[allow(clippy::upper_case_acronyms)]
+pub struct LDAP {
+    config: LdapRealmConfig,
+}
+
+impl ProxmoxAuthenticator for LDAP {
+    /// Authenticate user in LDAP realm
+    fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error> {
+        let ldap_config = ldap_api_type_to_ldap_config(&self.config)?;
+
+        let ldap = LdapConnection::new(ldap_config);
+
+        proxmox_async::runtime::block_on(ldap.authenticate_user(username.as_str(), password))
+    }
+
+    fn store_password(&self, _username: &UsernameRef, _password: &str) -> Result<(), Error> {
+        // do not store password for LDAP users
+        Ok(())
+    }
+
+    fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> {
+        // do not remove password for LDAP users
+        Ok(())
+    }
+}
+
 /// Lookup the autenticator for the specified realm
 pub fn lookup_authenticator(realm: &RealmRef) -> Result<Box<dyn ProxmoxAuthenticator>, Error> {
     match realm.as_str() {
         "pam" => Ok(Box::new(PAM())),
         "pbs" => Ok(Box::new(PBS())),
-        _ => bail!("unknown realm '{}'", realm.as_str()),
+        realm => {
+            let (domains, _digest) = pbs_config::domains::config()?;
+            if let Ok(config) = domains.lookup::<LdapRealmConfig>("ldap", realm) {
+                Ok(Box::new(LDAP { config }))
+            } else {
+                bail!("unknown realm '{}'", realm);
+            }
+        }
     }
 }
 
@@ -135,3 +171,28 @@ pub fn lookup_authenticator(realm: &RealmRef) -> Result<Box<dyn ProxmoxAuthentic
 pub fn authenticate_user(userid: &Userid, password: &str) -> Result<(), Error> {
     lookup_authenticator(userid.realm())?.authenticate_user(userid.name(), password)
 }
+
+// TODO: Is there a better place for this?
+pub fn ldap_api_type_to_ldap_config(config: &LdapRealmConfig) -> Result<LdapConfig, Error> {
+    let mut servers = vec![config.server1.clone()];
+    if let Some(server) = &config.server2 {
+        servers.push(server.clone());
+    }
+
+    let tls_mode = match config.mode.unwrap_or_default() {
+        LdapMode::Ldap => LdapConnectionMode::Ldap,
+        LdapMode::StartTls => LdapConnectionMode::StartTls,
+        LdapMode::Ldaps => LdapConnectionMode::Ldaps,
+    };
+
+    Ok(LdapConfig {
+        servers,
+        port: config.port,
+        user_attr: config.user_attr.clone(),
+        base_dn: config.base_dn.clone(),
+        bind_dn: config.bind_dn.clone(),
+        bind_password: auth_helpers::get_ldap_bind_password(&config.realm)?,
+        tls_mode,
+        verify_certificate: config.verify.unwrap_or_default(),
+    })
+}
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 07/17] api-types: add config options for LDAP user sync
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (5 preceding siblings ...)
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-04 13:40   ` Wolfgang Bumiller
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 08/17] server: add LDAP realm sync job Lukas Wagner
                   ` (9 subsequent siblings)
  16 siblings, 1 reply; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pbs-api-types/src/ldap.rs      | 124 ++++++++++++++++++++++++++++++++-
 src/api2/config/access/ldap.rs |  37 ++++++++++
 2 files changed, 159 insertions(+), 2 deletions(-)

diff --git a/pbs-api-types/src/ldap.rs b/pbs-api-types/src/ldap.rs
index a08e124b..672c81cd 100644
--- a/pbs-api-types/src/ldap.rs
+++ b/pbs-api-types/src/ldap.rs
@@ -1,6 +1,6 @@
 use serde::{Deserialize, Serialize};
 
-use proxmox_schema::{api, Updater};
+use proxmox_schema::{api, ApiStringFormat, ApiType, ArraySchema, Schema, StringSchema, Updater};
 
 use super::{REALM_ID_SCHEMA, SINGLE_LINE_COMMENT_SCHEMA};
 
@@ -32,7 +32,19 @@ pub enum LdapMode {
         "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,
+        },
     },
 )]
 #[derive(Serialize, Deserialize, Updater, Clone)]
@@ -68,4 +80,112 @@ pub struct LdapRealmConfig {
     /// Bind password for the given bind-dn
     #[serde(skip_serializing_if = "Option::is_none")]
     pub password: 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: {
+        "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 SYNC_DEFAULTS_STRING_SCHEMA: Schema = StringSchema::new("sync defaults options")
+    .format(&ApiStringFormat::PropertyString(
+        &SyncDefaultsOptions::API_SCHEMA,
+    ))
+    .schema();
+
+const REMOVE_VANISHED_DESCRIPTION: &str =
+    "A semicolon-seperated 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 PBS user field. For example, \
+to map the LDAP attribute ``mail`` to PBS'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();
diff --git a/src/api2/config/access/ldap.rs b/src/api2/config/access/ldap.rs
index 14bbf9ea..2206cbe9 100644
--- a/src/api2/config/access/ldap.rs
+++ b/src/api2/config/access/ldap.rs
@@ -79,6 +79,8 @@ pub fn create_ldap_realm(mut config: LdapRealmConfig) -> Result<(), Error> {
 
     domains.set_data(&config.realm, "ldap", &config)?;
 
+    debug_assert!(config.password.is_none());
+
     domains::save_config(&domains)?;
 
     Ok(())
@@ -174,6 +176,14 @@ pub enum DeletableProperty {
     bind_dn,
     /// Bind password
     password,
+    /// User filter
+    filter,
+    /// Default options for user sync
+    sync_defaults_options,
+    /// user attributes to sync with LDAP attributes
+    sync_attributes,
+    /// User classes
+    user_classes,
 }
 
 #[api(
@@ -249,6 +259,18 @@ pub fn update_ldap_realm(
                 DeletableProperty::password => {
                     auth_helpers::remove_ldap_bind_password(&realm)?;
                 }
+                DeletableProperty::filter => {
+                    config.filter = None;
+                }
+                DeletableProperty::sync_defaults_options => {
+                    config.sync_defaults_options = None;
+                }
+                DeletableProperty::sync_attributes => {
+                    config.sync_attributes = None;
+                }
+                DeletableProperty::user_classes => {
+                    config.user_classes = None;
+                }
             }
         }
     }
@@ -298,8 +320,23 @@ pub fn update_ldap_realm(
         auth_helpers::store_ldap_bind_password(&realm, &password)?;
     }
 
+    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);
+    }
+
     domains.set_data(&realm, "ldap", &config)?;
 
+    debug_assert!(config.password.is_none());
+
     domains::save_config(&domains)?;
 
     Ok(())
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 08/17] server: add LDAP realm sync job
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (6 preceding siblings ...)
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 07/17] api-types: add config options for LDAP user sync Lukas Wagner
@ 2023-01-03 14:22 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 09/17] manager: add LDAP commands Lukas Wagner
                   ` (8 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:22 UTC (permalink / raw)
  To: pbs-devel

This commit adds sync jobs for LDAP user sync. As of now, they
can only be started manually.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pbs-api-types/src/user.rs    |   2 +-
 src/api2/access/domain.rs    |  85 ++++++-
 src/server/ldap.rs           | 171 ++++++++++++-
 src/server/mod.rs            |   3 +
 src/server/realm_sync_job.rs | 469 +++++++++++++++++++++++++++++++++++
 www/Utils.js                 |   4 +-
 6 files changed, 724 insertions(+), 10 deletions(-)
 create mode 100644 src/server/realm_sync_job.rs

diff --git a/pbs-api-types/src/user.rs b/pbs-api-types/src/user.rs
index a7481190..21bf0e61 100644
--- a/pbs-api-types/src/user.rs
+++ b/pbs-api-types/src/user.rs
@@ -172,7 +172,7 @@ impl ApiToken {
         },
     }
 )]
-#[derive(Serialize, Deserialize, Updater)]
+#[derive(Serialize, Deserialize, Updater, PartialEq, Eq)]
 /// User properties.
 pub struct User {
     #[updater(skip)]
diff --git a/src/api2/access/domain.rs b/src/api2/access/domain.rs
index 3aaf98ae..31aa62bc 100644
--- a/src/api2/access/domain.rs
+++ b/src/api2/access/domain.rs
@@ -1,12 +1,16 @@
 //! List Authentication domains/realms
 
-use anyhow::Error;
+use anyhow::{format_err, Error};
 use serde_json::{json, Value};
 
-use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_router::{Permission, Router, RpcEnvironment, RpcEnvironmentType, SubdirMap};
 use proxmox_schema::api;
 
-use pbs_api_types::BasicRealmInfo;
+use pbs_api_types::{
+    Authid, BasicRealmInfo, Realm, PRIV_PERMISSIONS_MODIFY, REMOVE_VANISHED_SCHEMA, UPID_SCHEMA,
+};
+
+use crate::server::jobstate::Job;
 
 #[api(
     returns: {
@@ -50,4 +54,77 @@ 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 job = Job::new("realm-sync", realm.as_str())
+        .map_err(|_| format_err!("realm sync already running"))?;
+
+    let to_stdout = rpcenv.env_type() == RpcEnvironmentType::CLI;
+
+    let upid_str = crate::server::do_realm_sync_job(
+        job,
+        realm.clone(),
+        &auth_id,
+        None,
+        to_stdout,
+        dry_run,
+        remove_vanished,
+        enable_new,
+    )
+    .map_err(|err| {
+        format_err!(
+            "unable to start realm sync job on realm {} - {}",
+            realm.as_str(),
+            err
+        )
+    })?;
+
+    Ok(json!(upid_str))
+}
+
+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/src/server/ldap.rs b/src/server/ldap.rs
index a8b7a79d..2e218cf6 100644
--- a/src/server/ldap.rs
+++ b/src/server/ldap.rs
@@ -1,9 +1,12 @@
-use std::time::Duration;
+use std::{collections::HashMap, time::Duration};
 
 use anyhow::{bail, Error};
-use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry};
+use ldap3::{
+    adapters::{Adapter, EntriesOnly, PagedResults},
+    Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry,
+};
 
-#[derive(PartialEq, Eq)]
+#[derive(PartialEq, Eq, Clone, Copy)]
 /// LDAP connection security
 pub enum LdapConnectionMode {
     /// unencrypted connection
@@ -14,6 +17,7 @@ pub enum LdapConnectionMode {
     Ldaps,
 }
 
+#[derive(Clone)]
 /// Configuration for LDAP connections
 pub struct LdapConfig {
     /// Array of servers that will be tried in order
@@ -38,6 +42,24 @@ pub struct LdapConnection {
     config: LdapConfig,
 }
 
+/// Parameters for LDAP user searches
+pub struct SearchParameters {
+    /// Attributes that should be retrieved
+    pub attributes: Vec<String>,
+    /// `objectclass`es of intereset
+    pub user_classes: Vec<String>,
+    /// Custom user filter
+    pub user_filter: Option<String>,
+}
+
+/// Single LDAP user search result
+pub struct SearchResult {
+    /// The full user's domain
+    pub dn: String,
+    /// Queried user attributes
+    pub attributes: HashMap<String, Vec<String>>,
+}
+
 impl LdapConnection {
     const LDAP_DEFAULT_PORT: u16 = 389;
     const LDAPS_DEFAULT_PORT: u16 = 636;
@@ -66,7 +88,52 @@ impl LdapConnection {
         Ok(())
     }
 
-    /// Retrive port from LDAP configuration, otherwise use the appropriate default
+    /// Query entities matching given search parameters
+    pub async fn search_entities(
+        &self,
+        parameters: &SearchParameters,
+    ) -> Result<Vec<SearchResult>, Error> {
+        let search_filter = Self::assemble_search_filter(parameters);
+
+        let mut ldap = self.create_connection().await?;
+
+        if let Some(bind_dn) = self.config.bind_dn.as_deref() {
+            let password = self.config.bind_password.as_deref().unwrap_or_default();
+            let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?;
+        }
+
+        let adapters: Vec<Box<dyn Adapter<_, _>>> = vec![
+            Box::new(EntriesOnly::new()),
+            Box::new(PagedResults::new(500)),
+        ];
+        let mut search = ldap
+            .streaming_search_with(
+                adapters,
+                &self.config.base_dn,
+                Scope::Subtree,
+                &search_filter,
+                parameters.attributes.clone(),
+            )
+            .await?;
+
+        let mut results = Vec::new();
+
+        while let Some(entry) = search.next().await? {
+            let entry = SearchEntry::construct(entry);
+
+            results.push(SearchResult {
+                dn: entry.dn,
+                attributes: entry.attrs,
+            })
+        }
+        let _res = search.finish().await.success()?;
+
+        let _ = ldap.unbind().await;
+
+        Ok(results)
+    }
+
+    /// Retrive port from LDAP configuration, otherwise use the correct default
     fn port_from_config(&self) -> u16 {
         self.config.port.unwrap_or_else(|| {
             if self.config.tls_mode == LdapConnectionMode::Ldaps {
@@ -171,4 +238,100 @@ impl LdapConnection {
 
         bail!("user not found")
     }
+
+    fn assemble_search_filter(parameters: &SearchParameters) -> String {
+        use FilterElement::*;
+
+        let attr_wildcards = Or(parameters
+            .attributes
+            .iter()
+            .map(|attr| Condition(attr.clone(), "*".into()))
+            .collect());
+        let user_classes = Or(parameters
+            .user_classes
+            .iter()
+            .map(|class| Condition("objectclass".into(), class.clone()))
+            .collect());
+
+        if let Some(user_filter) = &parameters.user_filter {
+            And(vec![
+                Verbatim(user_filter.clone()),
+                attr_wildcards,
+                user_classes,
+            ])
+        } else {
+            And(vec![attr_wildcards, user_classes])
+        }
+        .to_string()
+    }
+}
+
+#[allow(dead_code)]
+enum FilterElement {
+    And(Vec<FilterElement>),
+    Or(Vec<FilterElement>),
+    Condition(String, String),
+    Not(Box<FilterElement>),
+    Verbatim(String),
+}
+
+impl ToString for FilterElement {
+    fn to_string(&self) -> String {
+        fn children_to_string(children: &[FilterElement]) -> String {
+            children.iter().fold(String::new(), |mut acc, v| {
+                acc.push_str(&v.to_string());
+                acc
+            })
+        }
+
+        match self {
+            FilterElement::And(children) => {
+                format!("(&{})", children_to_string(children))
+            }
+            FilterElement::Or(children) => {
+                format!("(|{})", children_to_string(children))
+            }
+            FilterElement::Not(element) => {
+                format!("(!{})", element.to_string())
+            }
+            FilterElement::Condition(attr, value) => {
+                format!("({attr}={value})")
+            }
+            FilterElement::Verbatim(verbatim) => verbatim.clone(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::FilterElement::*;
+
+    #[test]
+    fn test_filter_elements_to_string() {
+        assert_eq!(
+            "(uid=john)",
+            Condition("uid".into(), "john".into()).to_string()
+        );
+        assert_eq!(
+            "(!(uid=john))",
+            Not(Box::new(Condition("uid".into(), "john".into()))).to_string()
+        );
+
+        assert_eq!("(foo=bar)", Verbatim("(foo=bar)".into()).to_string());
+
+        let filter_string = And(vec![
+            Condition("givenname".into(), "john".into()),
+            Condition("sn".into(), "doe".into()),
+            Or(vec![
+                Condition("email".into(), "john@foo".into()),
+                Condition("email".into(), "john@bar".into()),
+            ]),
+        ])
+        .to_string();
+
+        assert_eq!(
+            "(&(givenname=john)(sn=doe)(|(email=john@foo)(email=john@bar)))".to_owned(),
+            filter_string
+        );
+    }
 }
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 649c1c51..6c2c07f4 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -24,6 +24,9 @@ pub use prune_job::*;
 mod gc_job;
 pub use gc_job::*;
 
+mod realm_sync_job;
+pub use realm_sync_job::*;
+
 mod email_notifications;
 pub use email_notifications::*;
 
diff --git a/src/server/realm_sync_job.rs b/src/server/realm_sync_job.rs
new file mode 100644
index 00000000..622d98b3
--- /dev/null
+++ b/src/server/realm_sync_job.rs
@@ -0,0 +1,469 @@
+use anyhow::{bail, Context, Error};
+use pbs_config::{acl::AclTree, token_shadow, BackupLockGuard};
+use proxmox_schema::ApiType;
+use proxmox_section_config::SectionConfigData;
+use std::{collections::HashSet, sync::Arc};
+
+use proxmox_sys::task_log;
+
+use pbs_api_types::{
+    ApiToken, Authid, LdapRealmConfig, Realm, RemoveVanished, SyncAttributes as LdapSyncAttributes,
+    SyncDefaultsOptions, User, Userid, REMOVE_VANISHED_ARRAY, USER_CLASSES_ARRAY,
+};
+
+use proxmox_rest_server::WorkerTask;
+
+use crate::{auth, auth_helpers, server::jobstate::Job};
+
+use super::ldap::{LdapConfig, LdapConnection, SearchParameters, SearchResult};
+
+/// Runs a realm sync job
+#[allow(clippy::too_many_arguments)]
+pub fn do_realm_sync_job(
+    mut job: Job,
+    realm: Realm,
+    auth_id: &Authid,
+    _schedule: Option<String>,
+    to_stdout: bool,
+    dry_run: bool,
+    remove_vanished: Option<String>,
+    enable_new: Option<bool>,
+) -> Result<String, Error> {
+    let worker_type = job.jobtype().to_string();
+    let upid_str = WorkerTask::spawn(
+        &worker_type,
+        Some(realm.as_str().to_owned()),
+        auth_id.to_string(),
+        to_stdout,
+        move |worker| {
+            job.start(&worker.upid().to_string()).unwrap();
+
+            task_log!(worker, "starting realm sync for {}", realm.as_str());
+
+            let override_settings = GeneralSyncSettingsOverride {
+                remove_vanished,
+                enable_new,
+            };
+
+            async move {
+                let sync_job = LdapRealmSyncJob::new(worker, realm, &override_settings, dry_run)?;
+                sync_job.sync().await
+            }
+        },
+    )?;
+
+    Ok(upid_str)
+}
+
+/// Implemenation for syncing LDAP realms
+struct LdapRealmSyncJob {
+    worker: Arc<WorkerTask>,
+    realm: Realm,
+    general_sync_settings: GeneralSyncSettings,
+    ldap_sync_settings: LdapSyncSettings,
+    ldap_config: LdapConfig,
+    dry_run: bool,
+}
+
+impl LdapRealmSyncJob {
+    /// Create new LdapRealmSyncJob
+    fn new(
+        worker: Arc<WorkerTask>,
+        realm: Realm,
+        override_settings: &GeneralSyncSettingsOverride,
+        dry_run: bool,
+    ) -> Result<Self, Error> {
+        let (domains, _digest) = pbs_config::domains::config()?;
+        let config =
+            if let Ok(mut config) = domains.lookup::<LdapRealmConfig>("ldap", realm.as_str()) {
+                // Inject password into config
+                let password = auth_helpers::get_ldap_bind_password(realm.as_str())?;
+                config.password = password;
+                config
+            } else {
+                bail!("unknown realm '{}'", realm.as_str());
+            };
+
+        let sync_settings = GeneralSyncSettings::default()
+            .apply_config(&config)?
+            .apply_override(override_settings)?;
+        let sync_attributes = LdapSyncSettings::from_config(&config)?;
+
+        let ldap_config = auth::ldap_api_type_to_ldap_config(&config)?;
+
+        Ok(Self {
+            worker,
+            realm,
+            general_sync_settings: sync_settings,
+            ldap_sync_settings: sync_attributes,
+            ldap_config,
+            dry_run,
+        })
+    }
+
+    /// Perform realm synchronization
+    async fn sync(&self) -> Result<(), Error> {
+        if self.dry_run {
+            task_log!(
+                self.worker,
+                "this is a DRY RUN - changes will not be persisted"
+            );
+        }
+
+        let ldap = LdapConnection::new(self.ldap_config.clone());
+
+        let parameters = 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 = pbs_config::user::lock_config()?;
+        let acl_lock = pbs_config::acl::lock_config()?;
+
+        let (mut user_config, _digest) = pbs_config::user::config()?;
+        let (mut tree, _) = pbs_config::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 {
+            pbs_config::user::save_config(&user_config).context("could not store user config")?;
+            pbs_config::acl::save_config(&tree).context("could not store acl config")?;
+        }
+
+        Ok(())
+    }
+
+    fn create_or_update_users(
+        &self,
+        user_config: &mut SectionConfigData,
+        _user_lock: &BackupLockGuard,
+        users: &[SearchResult],
+    ) -> Result<HashSet<Userid>, Error> {
+        let mut retrieved_users = HashSet::new();
+
+        for result in users {
+            let mut username = result
+                .attributes
+                .get(&self.ldap_sync_settings.user_attr)
+                .context("userid attribute not in search result")?
+                .get(0)
+                .context("userid attribute array is empty")?
+                .clone();
+
+            username.push_str(&format!("@{}", self.realm.as_str()));
+
+            let userid: Userid = username.parse()?;
+            retrieved_users.insert(userid.clone());
+
+            self.create_or_update_user(user_config, userid, result)?;
+        }
+
+        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 {
+                task_log!(
+                    self.worker,
+                    "updating user {}",
+                    new_or_updated_user.userid.as_str()
+                );
+            }
+        } else {
+            task_log!(
+                self.worker,
+                "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 = |a: Option<&String>| {
+            a.and_then(|e| result.attributes.get(e))
+                .and_then(|v| v.get(0))
+                .cloned()
+        };
+
+        User {
+            userid,
+            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(self.ldap_sync_settings.firstname_attr.as_ref()).or_else(|| {
+                if !self.general_sync_settings.should_remove_properties() {
+                    existing_user.and_then(|o| o.firstname.clone())
+                } else {
+                    None
+                }
+            }),
+            lastname: lookup(self.ldap_sync_settings.lastname_attr.as_ref()).or_else(|| {
+                if !self.general_sync_settings.should_remove_properties() {
+                    existing_user.and_then(|o| o.lastname.clone())
+                } else {
+                    None
+                }
+            }),
+            email: lookup(self.ldap_sync_settings.email_attr.as_ref()).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: &BackupLockGuard,
+        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: &BackupLockGuard,
+        acl_config: &mut AclTree,
+        _acl_lock: &BackupLockGuard,
+        to_delete: &[Userid],
+    ) -> Result<(), Error> {
+        for userid in to_delete {
+            task_log!(self.worker, "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 {
+                        token_shadow::delete_secret(&tokenid)?;
+                    }
+
+                    if self.general_sync_settings.should_remove_acls() {
+                        acl_config.delete_authid(&tokenid);
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+/// General realm sync settings - Override for manual invokation
+struct GeneralSyncSettingsOverride {
+    remove_vanished: Option<String>,
+    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 from_config(config: &LdapRealmConfig) -> Result<Self, Error> {
+        let mut attributes = vec![config.user_attr.clone()];
+
+        let mut email = None;
+        let mut firstname = None;
+        let mut lastname = None;
+
+        if let Some(sync_attributes) = &config.sync_attributes {
+            let value = LdapSyncAttributes::API_SCHEMA.parse_property_string(sync_attributes)?;
+            let sync_attributes: LdapSyncAttributes = serde_json::from_value(value)?;
+
+            email = sync_attributes.email.clone();
+            firstname = sync_attributes.firstname.clone();
+            lastname = sync_attributes.lastname.clone();
+
+            if let Some(email_attr) = sync_attributes.email {
+                attributes.push(email_attr);
+            }
+
+            if let Some(firstname_attr) = sync_attributes.firstname {
+                attributes.push(firstname_attr);
+            }
+
+            if let Some(lastname_attr) = sync_attributes.lastname {
+                attributes.push(lastname_attr);
+            }
+        }
+
+        let user_classes = if let Some(user_classes) = &config.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: config.user_attr.clone(),
+            firstname_attr: firstname,
+            lastname_attr: lastname,
+            email_attr: email,
+            attributes,
+            user_classes,
+            user_filter: config.filter.clone(),
+        })
+    }
+}
+
+impl Default for GeneralSyncSettings {
+    fn default() -> Self {
+        Self {
+            remove_vanished: Default::default(),
+            enable_new: true,
+        }
+    }
+}
+
+impl GeneralSyncSettings {
+    fn apply_config(self, config: &LdapRealmConfig) -> Result<Self, Error> {
+        let mut enable_new = None;
+        let mut remove_vanished = None;
+
+        if let Some(sync_defaults_options) = &config.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/www/Utils.js b/www/Utils.js
index 3d51d6d2..2eca600e 100644
--- a/www/Utils.js
+++ b/www/Utils.js
@@ -337,7 +337,7 @@ Ext.define('PBS.Utils', {
 	    handler: function() {
 		window.open(docsURI);
 	    },
-        };
+	};
     },
 
     calculate_dedup_factor: function(gcstatus) {
@@ -406,6 +406,7 @@ Ext.define('PBS.Utils', {
 	    "format-media": [gettext('Drive'), gettext('Format media')],
 	    "forget-group": [gettext('Group'), gettext('Remove Group')],
 	    garbage_collection: ['Datastore', gettext('Garbage Collect')],
+	    'realm-sync': ['Realm', gettext('User Sync')],
 	    'inventory-update': [gettext('Drive'), gettext('Inventory Update')],
 	    'label-media': [gettext('Drive'), gettext('Label Media')],
 	    'load-media': (type, id) => PBS.Utils.render_drive_load_media_id(id, gettext('Load Media')),
@@ -433,6 +434,7 @@ Ext.define('PBS.Utils', {
 		add: false,
 		edit: false,
 		pwchange: true,
+		sync: false,
 	    },
 	});
     },
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 09/17] manager: add LDAP commands
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (7 preceding siblings ...)
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 08/17] server: add LDAP realm sync job Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 10/17] manager: add sync command for LDAP realms Lukas Wagner
                   ` (7 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

Adds commands for managing LDAP realms to
`proxmox-backup-manager`.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 pbs-config/src/domains.rs              | 12 +++-
 src/bin/proxmox-backup-manager.rs      |  1 +
 src/bin/proxmox_backup_manager/ldap.rs | 99 ++++++++++++++++++++++++++
 src/bin/proxmox_backup_manager/mod.rs  |  2 +
 4 files changed, 112 insertions(+), 2 deletions(-)
 create mode 100644 src/bin/proxmox_backup_manager/ldap.rs

diff --git a/pbs-config/src/domains.rs b/pbs-config/src/domains.rs
index 6cef3ec5..fda87259 100644
--- a/pbs-config/src/domains.rs
+++ b/pbs-config/src/domains.rs
@@ -72,13 +72,13 @@ pub fn complete_realm_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<
     }
 }
 
-pub fn complete_openid_realm_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+fn complete_realm_of_type(realm_type: &str) -> Vec<String> {
     match config() {
         Ok((data, _digest)) => data
             .sections
             .iter()
             .filter_map(|(id, (t, _))| {
-                if t == "openid" {
+                if t == realm_type {
                     Some(id.to_string())
                 } else {
                     None
@@ -88,3 +88,11 @@ pub fn complete_openid_realm_name(_arg: &str, _param: &HashMap<String, String>)
         Err(_) => Vec::new(),
     }
 }
+
+pub fn complete_openid_realm_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+    complete_realm_of_type("openid")
+}
+
+pub fn complete_ldap_realm_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
+    complete_realm_of_type("ldap")
+}
diff --git a/src/bin/proxmox-backup-manager.rs b/src/bin/proxmox-backup-manager.rs
index 06330c78..c97d06d8 100644
--- a/src/bin/proxmox-backup-manager.rs
+++ b/src/bin/proxmox-backup-manager.rs
@@ -427,6 +427,7 @@ async fn run() -> Result<(), Error> {
         .insert("datastore", datastore_commands())
         .insert("disk", disk_commands())
         .insert("dns", dns_commands())
+        .insert("ldap", ldap_commands())
         .insert("network", network_commands())
         .insert("node", node_commands())
         .insert("user", user_commands())
diff --git a/src/bin/proxmox_backup_manager/ldap.rs b/src/bin/proxmox_backup_manager/ldap.rs
new file mode 100644
index 00000000..4020caee
--- /dev/null
+++ b/src/bin/proxmox_backup_manager/ldap.rs
@@ -0,0 +1,99 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_router::{cli::*, ApiHandler, RpcEnvironment};
+use proxmox_schema::api;
+
+use pbs_api_types::REALM_ID_SCHEMA;
+
+use proxmox_backup::api2;
+
+#[api(
+    input: {
+        properties: {
+            "output-format": {
+                schema: OUTPUT_FORMAT,
+                optional: true,
+            },
+        }
+    }
+)]
+/// List configured LDAP realms
+fn list_ldap_realms(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
+    let output_format = get_output_format(&param);
+
+    let info = &api2::config::access::ldap::API_METHOD_LIST_LDAP_REALMS;
+    let mut data = match info.handler {
+        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+        _ => unreachable!(),
+    };
+
+    let options = default_table_format_options()
+        .column(ColumnConfig::new("realm"))
+        .column(ColumnConfig::new("server1"))
+        .column(ColumnConfig::new("comment"));
+
+    format_and_print_result_full(&mut data, &info.returns, &output_format, &options);
+
+    Ok(Value::Null)
+}
+#[api(
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+            "output-format": {
+                schema: OUTPUT_FORMAT,
+                optional: true,
+            },
+        }
+    }
+)]
+
+/// Show LDAP realm configuration
+fn show_ldap_realm(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
+    let output_format = get_output_format(&param);
+
+    let info = &api2::config::access::ldap::API_METHOD_READ_LDAP_REALM;
+    let mut data = match info.handler {
+        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+        _ => unreachable!(),
+    };
+
+    let options = default_table_format_options();
+    format_and_print_result_full(&mut data, &info.returns, &output_format, &options);
+
+    Ok(Value::Null)
+}
+
+pub fn ldap_commands() -> CommandLineInterface {
+    let cmd_def = CliCommandMap::new()
+        .insert("list", CliCommand::new(&API_METHOD_LIST_LDAP_REALMS))
+        .insert(
+            "show",
+            CliCommand::new(&API_METHOD_SHOW_LDAP_REALM)
+                .arg_param(&["realm"])
+                .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
+        )
+        .insert(
+            "create",
+            CliCommand::new(&api2::config::access::ldap::API_METHOD_CREATE_LDAP_REALM)
+                .arg_param(&["realm"])
+                .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
+        )
+        .insert(
+            "update",
+            CliCommand::new(&api2::config::access::ldap::API_METHOD_UPDATE_LDAP_REALM)
+                .arg_param(&["realm"])
+                .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
+        )
+        .insert(
+            "delete",
+            CliCommand::new(&api2::config::access::ldap::API_METHOD_DELETE_LDAP_REALM)
+                .arg_param(&["realm"])
+                .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
+        );
+
+    cmd_def.into()
+}
diff --git a/src/bin/proxmox_backup_manager/mod.rs b/src/bin/proxmox_backup_manager/mod.rs
index 9788f637..8a1c140c 100644
--- a/src/bin/proxmox_backup_manager/mod.rs
+++ b/src/bin/proxmox_backup_manager/mod.rs
@@ -8,6 +8,8 @@ mod datastore;
 pub use datastore::*;
 mod dns;
 pub use dns::*;
+mod ldap;
+pub use ldap::*;
 mod network;
 pub use network::*;
 mod prune;
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 10/17] manager: add sync command for LDAP realms
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (8 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 09/17] manager: add LDAP commands Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-04 13:56   ` Wolfgang Bumiller
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 11/17] docs: add configuration file reference for domains.cfg Lukas Wagner
                   ` (6 subsequent siblings)
  16 siblings, 1 reply; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/bin/proxmox_backup_manager/ldap.rs | 83 +++++++++++++++++++++++++-
 1 file changed, 81 insertions(+), 2 deletions(-)

diff --git a/src/bin/proxmox_backup_manager/ldap.rs b/src/bin/proxmox_backup_manager/ldap.rs
index 4020caee..407c675d 100644
--- a/src/bin/proxmox_backup_manager/ldap.rs
+++ b/src/bin/proxmox_backup_manager/ldap.rs
@@ -1,10 +1,15 @@
 use anyhow::Error;
+use futures::FutureExt;
 use serde_json::Value;
+use tokio::signal::unix::{signal, SignalKind};
 
-use proxmox_router::{cli::*, ApiHandler, RpcEnvironment};
+use proxmox_router::{cli::*, ApiHandler, Permission, RpcEnvironment};
 use proxmox_schema::api;
 
-use pbs_api_types::REALM_ID_SCHEMA;
+use pbs_api_types::{
+    Realm, PRIV_PERMISSIONS_MODIFY, PROXMOX_UPID_REGEX, REALM_ID_SCHEMA, REMOVE_VANISHED_SCHEMA,
+    UPID,
+};
 
 use proxmox_backup::api2;
 
@@ -67,6 +72,74 @@ fn show_ldap_realm(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<Valu
     Ok(Value::Null)
 }
 
+#[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,
+                type: bool,
+            }
+         },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
+    },
+)]
+/// List configured LDAP realms
+async fn sync_ldap_realm(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
+    let info = &api2::access::domain::API_METHOD_SYNC_REALM;
+    let data = match info.handler {
+        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+        _ => unreachable!(),
+    };
+
+    if let Some(upid) = data.as_str() {
+        if PROXMOX_UPID_REGEX.is_match(upid) {
+            handle_worker(upid).await?;
+        }
+    }
+
+    Ok(Value::Null)
+}
+
+// TODO: This was copied from proxmox_backup_debug/api.rs - is there a good place to
+// put this so we can use the same impl for both?
+async fn handle_worker(upid_str: &str) -> Result<(), Error> {
+    let upid: UPID = upid_str.parse()?;
+    let mut signal_stream = signal(SignalKind::interrupt())?;
+    let abort_future = async move {
+        while signal_stream.recv().await.is_some() {
+            println!("got shutdown request (SIGINT)");
+            proxmox_rest_server::abort_local_worker(upid.clone());
+        }
+        Ok::<_, Error>(())
+    };
+
+    let result_future = proxmox_rest_server::wait_for_local_worker(upid_str);
+
+    futures::select! {
+        result = result_future.fuse() => result?,
+        abort = abort_future.fuse() => abort?,
+    };
+
+    Ok(())
+}
+
 pub fn ldap_commands() -> CommandLineInterface {
     let cmd_def = CliCommandMap::new()
         .insert("list", CliCommand::new(&API_METHOD_LIST_LDAP_REALMS))
@@ -93,6 +166,12 @@ pub fn ldap_commands() -> CommandLineInterface {
             CliCommand::new(&api2::config::access::ldap::API_METHOD_DELETE_LDAP_REALM)
                 .arg_param(&["realm"])
                 .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
+        )
+        .insert(
+            "sync",
+            CliCommand::new(&API_METHOD_SYNC_LDAP_REALM)
+                .arg_param(&["realm"])
+                .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
         );
 
     cmd_def.into()
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 11/17] docs: add configuration file reference for domains.cfg
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (9 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 10/17] manager: add sync command for LDAP realms Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 12/17] docs: add documentation for LDAP realms Lukas Wagner
                   ` (5 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 docs/Makefile                  |  6 ++++--
 docs/conf.py                   |  1 +
 docs/config/domains/format.rst | 27 +++++++++++++++++++++++++++
 docs/config/domains/man5.rst   | 21 +++++++++++++++++++++
 docs/configuration-files.rst   | 14 ++++++++++++++
 src/bin/docgen.rs              |  1 +
 6 files changed, 68 insertions(+), 2 deletions(-)
 create mode 100644 docs/config/domains/format.rst
 create mode 100644 docs/config/domains/man5.rst

diff --git a/docs/Makefile b/docs/Makefile
index b1ce4f7a..b06badff 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -18,7 +18,8 @@ GENERATED_SYNOPSIS := 						\
 	config/sync/config.rst					\
 	config/verification/config.rst				\
 	config/acl/roles.rst					\
-	config/datastore/config.rst
+	config/datastore/config.rst				\
+	config/domains/config.rst
 
 MAN1_PAGES := 				\
 	pxar.1				\
@@ -40,7 +41,8 @@ MAN5_PAGES :=				\
 	remote.cfg.5			\
 	sync.cfg.5			\
 	verification.cfg.5		\
-	datastore.cfg.5
+	datastore.cfg.5			\
+	domains.cfg.5
 
 PRUNE_SIMULATOR_FILES := 					\
 	prune-simulator/index.html				\
diff --git a/docs/conf.py b/docs/conf.py
index 59f27c42..8944926e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -105,6 +105,7 @@ man_pages = [
     # configs
     ('config/acl/man5', 'acl.cfg', 'Access Control Configuration', [author], 5),
     ('config/datastore/man5', 'datastore.cfg', 'Datastore Configuration', [author], 5),
+    ('config/domains/man5', 'domains.cfg', 'Realm Configuration', [author], 5),
     ('config/media-pool/man5', 'media-pool.cfg', 'Media Pool Configuration', [author], 5),
     ('config/remote/man5', 'remote.cfg', 'Remote Server Configuration', [author], 5),
     ('config/sync/man5', 'sync.cfg', 'Synchronization Job Configuration', [author], 5),
diff --git a/docs/config/domains/format.rst b/docs/config/domains/format.rst
new file mode 100644
index 00000000..d92cd473
--- /dev/null
+++ b/docs/config/domains/format.rst
@@ -0,0 +1,27 @@
+This file contains the list authentication realms.
+
+Each user configuration section starts with the header ``<realm-type>: <name>``,
+followed by the realm's configuration options.
+
+For LDAP realms, the LDAP bind password is stored in ``ldap_passwords.json``.
+
+::
+
+  openid: master
+	client-id pbs
+	comment
+	issuer-url http://192.168.0.10:8080/realms/master
+	username-claim username
+
+  ldap: ldap-server
+	base-dn OU=People,DC=ldap-server,DC=example,DC=com
+	mode ldaps
+	server1 192.168.0.10
+	sync-attributes email=mail
+	sync-defaults-options enable-new=0,remove-vanished=acl;entry
+	user-attr uid
+	user-classes inetorgperson,posixaccount,person,user
+
+
+You can use the ``proxmox-backup-manager openid`` and ``proxmox-backup-manager ldap`` commands to manipulate
+this file.
diff --git a/docs/config/domains/man5.rst b/docs/config/domains/man5.rst
new file mode 100644
index 00000000..83341ec1
--- /dev/null
+++ b/docs/config/domains/man5.rst
@@ -0,0 +1,21 @@
+===========
+domains.cfg
+===========
+
+Description
+===========
+
+The file /etc/proxmox-backup/domains.cfg is a configuration file for Proxmox
+Backup Server. It contains the realm configuration.
+
+File Format
+===========
+
+.. include:: format.rst
+
+Options
+=======
+
+.. include:: config.rst
+
+.. include:: ../../pbs-copyright.rst
diff --git a/docs/configuration-files.rst b/docs/configuration-files.rst
index 047636a2..12a4a54e 100644
--- a/docs/configuration-files.rst
+++ b/docs/configuration-files.rst
@@ -36,6 +36,20 @@ Options
 
 .. include:: config/datastore/config.rst
 
+``domains.cfg``
+~~~~~~~~~~~~~~~~~
+
+File Format
+^^^^^^^^^^^
+
+.. include:: config/domains/format.rst
+
+
+Options
+^^^^^^^
+
+.. include:: config/domains/config.rst
+
 
 ``media-pool.cfg``
 ~~~~~~~~~~~~~~~~~~
diff --git a/src/bin/docgen.rs b/src/bin/docgen.rs
index beea4cf1..0b8cc065 100644
--- a/src/bin/docgen.rs
+++ b/src/bin/docgen.rs
@@ -30,6 +30,7 @@ fn main() -> Result<(), Error> {
         let text = match arg.as_ref() {
             "apidata.js" => generate_api_tree(),
             "datastore.cfg" => dump_section_config(&pbs_config::datastore::CONFIG),
+            "domains.cfg" => dump_section_config(&pbs_config::domains::CONFIG),
             "tape.cfg" => dump_section_config(&pbs_config::drive::CONFIG),
             "tape-job.cfg" => dump_section_config(&pbs_config::tape_job::CONFIG),
             "user.cfg" => dump_section_config(&pbs_config::user::CONFIG),
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 12/17] docs: add documentation for LDAP realms
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (10 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 11/17] docs: add configuration file reference for domains.cfg Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 13/17] auth ldap: add `certificate-path` option Lukas Wagner
                   ` (4 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 docs/command-syntax.rst      |  1 +
 docs/configuration-files.rst |  4 ++-
 docs/user-management.rst     | 58 ++++++++++++++++++++++++++++++++++++
 www/OnlineHelpInfo.js        |  8 +++++
 4 files changed, 70 insertions(+), 1 deletion(-)

diff --git a/docs/command-syntax.rst b/docs/command-syntax.rst
index b2ea330a..d9bb148f 100644
--- a/docs/command-syntax.rst
+++ b/docs/command-syntax.rst
@@ -23,6 +23,7 @@ The following commands are available in an interactive restore shell:
 
 .. include:: proxmox-backup-client/catalog-shell-synopsis.rst
 
 
 ``proxmox-backup-manager``
 --------------------------
diff --git a/docs/configuration-files.rst b/docs/configuration-files.rst
index 12a4a54e..0540d0b1 100644
--- a/docs/configuration-files.rst
+++ b/docs/configuration-files.rst
@@ -36,8 +36,10 @@ Options
 
 .. include:: config/datastore/config.rst
 
+.. _domains.cfg:
+
 ``domains.cfg``
-~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~
 
 File Format
 ^^^^^^^^^^^
diff --git a/docs/user-management.rst b/docs/user-management.rst
index b739121d..faaf183d 100644
--- a/docs/user-management.rst
+++ b/docs/user-management.rst
@@ -25,6 +25,8 @@ choose the realm when you add a new user. Possible realms are:
 :openid: OpenID Connect server. Users can authenticate against an external
          OpenID Connect server.
 
+:ldap: LDAP server. Users can authenticate against external LDAP servers.
+
 After installation, there is a single user, ``root@pam``, which corresponds to
 the Unix superuser. User configuration information is stored in the file
 ``/etc/proxmox-backup/user.cfg``. You can use the ``proxmox-backup-manager``
@@ -560,3 +562,59 @@ Two-factor authentication is only implemented for the web-interface. You should
 use :ref:`API Tokens <user_tokens>` for all other use cases, especially
 non-interactive ones (for example, adding a Proxmox Backup Server to Proxmox VE
 as a storage).
+
+
+Authentication Realms
+---------------------
+
+.. _user_realms_ldap:
+
+LDAP
+~~~~
+
+Proxmox Backup Server can utilize external LDAP servers for user authentication. To achieve this,
+a realm of the type ``ldap`` has to be configured.
+
+In LDAP, users are uniquely identified
+by their domain (``dn``). For instance, in the following LDIF dataset, the user ``user1`` has the
+unique domain ``uid=user1,ou=People,dc=ldap-test,dc=com``:
+
+
+.. code-block:: console
+
+  # user1 of People at ldap-test.com
+  dn: uid=user1,ou=People,dc=ldap-test,dc=com
+  objectClass: top
+  objectClass: person
+  objectClass: organizationalPerson
+  objectClass: inetOrgPerson
+  uid: user1
+  cn: Test User 1
+  sn: Testers
+  description: This is the first test user.
+
+In in similar manner, Proxmox Backup Server uses user identifiers (``userid``) to uniquely identify users.
+Thus, it is necessary to establish a mapping between PBS's ``userid`` and LDAP's ``dn``.
+This mapping is established by the ``user-attr`` configuration parameter - it contains the name of the
+LDAP attribute containing a valid PBS user identifier.
+
+For the example above, setting ``user-attr`` to ``uid`` will have the effect that the user ``user1@<realm-name>`` will be mapped to the LDAP entity
+``uid=user1,ou=People,dc=ldap-test,dc=com``. On user login, PBS will perform a `subtree search` under the configured Base Domain (``base-dn``) to query
+the user's ``dn``. Once the ``dn`` is known, an LDAP bind operation is performed to authenticate the user against the LDAP server.
+
+As not all LDAP servers allow `anonymous` search operations, it is possible to configure a bind domain (``bind-dn``) and a bind password (``password``).
+If set, PBS will bind to the LDAP server using these credentials before performing any search operations.
+
+A full list of all configuration parameters can be found at :ref:`domains.cfg`.
+
+.. note:: In order to allow a particular user to authenticate using the LDAP server, you must also add them as a user of that realm in Proxmox Backup Server. 
+  This can be carried out automatically with syncing.
+
+User Synchronization in LDAP realms
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+It is possible to automatically sync users for LDAP-based realms, rather than having to add them to Proxmox VE manually.
+Synchronization options can be set in the LDAP realm configuration dialog window in the GUI and via the ``proxmox-backup-manager ldap create/update`` command.
+User synchronization can started in the GUI at Configuration > Access Control > Realms by selecting a realm and pressing the `Sync` button. In the sync dialog,
+some of the default options set in the realm configuration can be overridden. Alternatively,
+user synchronization can also be started via the ``proxmox-backup-manager ldap sync`` command.
\ No newline at end of file
diff --git a/www/OnlineHelpInfo.js b/www/OnlineHelpInfo.js
index ac455450..704038b4 100644
--- a/www/OnlineHelpInfo.js
+++ b/www/OnlineHelpInfo.js
@@ -31,6 +31,10 @@ const proxmoxOnlineHelpInfo = {
     "link": "/docs/calendarevents.html#calendar-event-scheduling",
     "title": "Calendar Events"
   },
+  "domains-cfg": {
+    "link": "/docs/configuration-files.html#domains-cfg",
+    "title": "``domains.cfg``"
+  },
   "pxar-format": {
     "link": "/docs/file-formats.html#pxar-format",
     "title": "Proxmox File Archive Format (``.pxar``)"
@@ -334,5 +338,9 @@ const proxmoxOnlineHelpInfo = {
   "user-tfa-setup-recovery-keys": {
     "link": "/docs/user-management.html#user-tfa-setup-recovery-keys",
     "title": "Recovery Keys"
+  },
+  "user-realms-ldap": {
+    "link": "/docs/user-management.html#user-realms-ldap",
+    "title": "LDAP"
   }
 };
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-backup 13/17] auth ldap: add `certificate-path` option
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (11 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 12/17] docs: add documentation for LDAP realms Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 14/17] auth ui: add LDAP realm edit panel Lukas Wagner
                   ` (3 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

This allows adding a custom root CA for TLS-encrypted
LDAP connections.

Note: this commit adds a direct depedencency to
the `native-tls` crate, on which we already dependeded
transitively.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml                |  2 ++
 pbs-api-types/src/ldap.rs |  5 +++++
 src/auth.rs               |  7 +++++++
 src/server/ldap.rs        | 15 +++++++++++++--
 4 files changed, 27 insertions(+), 2 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index c9f1f185..7837bf39 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -122,6 +122,7 @@ ldap3 = { version = "0.11.0-beta.1", default_features=false, features=["tls"]}
 libc = "0.2"
 log = "0.4.17"
 nix = "0.24"
+native-tls = "0.2.8"
 nom = "7"
 num-traits = "0.2"
 once_cell = "1.3.1"
@@ -174,6 +175,7 @@ ldap3.workspace = true
 libc.workspace = true
 log.workspace = true
 nix.workspace = true
+native-tls.workspace = true
 nom.workspace = true
 num-traits.workspace = true
 once_cell.workspace = true
diff --git a/pbs-api-types/src/ldap.rs b/pbs-api-types/src/ldap.rs
index 672c81cd..99196c04 100644
--- a/pbs-api-types/src/ldap.rs
+++ b/pbs-api-types/src/ldap.rs
@@ -74,6 +74,11 @@ pub struct LdapRealmConfig {
     /// Verify server certificate
     #[serde(skip_serializing_if = "Option::is_none")]
     pub verify: Option<bool>,
+    /// CA certificate to use for the server. If set,
+    /// the certificate stored at the given path will
+    /// be added to the set of trusted root CAs.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub certificate_path: Option<String>,
     /// Bind domain to use for looking up users
     #[serde(skip_serializing_if = "Option::is_none")]
     pub bind_dn: Option<String>,
diff --git a/src/auth.rs b/src/auth.rs
index 101bec0e..46d6c56c 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -185,6 +185,12 @@ pub fn ldap_api_type_to_ldap_config(config: &LdapRealmConfig) -> Result<LdapConf
         LdapMode::Ldaps => LdapConnectionMode::Ldaps,
     };
 
+    let root_certificate = if let Some(path) = config.certificate_path.as_ref() {
+        Some(proxmox_sys::fs::file_read_string(path)?)
+    } else {
+        None
+    };
+
     Ok(LdapConfig {
         servers,
         port: config.port,
@@ -194,5 +200,6 @@ pub fn ldap_api_type_to_ldap_config(config: &LdapRealmConfig) -> Result<LdapConf
         bind_password: auth_helpers::get_ldap_bind_password(&config.realm)?,
         tls_mode,
         verify_certificate: config.verify.unwrap_or_default(),
+        root_certificate_pem: root_certificate,
     })
 }
diff --git a/src/server/ldap.rs b/src/server/ldap.rs
index 2e218cf6..8cc35181 100644
--- a/src/server/ldap.rs
+++ b/src/server/ldap.rs
@@ -5,6 +5,7 @@ use ldap3::{
     adapters::{Adapter, EntriesOnly, PagedResults},
     Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry,
 };
+use native_tls::{Certificate, TlsConnector};
 
 #[derive(PartialEq, Eq, Clone, Copy)]
 /// LDAP connection security
@@ -36,6 +37,8 @@ pub struct LdapConfig {
     pub tls_mode: LdapConnectionMode,
     /// Verify the server's TLS certificate
     pub verify_certificate: bool,
+    /// Custom TLS root certificiate
+    pub root_certificate_pem: Option<String>,
 }
 
 pub struct LdapConnection {
@@ -163,11 +166,19 @@ impl LdapConnection {
     async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> {
         let starttls = self.config.tls_mode == LdapConnectionMode::StartTls;
 
+        let mut connector_builder = TlsConnector::builder();
+        connector_builder.danger_accept_invalid_certs(!self.config.verify_certificate);
+
+        if let Some(certificate) = self.config.root_certificate_pem.as_deref() {
+            let cert = Certificate::from_pem(certificate.as_bytes())?;
+            connector_builder.add_root_certificate(cert);
+        }
+
         LdapConnAsync::with_settings(
             LdapConnSettings::new()
-                .set_no_tls_verify(!self.config.verify_certificate)
                 .set_starttls(starttls)
-                .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT),
+                .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT)
+                .set_connector(connector_builder.build()?),
             url,
         )
         .await
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-widget-toolkit 14/17] auth ui: add LDAP realm edit panel
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (12 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 13/17] auth ldap: add `certificate-path` option Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 15/17] auth ui: add LDAP sync UI Lukas Wagner
                   ` (2 subsequent siblings)
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

The panel was mostly taken from from PVE, but altered slightly:
  - bind-dn and bind-password are displayed under "General"
    and not under "Sync". For some servers, we need to be bound
    to lookup a user's domain from a given user id attribute.
    In PVE, the bind-dn and bind-password fields are under
    "Sync", which is a bit confusing if a user is not interested
    in automatic user syncing.

  - There is a 'anonymous search' checkbox. The value is not persisted
    in the configuration, it merely enables/disables the
    bind-dn and bind-password fiels to make their intent a bit more
    clear.

  - Instead of a 'secure' checkbox, a combobox for TLS mode is shown.
    This way users can select between LDAP, STARTLS and LDAPS.
    In PVE, the 'secure' config parameter is deprecated anyway, so
    I took the opportunity to replace it with the 'mode' parameter
    as described.

  - Parameters now consistently use kebab-case for naming. If
    PVE is modified to use the same panel, some sort of adapter
    will be needed.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/Makefile               |   1 +
 src/Schema.js              |   8 ++
 src/window/AuthEditLDAP.js | 194 +++++++++++++++++++++++++++++++++++++
 3 files changed, 203 insertions(+)
 create mode 100644 src/window/AuthEditLDAP.js

diff --git a/src/Makefile b/src/Makefile
index 95da5aa..a24ae43 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -83,6 +83,7 @@ JSSRC=					\
 	window/FileBrowser.js		\
 	window/AuthEditBase.js		\
 	window/AuthEditOpenId.js	\
+	window/AuthEditLDAP.js		\
 	window/TfaWindow.js		\
 	window/AddTfaRecovery.js	\
 	window/AddTotp.js		\
diff --git a/src/Schema.js b/src/Schema.js
index d414845..372af89 100644
--- a/src/Schema.js
+++ b/src/Schema.js
@@ -17,6 +17,14 @@ Ext.define('Proxmox.Schema', { // a singleton
 	    pwchange: false,
 	    iconCls: 'pmx-itype-icon-openid-logo',
 	},
+	ldap: {
+	    name: gettext('LDAP Server'),
+	    ipanel: 'pmxAuthLDAPPanel',
+	    add: true,
+	    edit: true,
+	    tfa: true,
+	    pwchange: false,
+	},
     },
     // to add or change existing for product specific ones
     overrideAuthDomains: function(extra) {
diff --git a/src/window/AuthEditLDAP.js b/src/window/AuthEditLDAP.js
new file mode 100644
index 0000000..a44c536
--- /dev/null
+++ b/src/window/AuthEditLDAP.js
@@ -0,0 +1,194 @@
+
+Ext.define('Proxmox.panel.LDAPInputPanelViewModel', {
+    extend: 'Ext.app.ViewModel',
+
+    alias: 'viewmodel.pmxAuthLDAPPanel',
+
+    data: {
+	mode: 'ldap',
+	anonymous_search: 1,
+    },
+
+    formulas: {
+	tls_enabled: function(get) {
+	    return get('mode') !== 'ldap';
+	},
+    },
+
+});
+
+
+Ext.define('Proxmox.panel.LDAPInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxAuthLDAPPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	type: 'pmxAuthLDAPPanel',
+    },
+
+    type: 'ldap',
+
+    onGetValues: function(values) {
+	if (this.isCreate) {
+	    values.type = this.type;
+	}
+
+	if (values.anonymous_search) {
+	    if (!values.delete) {
+		values.delete = [];
+	    }
+
+	    if (!Array.isArray(values.delete)) {
+		let tmp = values.delete;
+		values.delete = [];
+		values.delete.push(tmp);
+	    }
+
+	    values.delete.push("bind-dn");
+	    values.delete.push("password");
+	}
+
+	delete values.anonymous_search;
+
+	return values;
+    },
+
+    onSetValues: function(values) {
+	values.anonymous_search = values["bind-dn"] ? 0 : 1;
+
+	return values;
+    },
+
+
+    column1: [
+	{
+	    xtype: 'pmxDisplayEditField',
+	    name: 'realm',
+	    cbind: {
+		value: '{realm}',
+		editable: '{isCreate}',
+	    },
+	    fieldLabel: gettext('Realm'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Base Domain Name'),
+	    name: 'base-dn',
+	    allowBlank: false,
+	    emptyText: 'cn=Users,dc=company,dc=net',
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('User Attribute Name'),
+	    name: 'user-attr',
+	    allowBlank: false,
+	    emptyText: 'uid / sAMAccountName',
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Anonymous Search'),
+	    name: 'anonymous_search',
+	    bind: '{anonymous_search}',
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    fieldLabel: gettext('Bind Domain Name'),
+	    name: 'bind-dn',
+	    allowBlank: false,
+	    emptyText: 'cn=user,dc=company,dc=net',
+	    bind: {
+		disabled: "{anonymous_search}",
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    inputType: 'password',
+	    fieldLabel: gettext('Bind Password'),
+	    name: 'password',
+	    allowBlank: true,
+	    cbind: {
+		emptyText: get => !get('isCreate') ? gettext('Unchanged') : '',
+	    },
+	    bind: {
+		disabled: "{anonymous_search}",
+	    },
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'server1',
+	    fieldLabel: gettext('Server'),
+	    allowBlank: false,
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'server2',
+	    fieldLabel: gettext('Fallback Server'),
+	    submitEmpty: false,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+	{
+	    xtype: 'proxmoxintegerfield',
+	    name: 'port',
+	    fieldLabel: gettext('Port'),
+	    minValue: 1,
+	    maxValue: 65535,
+	    emptyText: gettext('Default'),
+	    submitEmptyText: false,
+	    deleteEmpty: true,
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    name: 'mode',
+	    fieldLabel: gettext('Mode'),
+	    editable: false,
+	    comboItems: [
+		['ldap', 'LDAP'],
+		['ldap+starttls', 'STARTTLS'],
+		['ldaps', 'LDAPS'],
+	    ],
+	    bind: "{mode}",
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+		value: get => get('isCreate') ? 'ldap' : 'LDAP',
+	    },
+	},
+	{
+	    xtype: 'proxmoxcheckbox',
+	    fieldLabel: gettext('Verify Certificate'),
+	    name: 'verify',
+	    value: 0,
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+
+	    bind: {
+		disabled: '{!tls_enabled}',
+	    },
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Verify TLS certificate of the server'),
+	    },
+
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'textfield',
+	    name: 'comment',
+	    fieldLabel: gettext('Comment'),
+	    cbind: {
+		deleteEmpty: '{!isCreate}',
+	    },
+	},
+    ],
+
+});
+
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-widget-toolkit 15/17] auth ui: add LDAP sync UI
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (13 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 14/17] auth ui: add LDAP realm edit panel Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 16/17] auth ui: add `onlineHelp` for AuthEditLDAP Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 17/17] auth ui: add `firstname` and `lastname` sync-attribute fields Lukas Wagner
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

Taken and adapted from PVE.
Changes:
  - Removed fields that are irrelevant for PBS for now (PBS has no
    groups yet). If PVE is adapted to use the implementation from the
    widget toolkit, the fields can simply be readded and somehow
    feature-gated so that the fields are only visible/editable on PVE

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/Makefile               |   1 +
 src/Schema.js              |   4 +
 src/panel/AuthView.js      |  24 +++++
 src/window/AuthEditLDAP.js | 161 +++++++++++++++++++++++++++++++
 src/window/SyncWindow.js   | 192 +++++++++++++++++++++++++++++++++++++
 5 files changed, 382 insertions(+)
 create mode 100644 src/window/SyncWindow.js

diff --git a/src/Makefile b/src/Makefile
index a24ae43..458ae93 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -91,6 +91,7 @@ JSSRC=					\
 	window/AddYubico.js		\
 	window/TfaEdit.js		\
 	window/NotesEdit.js		\
+	window/SyncWindow.js	\
 	node/APT.js			\
 	node/APTRepositories.js		\
 	node/NetworkEdit.js		\
diff --git a/src/Schema.js b/src/Schema.js
index 372af89..b247b1e 100644
--- a/src/Schema.js
+++ b/src/Schema.js
@@ -7,6 +7,7 @@ Ext.define('Proxmox.Schema', { // a singleton
 	    add: false,
 	    edit: false,
 	    pwchange: true,
+	    sync: false,
 	},
 	openid: {
 	    name: gettext('OpenID Connect Server'),
@@ -15,15 +16,18 @@ Ext.define('Proxmox.Schema', { // a singleton
 	    edit: true,
 	    tfa: false,
 	    pwchange: false,
+	    sync: false,
 	    iconCls: 'pmx-itype-icon-openid-logo',
 	},
 	ldap: {
 	    name: gettext('LDAP Server'),
 	    ipanel: 'pmxAuthLDAPPanel',
+	    syncipanel: 'pmxAuthLDAPSyncPanel',
 	    add: true,
 	    edit: true,
 	    tfa: true,
 	    pwchange: false,
+	    sync: true,
 	},
     },
     // to add or change existing for product specific ones
diff --git a/src/panel/AuthView.js b/src/panel/AuthView.js
index 69fe1a5..52b6cac 100644
--- a/src/panel/AuthView.js
+++ b/src/panel/AuthView.js
@@ -75,6 +75,23 @@ Ext.define('Proxmox.panel.AuthView', {
 	me.openEditWindow(rec.data.type, rec.data.realm);
     },
 
+    open_sync_window: function() {
+	let rec = this.getSelection()[0];
+	if (!rec) {
+	    return;
+	}
+	if (!Proxmox.Schema.authDomains[rec.data.type].sync) {
+	    return;
+	}
+	Ext.create('Proxmox.window.SyncWindow', {
+	    type: rec.data.type,
+	    realm: rec.data.realm,
+	    listeners: {
+		destroy: () => this.reload(),
+	    },
+	}).show();
+    },
+
     initComponent: function() {
 	var me = this;
 
@@ -115,6 +132,13 @@ Ext.define('Proxmox.panel.AuthView', {
 		enableFn: (rec) => Proxmox.Schema.authDomains[rec.data.type].add,
 		callback: () => me.reload(),
 	    },
+	    {
+		xtype: 'proxmoxButton',
+		text: gettext('Sync'),
+		disabled: true,
+		enableFn: (rec) => Proxmox.Schema.authDomains[rec.data.type].sync,
+		handler: () => me.open_sync_window(),
+	    },
 	];
 
 	if (me.extraButtons) {
diff --git a/src/window/AuthEditLDAP.js b/src/window/AuthEditLDAP.js
index a44c536..4195efe 100644
--- a/src/window/AuthEditLDAP.js
+++ b/src/window/AuthEditLDAP.js
@@ -192,3 +192,164 @@ Ext.define('Proxmox.panel.LDAPInputPanel', {
 
 });
 
+
+Ext.define('Proxmox.panel.LDAPSyncInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    xtype: 'pmxAuthLDAPSyncPanel',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    editableAttributes: ['email'],
+    editableDefaults: ['scope', 'enable-new'],
+    default_opts: {},
+    sync_attributes: {},
+
+    type: 'ldap',
+
+    // (de)construct the sync-attributes from the list above,
+    // not touching all others
+    onGetValues: function(values) {
+	this.editableDefaults.forEach((attr) => {
+	    if (values[attr]) {
+		this.default_opts[attr] = values[attr];
+		delete values[attr];
+	    } else {
+		delete this.default_opts[attr];
+	    }
+	});
+	let vanished_opts = [];
+	['acl', 'entry', 'properties'].forEach((prop) => {
+	    if (values[`remove-vanished-${prop}`]) {
+		vanished_opts.push(prop);
+	    }
+	    delete values[`remove-vanished-${prop}`];
+	});
+	this.default_opts['remove-vanished'] = vanished_opts.join(';');
+
+	values['sync-defaults-options'] = Proxmox.Utils.printPropertyString(this.default_opts);
+	this.editableAttributes.forEach((attr) => {
+	    if (values[attr]) {
+		this.sync_attributes[attr] = values[attr];
+		delete values[attr];
+	    } else {
+		delete this.sync_attributes[attr];
+	    }
+	});
+	values['sync-attributes'] = Proxmox.Utils.printPropertyString(this.sync_attributes);
+
+	Proxmox.Utils.delete_if_default(values, 'sync-defaults-options');
+	Proxmox.Utils.delete_if_default(values, 'sync-attributes');
+
+	if (this.isCreate) {
+	    delete values.delete; // on create we cannot delete values
+	}
+
+	return values;
+    },
+
+    setValues: function(values) {
+	if (values['sync-attributes']) {
+	    this.sync_attributes = Proxmox.Utils.parsePropertyString(values['sync-attributes']);
+	    delete values['sync-attributes'];
+	    this.editableAttributes.forEach((attr) => {
+		if (this.sync_attributes[attr]) {
+		    values[attr] = this.sync_attributes[attr];
+		}
+	    });
+	}
+	if (values['sync-defaults-options']) {
+	    this.default_opts = Proxmox.Utils.parsePropertyString(values['sync-defaults-options']);
+	    delete values.default_opts;
+	    this.editableDefaults.forEach((attr) => {
+		if (this.default_opts[attr]) {
+		    values[attr] = this.default_opts[attr];
+		}
+	    });
+
+	    if (this.default_opts['remove-vanished']) {
+		let opts = this.default_opts['remove-vanished'].split(';');
+		for (const opt of opts) {
+		    values[`remove-vanished-${opt}`] = 1;
+		}
+	    }
+	}
+	return this.callParent([values]);
+    },
+
+    column1: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'email',
+	    fieldLabel: gettext('E-Mail attribute'),
+	},
+	{
+	    xtype: 'displayfield',
+	    value: gettext('Default Sync Options'),
+	},
+	{
+	    xtype: 'proxmoxKVComboBox',
+	    value: '__default__',
+	    deleteEmpty: false,
+	    comboItems: [
+		[
+		    '__default__',
+		    Ext.String.format(
+			gettext("{0} ({1})"),
+			Proxmox.Utils.yesText,
+			Proxmox.Utils.defaultText,
+		    ),
+		],
+		['true', Proxmox.Utils.yesText],
+		['false', Proxmox.Utils.noText],
+	    ],
+	    name: 'enable-new',
+	    fieldLabel: gettext('Enable new users'),
+	},
+    ],
+
+    column2: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'user-classes',
+	    fieldLabel: gettext('User classes'),
+	    deleteEmpty: true,
+	    emptyText: 'inetorgperson, posixaccount, person, user',
+	    autoEl: {
+		tag: 'div',
+		'data-qtip': gettext('Default user classes: inetorgperson, posixaccount, person, user'),
+	    },
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'filter',
+	    fieldLabel: gettext('User Filter'),
+	    deleteEmpty: true,
+	},
+    ],
+
+    columnB: [
+	{
+	    xtype: 'fieldset',
+	    title: gettext('Remove Vanished Options'),
+	    items: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('ACL'),
+		    name: 'remove-vanished-acl',
+		    boxLabel: gettext('Remove ACLs of vanished users'),
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Entry'),
+		    name: 'remove-vanished-entry',
+		    boxLabel: gettext('Remove vanished user'),
+		},
+		{
+		    xtype: 'proxmoxcheckbox',
+		    fieldLabel: gettext('Properties'),
+		    name: 'remove-vanished-properties',
+		    boxLabel: gettext('Remove vanished properties from synced users.'),
+		},
+	    ],
+	},
+    ],
+});
diff --git a/src/window/SyncWindow.js b/src/window/SyncWindow.js
new file mode 100644
index 0000000..449782a
--- /dev/null
+++ b/src/window/SyncWindow.js
@@ -0,0 +1,192 @@
+Ext.define('Proxmox.window.SyncWindow', {
+    extend: 'Ext.window.Window',
+
+    title: gettext('Realm Sync'),
+
+    width: 600,
+    bodyPadding: 10,
+    modal: true,
+    resizable: false,
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	control: {
+	    'form': {
+		validitychange: function(field, valid) {
+		    this.lookup('preview_btn').setDisabled(!valid);
+		    this.lookup('sync_btn').setDisabled(!valid);
+		},
+	    },
+	    'button': {
+		click: function(btn) {
+		    this.sync_realm(btn.reference === 'preview_btn');
+		},
+	    },
+	},
+
+	sync_realm: function(is_preview) {
+	    let view = this.getView();
+	    let ipanel = this.lookup('ipanel');
+	    let params = ipanel.getValues();
+
+	    let vanished_opts = [];
+	    ['acl', 'entry', 'properties'].forEach((prop) => {
+		if (params[`remove-vanished-${prop}`]) {
+		    vanished_opts.push(prop);
+		}
+		delete params[`remove-vanished-${prop}`];
+	    });
+	    if (vanished_opts.length > 0) {
+		params['remove-vanished'] = vanished_opts.join(';');
+	    }
+
+	    params['dry-run'] = is_preview ? 1 : 0;
+	    Proxmox.Utils.API2Request({
+		url: `/access/domains/${view.realm}/sync`,
+		waitMsgTarget: view,
+		method: 'POST',
+		params,
+		failure: (response) => {
+		    view.show();
+		    Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		},
+		success: (response) => {
+		    view.hide();
+		    Ext.create('Proxmox.window.TaskViewer', {
+			upid: response.result.data,
+			listeners: {
+			    destroy: () => {
+				if (is_preview) {
+				    view.show();
+				} else {
+				    view.close();
+				}
+			    },
+			},
+		    }).show();
+		},
+	    });
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'form',
+	    reference: 'form',
+	    border: false,
+	    fieldDefaults: {
+		labelWidth: 100,
+		anchor: '100%',
+	    },
+	    items: [{
+		xtype: 'inputpanel',
+		reference: 'ipanel',
+		column1: [
+		    {
+			xtype: 'proxmoxKVComboBox',
+			value: 'true',
+			deleteEmpty: false,
+			allowBlank: false,
+			comboItems: [
+			    ['true', Proxmox.Utils.yesText],
+			    ['false', Proxmox.Utils.noText],
+			],
+			name: 'enable-new',
+			fieldLabel: gettext('Enable new'),
+		    },
+		],
+
+		column2: [
+		],
+
+		columnB: [
+		    {
+			xtype: 'fieldset',
+			title: gettext('Remove Vanished Options'),
+			items: [
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('ACL'),
+				name: 'remove-vanished-acl',
+				boxLabel: gettext('Remove ACLs of vanished users and groups.'),
+			    },
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('Entry'),
+				name: 'remove-vanished-entry',
+				boxLabel: gettext('Remove vanished user and group entries.'),
+			    },
+			    {
+				xtype: 'proxmoxcheckbox',
+				fieldLabel: gettext('Properties'),
+				name: 'remove-vanished-properties',
+				boxLabel: gettext('Remove vanished properties from synced users.'),
+			    },
+			],
+		    },
+		    {
+			xtype: 'displayfield',
+			reference: 'defaulthint',
+			value: gettext('Default sync options can be set by editing the realm.'),
+			userCls: 'pmx-hint',
+			hidden: true,
+		    },
+		],
+	    }],
+	},
+    ],
+
+    buttons: [
+	'->',
+	{
+	    text: gettext('Preview'),
+	    reference: 'preview_btn',
+	},
+	{
+	    text: gettext('Sync'),
+	    reference: 'sync_btn',
+	},
+    ],
+
+    initComponent: function() {
+	if (!this.realm) {
+	    throw "no realm defined";
+	}
+
+	if (!this.type) {
+	    throw "no realm type defined";
+	}
+
+	this.callParent();
+
+	Proxmox.Utils.API2Request({
+	    url: `/config/access/${this.type}/${this.realm}`,
+	    waitMsgTarget: this,
+	    method: 'GET',
+	    failure: (response) => {
+		Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		this.close();
+	    },
+	    success: (response) => {
+		let default_options = response.result.data['sync-defaults-options'];
+		if (default_options) {
+		    let options = Proxmox.Utils.parsePropertyString(default_options);
+		    if (options['remove-vanished']) {
+			let opts = options['remove-vanished'].split(';');
+			for (const opt of opts) {
+			    options[`remove-vanished-${opt}`] = 1;
+			}
+		    }
+		    let ipanel = this.lookup('ipanel');
+		    ipanel.setValues(options);
+		} else {
+		    this.lookup('defaulthint').setVisible(true);
+		}
+
+		// check validity for button state
+		this.lookup('form').isValid();
+	    },
+	});
+    },
+});
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-widget-toolkit 16/17] auth ui: add `onlineHelp` for AuthEditLDAP
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (14 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 15/17] auth ui: add LDAP sync UI Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 17/17] auth ui: add `firstname` and `lastname` sync-attribute fields Lukas Wagner
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/window/AuthEditLDAP.js | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/window/AuthEditLDAP.js b/src/window/AuthEditLDAP.js
index 4195efe..2a1cffd 100644
--- a/src/window/AuthEditLDAP.js
+++ b/src/window/AuthEditLDAP.js
@@ -29,6 +29,8 @@ Ext.define('Proxmox.panel.LDAPInputPanel', {
 
     type: 'ldap',
 
+    onlineHelp: 'user-realms-ldap',
+
     onGetValues: function(values) {
 	if (this.isCreate) {
 	    values.type = this.type;
-- 
2.30.2





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

* [pbs-devel] [PATCH proxmox-widget-toolkit 17/17] auth ui: add `firstname` and `lastname` sync-attribute fields
  2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
                   ` (15 preceding siblings ...)
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 16/17] auth ui: add `onlineHelp` for AuthEditLDAP Lukas Wagner
@ 2023-01-03 14:23 ` Lukas Wagner
  16 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-03 14:23 UTC (permalink / raw)
  To: pbs-devel

This allows the user to set up a mapping for `firstname` and `lastname`
attributes for LDAP user syncs.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/window/AuthEditLDAP.js | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/src/window/AuthEditLDAP.js b/src/window/AuthEditLDAP.js
index 2a1cffd..8c92194 100644
--- a/src/window/AuthEditLDAP.js
+++ b/src/window/AuthEditLDAP.js
@@ -200,7 +200,7 @@ Ext.define('Proxmox.panel.LDAPSyncInputPanel', {
     xtype: 'pmxAuthLDAPSyncPanel',
     mixins: ['Proxmox.Mixin.CBind'],
 
-    editableAttributes: ['email'],
+    editableAttributes: ['firstname', 'lastname', 'email'],
     editableDefaults: ['scope', 'enable-new'],
     default_opts: {},
     sync_attributes: {},
@@ -278,6 +278,16 @@ Ext.define('Proxmox.panel.LDAPSyncInputPanel', {
     },
 
     column1: [
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'firstname',
+	    fieldLabel: gettext('First Name attribute'),
+	},
+	{
+	    xtype: 'proxmoxtextfield',
+	    name: 'lastname',
+	    fieldLabel: gettext('Last Name attribute'),
+	},
 	{
 	    xtype: 'proxmoxtextfield',
 	    name: 'email',
-- 
2.30.2





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

* Re: [pbs-devel] [PATCH proxmox-backup 01/17] pbs-config: add delete_authid to ACL-tree
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 01/17] pbs-config: add delete_authid to ACL-tree Lukas Wagner
@ 2023-01-04 10:23   ` Wolfgang Bumiller
  0 siblings, 0 replies; 28+ messages in thread
From: Wolfgang Bumiller @ 2023-01-04 10:23 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pbs-devel

On Tue, Jan 03, 2023 at 03:22:52PM +0100, Lukas Wagner wrote:
> From: Hannes Laimer <h.laimer@proxmox.com>
> 
> ... allows the deletion of an authid from the whole tree. Needed
> for removing deleted users/tokens.

And you probably want a way to include all tokens for a user.

> 
> Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
> ---
>  pbs-config/src/acl.rs | 71 +++++++++++++++++++++++++++++++++++++++++++
>  1 file changed, 71 insertions(+)
> 
> diff --git a/pbs-config/src/acl.rs b/pbs-config/src/acl.rs
> index 89a54dfc..a4a79755 100644
> --- a/pbs-config/src/acl.rs
> +++ b/pbs-config/src/acl.rs
> @@ -280,6 +280,13 @@ impl AclTreeNode {
>          roles.remove(role);
>      }
>  
> +    fn delete_authid(&mut self, auth_id: &Authid) {
> +        for (_name, node) in self.children.iter_mut() {

^ This can use `values_mut()`.

> +            node.delete_authid(auth_id);
> +        }
> +        self.users.remove(auth_id);
> +    }
> +
>      fn insert_group_role(&mut self, group: String, role: String, propagate: bool) {
>          let map = self.groups.entry(group).or_default();
>          if role == ROLE_NAME_NO_ACCESS {




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

* Re: [pbs-devel] [PATCH proxmox-backup 04/17] api: add routes for managing LDAP realms
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 04/17] api: add routes for managing LDAP realms Lukas Wagner
@ 2023-01-04 11:16   ` Wolfgang Bumiller
  0 siblings, 0 replies; 28+ messages in thread
From: Wolfgang Bumiller @ 2023-01-04 11:16 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pbs-devel

On Tue, Jan 03, 2023 at 03:22:55PM +0100, Lukas Wagner wrote:
> Note: bind-passwords set via the API  are not stored in `domains.cfg`,
> but in a separate `ldap_passwords.json` file located in
> `/etc/proxmox-backup/`.
> Similar to the already existing `shadow.json`, the file is
> stored with 0600 permissions and is owned by root.
> 
> Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
> ---
>  pbs-config/src/domains.rs      |  16 +-
>  src/api2/config/access/ldap.rs | 316 +++++++++++++++++++++++++++++++++
>  src/api2/config/access/mod.rs  |   7 +-
>  src/auth_helpers.rs            |  51 ++++++
>  4 files changed, 387 insertions(+), 3 deletions(-)
>  create mode 100644 src/api2/config/access/ldap.rs
> 
> diff --git a/pbs-config/src/domains.rs b/pbs-config/src/domains.rs
> index 12d4543d..6cef3ec5 100644
> --- a/pbs-config/src/domains.rs
> +++ b/pbs-config/src/domains.rs
> @@ -7,13 +7,15 @@ use proxmox_schema::{ApiType, Schema};
>  use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
>  
>  use crate::{open_backup_lockfile, replace_backup_config, BackupLockGuard};
> -use pbs_api_types::{OpenIdRealmConfig, REALM_ID_SCHEMA};
> +use pbs_api_types::{LdapRealmConfig, OpenIdRealmConfig, REALM_ID_SCHEMA};
>  
>  lazy_static! {
>      pub static ref CONFIG: SectionConfig = init();
>  }
>  
>  fn init() -> SectionConfig {
> +    let mut config = SectionConfig::new(&REALM_ID_SCHEMA);
> +
>      let obj_schema = match OpenIdRealmConfig::API_SCHEMA {
>          Schema::Object(ref obj_schema) => obj_schema,
>          _ => unreachable!(),
> @@ -24,7 +26,17 @@ fn init() -> SectionConfig {
>          Some(String::from("realm")),
>          obj_schema,
>      );
> -    let mut config = SectionConfig::new(&REALM_ID_SCHEMA);
> +
> +    config.register_plugin(plugin);
> +
> +    let obj_schema = match LdapRealmConfig::API_SCHEMA {
> +        Schema::Object(ref obj_schema) => obj_schema,
> +        _ => unreachable!(),

^ if you already touch this code, please update it to use compile time
checks here we got with improved const fn support a few rust versions
ago, see `metrics.rs`:

    const LDAP_SCHEMA: &ObjectSchema = LdapRealmConfig::API_SCHEMA.unwrap_object_schema()

(must be a `const` rather than a `let` binding to be checked at
compile time)

> +    };
> +
> +    let plugin =
> +        SectionConfigPlugin::new("ldap".to_string(), Some(String::from("realm")), obj_schema);
> +
>      config.register_plugin(plugin);
>  
>      config
> diff --git a/src/api2/config/access/ldap.rs b/src/api2/config/access/ldap.rs
> new file mode 100644
> index 00000000..14bbf9ea
> --- /dev/null
> +++ b/src/api2/config/access/ldap.rs
> @@ -0,0 +1,316 @@
> +/// Create a new LDAP realm
> +pub fn create_ldap_realm(mut config: LdapRealmConfig) -> Result<(), Error> {
> +    let _lock = domains::lock_config()?;
> +
> +    let (mut domains, _digest) = domains::config()?;
> +
> +    if config.realm == "pbs"
> +        || config.realm == "pam"
> +        || domains.sections.get(&config.realm).is_some()

^ perhaps replace this & its openid version with an `exists` helper in
`pbs_config::domains`

You can just have that take the `&SectionConfigData` but bonus points if
you also add a `Domains` type and don't actually expose the
`SectionConfigData` anywhere (but that's probably better off in a
separate patch series...)

> +    {
> +        param_bail!("realm", "realm '{}' already exists.", config.realm);
> +    }
> +
> +    // If a bind password is set, take it out of the config struct and
> +    // save it separately with proper protection
> +    if let Some(password) = config.password.take() {
> +        auth_helpers::store_ldap_bind_password(&config.realm, &password)?;
> +    }

^ doesn't this sort of make `password` a dead field in
`LdapRealmconfig`?
It seems to me it would be better to have an explicit password parameter
on this API call with the rest of the `LdapRealmConfig` `flatten`ed into
the parameter list?
That way we can never accidentally use the password from that field and
don't need debug assertions.

> +
> +    debug_assert!(config.password.is_none());
> +
> +    domains.set_data(&config.realm, "ldap", &config)?;
> +
> +    domains::save_config(&domains)?;
> +
> +    Ok(())
> +}
> +
> +#[api(
> +    protected: true,
> +    input: {
> +        properties: {
> +            realm: {
> +                schema: REALM_ID_SCHEMA,
> +            },
> +            digest: {
> +                optional: true,
> +                schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
> +            },
> +        },
> +    },
> +    access: {
> +        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
> +    },
> +)]
> +/// Remove an LDAP realm configuration
> +pub fn delete_ldap_realm(
> +    realm: String,
> +    digest: Option<String>,
> +    _rpcenv: &mut dyn RpcEnvironment,
> +) -> Result<(), Error> {
> +    let _lock = domains::lock_config()?;
> +
> +    let (mut domains, expected_digest) = domains::config()?;
> +
> +    if let Some(ref digest) = digest {
> +        let digest = <[u8; 32]>::from_hex(digest)?;
> +        crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
> +    }
> +
> +    if domains.sections.remove(&realm).is_none() {
> +        http_bail!(NOT_FOUND, "realm '{}' does not exist.", realm);
> +    }
> +
> +    domains::save_config(&domains)?;
> +
> +    auth_helpers::remove_ldap_bind_password(&realm)?;

^ The password removal should probably only log errors, but not actually
fail the API call? Since the config is already stored and you wouldn't
be able to re-run the same call.

> +
> +    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"] = hex::encode(digest).into();
> +
> +    Ok(config)
> +}
> +
> +#[api()]
> +#[derive(Serialize, Deserialize)]
> +#[serde(rename_all = "kebab-case")]
> +#[allow(non_camel_case_types)]

^ Please don't continue this trend, just name the variants correctly.

> +/// Deletable property name
> +pub enum DeletableProperty {
> +    /// Fallback LDAP server address
> +    server2,
> +    /// Port
> +    port,
> +    /// Comment
> +    comment,
> +    /// Verify server certificate
> +    verify,
> +    /// Mode (ldap, ldap+starttls or ldaps),
> +    mode,
> +    /// Bind Domain
> +    bind_dn,
> +    /// Bind password
> +    password,
> +}
> diff --git a/src/auth_helpers.rs b/src/auth_helpers.rs
> index 57e02900..79128811 100644
> --- a/src/auth_helpers.rs
> +++ b/src/auth_helpers.rs
> @@ -11,6 +11,7 @@ use proxmox_sys::fs::{file_get_contents, replace_file, CreateOptions};
>  
>  use pbs_api_types::Userid;
>  use pbs_buildcfg::configdir;
> +use serde_json::json;
>  
>  fn compute_csrf_secret_digest(timestamp: i64, secret: &[u8], userid: &Userid) -> String {
>      let mut hasher = sha::Sha256::new();
> @@ -180,3 +181,53 @@ pub fn private_auth_key() -> &'static PKey<Private> {
>  
>      &KEY
>  }
> +
> +const LDAP_PASSWORDS_FILENAME: &str = configdir!("/ldap_passwords.json");
> +

Please include in the comments of all store & remove that they need the
domain config lock to be held.
Or actually, maybe move the store & remove ones into the api module
where they're actually used and drop the `pub`.

> +/// Store LDAP bind passwords in protected file
> +pub fn store_ldap_bind_password(realm: &str, password: &str) -> Result<(), Error> {
> +    let mut data = proxmox_sys::fs::file_get_json(LDAP_PASSWORDS_FILENAME, Some(json!({})))?;
> +    data[realm] = password.into();
> +
> +    let mode = nix::sys::stat::Mode::from_bits_truncate(0o0600);
> +    let options = proxmox_sys::fs::CreateOptions::new()
> +        .perm(mode)
> +        .owner(nix::unistd::ROOT)
> +        .group(nix::unistd::Gid::from_raw(0));
> +
> +    let data = serde_json::to_vec_pretty(&data)?;
> +    proxmox_sys::fs::replace_file(LDAP_PASSWORDS_FILENAME, &data, options, true)?;
> +
> +    Ok(())
> +}
> +
> +/// Remove stored LDAP bind password
> +pub fn remove_ldap_bind_password(realm: &str) -> 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 mode = nix::sys::stat::Mode::from_bits_truncate(0o0600);
> +    let options = proxmox_sys::fs::CreateOptions::new()
> +        .perm(mode)
> +        .owner(nix::unistd::ROOT)
> +        .group(nix::unistd::Gid::from_raw(0));
> +
> +    let data = serde_json::to_vec_pretty(&data)?;
> +    proxmox_sys::fs::replace_file(LDAP_PASSWORDS_FILENAME, &data, options, true)?;
> +
> +    Ok(())
> +}
> +
> +/// Retrieve stored LDAP bind password
> +pub 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)
> +}
> -- 
> 2.30.2




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

* Re: [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module Lukas Wagner
@ 2023-01-04 13:23   ` Wolfgang Bumiller
  2023-01-09 10:52     ` Lukas Wagner
  0 siblings, 1 reply; 28+ messages in thread
From: Wolfgang Bumiller @ 2023-01-04 13:23 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pbs-devel

On Tue, Jan 03, 2023 at 03:22:56PM +0100, Lukas Wagner wrote:
> The module is an abstraction over the ldap3 crate. It uses
> its own configuration structs to prevent strongly coupling it
> to pbs-api-types.
> 
> Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
> ---
>  Cargo.toml         |   2 +
>  src/server/ldap.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++
>  src/server/mod.rs  |   2 +
>  3 files changed, 178 insertions(+)
>  create mode 100644 src/server/ldap.rs
> 
> diff --git a/Cargo.toml b/Cargo.toml
> index 2639b4b1..c9f1f185 100644
> --- a/Cargo.toml
> +++ b/Cargo.toml
> @@ -118,6 +118,7 @@ hex = "0.4.3"
>  http = "0.2"
>  hyper = { version = "0.14", features = [ "full" ] }
>  lazy_static = "1.4"
> +ldap3 = { version = "0.11.0-beta.1", default_features=false, features=["tls"]}
>  libc = "0.2"
>  log = "0.4.17"
>  nix = "0.24"
> @@ -169,6 +170,7 @@ hex.workspace = true
>  http.workspace = true
>  hyper.workspace = true
>  lazy_static.workspace = true
> +ldap3.workspace = true
>  libc.workspace = true
>  log.workspace = true
>  nix.workspace = true
> diff --git a/src/server/ldap.rs b/src/server/ldap.rs
> new file mode 100644
> index 00000000..a8b7a79d
> --- /dev/null
> +++ b/src/server/ldap.rs
> @@ -0,0 +1,174 @@
> +use std::time::Duration;
> +
> +use anyhow::{bail, Error};
> +use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, LdapResult, Scope, SearchEntry};
> +
> +#[derive(PartialEq, Eq)]
> +/// LDAP connection security
> +pub enum LdapConnectionMode {

Is there any particular reason to not just reuse the API type?

> +    /// unencrypted connection
> +    Ldap,
> +    /// upgrade to TLS via STARTTLS
> +    StartTls,
> +    /// TLS via LDAPS
> +    Ldaps,
> +}
> +
> +/// Configuration for LDAP connections
> +pub struct LdapConfig {

Same here, you could just reference the api config?

> +    /// Array of servers that will be tried in order
> +    pub servers: Vec<String>,
> +    /// Port
> +    pub port: Option<u16>,
> +    /// LDAP attribute containing the user id. Will be used to look up the user's domain
> +    pub user_attr: String,
> +    /// LDAP base domain
> +    pub base_dn: String,
> +    /// LDAP bind domain, will be used for user lookup/sync if set
> +    pub bind_dn: Option<String>,
> +    /// LDAP bind password, will be used for user lookup/sync if set
> +    pub bind_password: Option<String>,
> +    /// Connection security
> +    pub tls_mode: LdapConnectionMode,
> +    /// Verify the server's TLS certificate
> +    pub verify_certificate: bool,
> +}
> +
> +pub struct LdapConnection {
> +    config: LdapConfig,
> +}
> +
> +impl LdapConnection {
> +    const LDAP_DEFAULT_PORT: u16 = 389;
> +    const LDAPS_DEFAULT_PORT: u16 = 636;
> +    const LDAP_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);

^ With this and the next patch using block_on() it's time to modify
`ProxmoxAuthenticator` to return a
`Box<dyn Future + Send + Sync + 'a>`, and the returned dyn-box of
`auth::lookup_authenticator` should include `+ Send + Sync + 'static`.

Otherwise if the connection is down, users trying to log into ldap could
easily block an admin trying to fix-up whatever connection issue they
have from logging in.

I also wonder if we should try the 2nd connection in parallel with a
delay that is shorter than this timeout, eg. just 1 or 2 seconds, and
use whatever finishes its handshake first?

Although that can be part of a later series...

> +
> +    pub fn new(config: LdapConfig) -> Self {
> +        Self { config }
> +    }
> +
> +    /// Authenticate a user with username/password.
> +    ///
> +    /// The user's domain is queried is by performing an LDAP search with the configured bind_dn
> +    /// and bind_password. If no bind_dn is provided, an anonymous search is attempted.
> +    pub async fn authenticate_user(&self, username: &str, password: &str) -> Result<(), Error> {
> +        let user_dn = self.search_user_dn(username).await?;
> +
> +        let mut ldap = self.create_connection().await?;
> +
> +        // Perform actual user authentication by binding.
> +        let _: LdapResult = ldap.simple_bind(&user_dn, password).await?.success()?;
> +
> +        // We are already authenticated, so don't fail if terminating the connection
> +        // does not work for some reason.
> +        let _: Result<(), _> = ldap.unbind().await;
> +
> +        Ok(())
> +    }
> +
> +    /// Retrive port from LDAP configuration, otherwise use the appropriate default
> +    fn port_from_config(&self) -> u16 {
> +        self.config.port.unwrap_or_else(|| {
> +            if self.config.tls_mode == LdapConnectionMode::Ldaps {
> +                Self::LDAPS_DEFAULT_PORT
> +            } else {
> +                Self::LDAP_DEFAULT_PORT
> +            }
> +        })
> +    }
> +
> +    /// Determine correct URL scheme from LDAP config
> +    fn scheme_from_config(&self) -> &'static str {
> +        if self.config.tls_mode == LdapConnectionMode::Ldaps {
> +            "ldaps"
> +        } else {
> +            "ldap"
> +        }
> +    }
> +
> +    /// Construct URL from LDAP config
> +    fn ldap_url_from_config(&self, server: &str) -> String {
> +        let port = self.port_from_config();
> +        let scheme = self.scheme_from_config();
> +        format!("{scheme}://{server}:{port}")
> +    }
> +
> +    async fn try_connect(&self, url: &str) -> Result<(LdapConnAsync, Ldap), Error> {
> +        let starttls = self.config.tls_mode == LdapConnectionMode::StartTls;
> +
> +        LdapConnAsync::with_settings(
> +            LdapConnSettings::new()
> +                .set_no_tls_verify(!self.config.verify_certificate)
> +                .set_starttls(starttls)
> +                .set_conn_timeout(Self::LDAP_CONNECTION_TIMEOUT),
> +            url,
> +        )
> +        .await
> +        .map_err(|e| e.into())
> +    }
> +
> +    /// Create LDAP connection
> +    ///
> +    /// If a connection to the server cannot be established, the fallbacks
> +    /// are tried.
> +    async fn create_connection(&self) -> Result<Ldap, Error> {
> +        let mut last_error = None;
> +
> +        for server in &self.config.servers {
> +            match self.try_connect(&self.ldap_url_from_config(server)).await {
> +                Ok((connection, ldap)) => {
> +                    ldap3::drive!(connection);
> +                    return Ok(ldap);
> +                }
> +                Err(e) => {
> +                    last_error = Some(e);
> +                }
> +            }
> +        }
> +
> +        Err(last_error.unwrap())
> +    }
> +
> +    /// Search a user's domain.
> +    async fn search_user_dn(&self, username: &str) -> Result<String, Error> {
> +        let mut ldap = self.create_connection().await?;
> +
> +        if let Some(bind_dn) = self.config.bind_dn.as_deref() {
> +            let password = self.config.bind_password.as_deref().unwrap_or_default();
> +            let _: LdapResult = ldap.simple_bind(bind_dn, password).await?.success()?;
> +
> +            let user_dn = self.do_search_user_dn(username, &mut ldap).await;
> +
> +            ldap.unbind().await?;
> +
> +            user_dn
> +        } else {
> +            self.do_search_user_dn(username, &mut ldap).await
> +        }
> +    }
> +
> +    async fn do_search_user_dn(&self, username: &str, ldap: &mut Ldap) -> Result<String, Error> {
> +        let query = format!("(&({}={}))", self.config.user_attr, username);
> +
> +        let (entries, _res) = ldap
> +            .search(&self.config.base_dn, Scope::Subtree, &query, vec!["dn"])
> +            .await?
> +            .success()?;
> +
> +        if entries.len() > 1 {
> +            bail!(
> +                "found multiple users with attribute `{}={}`",
> +                self.config.user_attr,
> +                username
> +            )
> +        }
> +
> +        if let Some(entry) = entries.into_iter().next() {
> +            let entry = SearchEntry::construct(entry);
> +
> +            return Ok(entry.dn);
> +        }
> +
> +        bail!("user not found")
> +    }
> +}
> diff --git a/src/server/mod.rs b/src/server/mod.rs
> index 06dcb867..649c1c51 100644
> --- a/src/server/mod.rs
> +++ b/src/server/mod.rs
> @@ -13,6 +13,8 @@ use pbs_buildcfg;
>  
>  pub mod jobstate;
>  
> +pub mod ldap;
> +
>  mod verify_job;
>  pub use verify_job::*;
>  
> -- 
> 2.30.2




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

* Re: [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator Lukas Wagner
@ 2023-01-04 13:32   ` Wolfgang Bumiller
  2023-01-04 14:48     ` Thomas Lamprecht
  2023-01-09 11:00     ` Lukas Wagner
  0 siblings, 2 replies; 28+ messages in thread
From: Wolfgang Bumiller @ 2023-01-04 13:32 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pbs-devel

On Tue, Jan 03, 2023 at 03:22:57PM +0100, Lukas Wagner wrote:
> Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
> ---
>  src/auth.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++--
>  1 file changed, 63 insertions(+), 2 deletions(-)
> 
> diff --git a/src/auth.rs b/src/auth.rs
> index f1d5c0a1..101bec0e 100644
> --- a/src/auth.rs
> +++ b/src/auth.rs
> @@ -8,9 +8,12 @@ use std::process::{Command, Stdio};
>  use anyhow::{bail, format_err, Error};
>  use serde_json::json;
>  
> -use pbs_api_types::{RealmRef, Userid, UsernameRef};
> +use pbs_api_types::{LdapMode, LdapRealmConfig, RealmRef, Userid, UsernameRef};
>  use pbs_buildcfg::configdir;
>  
> +use crate::auth_helpers;
> +use crate::server::ldap::{LdapConfig, LdapConnection, LdapConnectionMode};
> +
>  pub trait ProxmoxAuthenticator {
>      fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error>;
>      fn store_password(&self, username: &UsernameRef, password: &str) -> Result<(), Error>;
> @@ -122,12 +125,45 @@ impl ProxmoxAuthenticator for PBS {
>      }
>  }
>  
> +#[allow(clippy::upper_case_acronyms)]
> +pub struct LDAP {
> +    config: LdapRealmConfig,
> +}
> +
> +impl ProxmoxAuthenticator for LDAP {
> +    /// Authenticate user in LDAP realm
> +    fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error> {
> +        let ldap_config = ldap_api_type_to_ldap_config(&self.config)?;
> +
> +        let ldap = LdapConnection::new(ldap_config);
> +
> +        proxmox_async::runtime::block_on(ldap.authenticate_user(username.as_str(), password))
> +    }
> +
> +    fn store_password(&self, _username: &UsernameRef, _password: &str) -> Result<(), Error> {
> +        // do not store password for LDAP users
> +        Ok(())

Actually this should fail.
Otherwise this will make change-password API calls "succeed" without
actually doing anything, but IMO it makes more sense to return a
meaningful error there.

(Perhaps even a http_bail!(NOT_IMPLEMENTED) though I'm not really sure
how the GUI would deal with that ;-) )

> +    }
> +
> +    fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> {
> +        // do not remove password for LDAP users

^ same here

> +        Ok(())
> +    }
> +}
> +
>  /// Lookup the autenticator for the specified realm
>  pub fn lookup_authenticator(realm: &RealmRef) -> Result<Box<dyn ProxmoxAuthenticator>, Error> {
>      match realm.as_str() {
>          "pam" => Ok(Box::new(PAM())),
>          "pbs" => Ok(Box::new(PBS())),
> -        _ => bail!("unknown realm '{}'", realm.as_str()),
> +        realm => {
> +            let (domains, _digest) = pbs_config::domains::config()?;
> +            if let Ok(config) = domains.lookup::<LdapRealmConfig>("ldap", realm) {
> +                Ok(Box::new(LDAP { config }))
> +            } else {
> +                bail!("unknown realm '{}'", realm);
> +            }
> +        }
>      }
>  }
>  
> @@ -135,3 +171,28 @@ pub fn lookup_authenticator(realm: &RealmRef) -> Result<Box<dyn ProxmoxAuthentic
>  pub fn authenticate_user(userid: &Userid, password: &str) -> Result<(), Error> {
>      lookup_authenticator(userid.realm())?.authenticate_user(userid.name(), password)
>  }
> +
> +// TODO: Is there a better place for this?

IMO the best way is to just not :-) (as I mentioned in a reply to the
earlier patches)

Although that would make it more difficult to make this reusable and...
well...
we may still need it in the end, we'll see...

> +pub fn ldap_api_type_to_ldap_config(config: &LdapRealmConfig) -> Result<LdapConfig, Error> {
> +    let mut servers = vec![config.server1.clone()];
> +    if let Some(server) = &config.server2 {
> +        servers.push(server.clone());
> +    }
> +
> +    let tls_mode = match config.mode.unwrap_or_default() {
> +        LdapMode::Ldap => LdapConnectionMode::Ldap,
> +        LdapMode::StartTls => LdapConnectionMode::StartTls,
> +        LdapMode::Ldaps => LdapConnectionMode::Ldaps,
> +    };
> +
> +    Ok(LdapConfig {
> +        servers,
> +        port: config.port,
> +        user_attr: config.user_attr.clone(),
> +        base_dn: config.base_dn.clone(),
> +        bind_dn: config.bind_dn.clone(),
> +        bind_password: auth_helpers::get_ldap_bind_password(&config.realm)?,
> +        tls_mode,
> +        verify_certificate: config.verify.unwrap_or_default(),
> +    })
> +}
> -- 
> 2.30.2
> 
> 
> 
> _______________________________________________
> pbs-devel mailing list
> pbs-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel
> 
> 




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

* Re: [pbs-devel] [PATCH proxmox-backup 07/17] api-types: add config options for LDAP user sync
  2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 07/17] api-types: add config options for LDAP user sync Lukas Wagner
@ 2023-01-04 13:40   ` Wolfgang Bumiller
  2023-01-09 13:58     ` Lukas Wagner
  0 siblings, 1 reply; 28+ messages in thread
From: Wolfgang Bumiller @ 2023-01-04 13:40 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pbs-devel

On Tue, Jan 03, 2023 at 03:22:58PM +0100, Lukas Wagner wrote:
> Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
> ---
>  pbs-api-types/src/ldap.rs      | 124 ++++++++++++++++++++++++++++++++-
>  src/api2/config/access/ldap.rs |  37 ++++++++++
>  2 files changed, 159 insertions(+), 2 deletions(-)
> 
> diff --git a/pbs-api-types/src/ldap.rs b/pbs-api-types/src/ldap.rs
> index a08e124b..672c81cd 100644
> --- a/pbs-api-types/src/ldap.rs
> +++ b/pbs-api-types/src/ldap.rs
> @@ -1,6 +1,6 @@
>  use serde::{Deserialize, Serialize};
>  
> -use proxmox_schema::{api, Updater};
> +use proxmox_schema::{api, ApiStringFormat, ApiType, ArraySchema, Schema, StringSchema, Updater};
>  
>  use super::{REALM_ID_SCHEMA, SINGLE_LINE_COMMENT_SCHEMA};
>  
> @@ -32,7 +32,19 @@ pub enum LdapMode {
>          "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,
> +        },
>      },
>  )]
>  #[derive(Serialize, Deserialize, Updater, Clone)]
> @@ -68,4 +80,112 @@ pub struct LdapRealmConfig {
>      /// Bind password for the given bind-dn
>      #[serde(skip_serializing_if = "Option::is_none")]
>      pub password: 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: {
> +        "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>,

^ Should be possible to actually use `RemoveVanished` as a type here?
(and replace `schema: REMOVE_..` with `type: RemoveVanished,` in the
`#[api]` block.

> +    /// 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 SYNC_DEFAULTS_STRING_SCHEMA: Schema = StringSchema::new("sync defaults options")
> +    .format(&ApiStringFormat::PropertyString(
> +        &SyncDefaultsOptions::API_SCHEMA,
> +    ))
> +    .schema();
> +
> +const REMOVE_VANISHED_DESCRIPTION: &str =
> +    "A semicolon-seperated 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 PBS user field. For example, \
> +to map the LDAP attribute ``mail`` to PBS'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``.";

^ seems to need reformatting (100 char limit)

> +
> +pub const USER_CLASSES_SCHEMA: Schema = StringSchema::new(USER_CLASSES_TEXT)
> +    .format(&ApiStringFormat::PropertyString(&USER_CLASSES_ARRAY))
> +    .default("inetorgperson,posixaccount,person,user")
> +    .schema();




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

* Re: [pbs-devel] [PATCH proxmox-backup 10/17] manager: add sync command for LDAP realms
  2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 10/17] manager: add sync command for LDAP realms Lukas Wagner
@ 2023-01-04 13:56   ` Wolfgang Bumiller
  0 siblings, 0 replies; 28+ messages in thread
From: Wolfgang Bumiller @ 2023-01-04 13:56 UTC (permalink / raw)
  To: Lukas Wagner; +Cc: pbs-devel

On Tue, Jan 03, 2023 at 03:23:01PM +0100, Lukas Wagner wrote:
> Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
> ---
>  src/bin/proxmox_backup_manager/ldap.rs | 83 +++++++++++++++++++++++++-
>  1 file changed, 81 insertions(+), 2 deletions(-)
> 
> diff --git a/src/bin/proxmox_backup_manager/ldap.rs b/src/bin/proxmox_backup_manager/ldap.rs
> index 4020caee..407c675d 100644
> --- a/src/bin/proxmox_backup_manager/ldap.rs
> +++ b/src/bin/proxmox_backup_manager/ldap.rs
> @@ -1,10 +1,15 @@
>  use anyhow::Error;
> +use futures::FutureExt;
>  use serde_json::Value;
> +use tokio::signal::unix::{signal, SignalKind};
>  
> -use proxmox_router::{cli::*, ApiHandler, RpcEnvironment};
> +use proxmox_router::{cli::*, ApiHandler, Permission, RpcEnvironment};
>  use proxmox_schema::api;
>  
> -use pbs_api_types::REALM_ID_SCHEMA;
> +use pbs_api_types::{
> +    Realm, PRIV_PERMISSIONS_MODIFY, PROXMOX_UPID_REGEX, REALM_ID_SCHEMA, REMOVE_VANISHED_SCHEMA,
> +    UPID,
> +};
>  
>  use proxmox_backup::api2;
>  
> @@ -67,6 +72,74 @@ fn show_ldap_realm(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<Valu
>      Ok(Value::Null)
>  }
>  
> +#[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,
> +                type: bool,
> +            }
> +         },
> +    },
> +    access: {
> +        permission: &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false),
> +    },
> +)]
> +/// List configured LDAP realms
> +async fn sync_ldap_realm(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
> +    let info = &api2::access::domain::API_METHOD_SYNC_REALM;
> +    let data = match info.handler {
> +        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
> +        _ => unreachable!(),
> +    };
> +
> +    if let Some(upid) = data.as_str() {
> +        if PROXMOX_UPID_REGEX.is_match(upid) {
> +            handle_worker(upid).await?;
> +        }
> +    }
> +
> +    Ok(Value::Null)
> +}
> +
> +// TODO: This was copied from proxmox_backup_debug/api.rs - is there a good place to
> +// put this so we can use the same impl for both?

I'd say proxmox_rest_server (in proxmox.git), it already pulls in
tokio's `signal` feature, the schema's UPID type and contains the main
components of this function anyway.

> +async fn handle_worker(upid_str: &str) -> Result<(), Error> {
> +    let upid: UPID = upid_str.parse()?;
> +    let mut signal_stream = signal(SignalKind::interrupt())?;
> +    let abort_future = async move {
> +        while signal_stream.recv().await.is_some() {
> +            println!("got shutdown request (SIGINT)");
> +            proxmox_rest_server::abort_local_worker(upid.clone());
> +        }
> +        Ok::<_, Error>(())
> +    };
> +
> +    let result_future = proxmox_rest_server::wait_for_local_worker(upid_str);
> +
> +    futures::select! {
> +        result = result_future.fuse() => result?,
> +        abort = abort_future.fuse() => abort?,
> +    };
> +
> +    Ok(())
> +}
> +
>  pub fn ldap_commands() -> CommandLineInterface {
>      let cmd_def = CliCommandMap::new()
>          .insert("list", CliCommand::new(&API_METHOD_LIST_LDAP_REALMS))
> @@ -93,6 +166,12 @@ pub fn ldap_commands() -> CommandLineInterface {
>              CliCommand::new(&api2::config::access::ldap::API_METHOD_DELETE_LDAP_REALM)
>                  .arg_param(&["realm"])
>                  .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
> +        )
> +        .insert(
> +            "sync",
> +            CliCommand::new(&API_METHOD_SYNC_LDAP_REALM)
> +                .arg_param(&["realm"])
> +                .completion_cb("realm", pbs_config::domains::complete_ldap_realm_name),
>          );
>  
>      cmd_def.into()
> -- 
> 2.30.2




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

* Re: [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator
  2023-01-04 13:32   ` Wolfgang Bumiller
@ 2023-01-04 14:48     ` Thomas Lamprecht
  2023-01-09 11:00     ` Lukas Wagner
  1 sibling, 0 replies; 28+ messages in thread
From: Thomas Lamprecht @ 2023-01-04 14:48 UTC (permalink / raw)
  To: Proxmox Backup Server development discussion, Wolfgang Bumiller,
	Lukas Wagner

Am 04/01/2023 um 14:32 schrieb Wolfgang Bumiller:
>> +    fn store_password(&self, _username: &UsernameRef, _password: &str) -> Result<(), Error> {
>> +        // do not store password for LDAP users
>> +        Ok(())
> Actually this should fail.
> Otherwise this will make change-password API calls "succeed" without
> actually doing anything, but IMO it makes more sense to return a
> meaningful error there.
> 
> (Perhaps even a http_bail!(NOT_IMPLEMENTED) though I'm not really sure
> how the GUI would deal with that 😉 )
> 

Seems reasonable and the web UI should handle it just fine (ExtJS alert
window containing the message)




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

* Re: [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module
  2023-01-04 13:23   ` Wolfgang Bumiller
@ 2023-01-09 10:52     ` Lukas Wagner
  0 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-09 10:52 UTC (permalink / raw)
  To: Wolfgang Bumiller; +Cc: pbs-devel



On 1/4/23 14:23, Wolfgang Bumiller wrote:
>> +#[derive(PartialEq, Eq)]
>> +/// LDAP connection security
>> +pub enum LdapConnectionMode {
> Is there any particular reason to not just reuse the API type?
> 
>> +    /// unencrypted connection
>> +    Ldap,
>> +    /// upgrade to TLS via STARTTLS
>> +    StartTls,
>> +    /// TLS via LDAPS
>> +    Ldaps,
>> +}
>> +
>> +/// Configuration for LDAP connections
>> +pub struct LdapConfig {
> Same here, you could just reference the api config?
> 

As mentioned in the commit message, the main rationale behind this decision
was decoupling this module from the rest of the system.
I did this with the thought in mind that `src/server/ldap.rs` could be
promoted to be its own crate, in case the we want to reuse the implementation
somewhere else. Our `proxmox-openid` crate seems to do the same thing, configuration-wise:
It provides its own configuration structs, and in the products using it, e.g. PBS, there
are adapters in place that map API-type -> OpenID-Config.

Maybe premature optmization^Wrefactoring, but at the time of writing this code
it seemed a good choice to me.


-- 
- Lukas




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

* Re: [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator
  2023-01-04 13:32   ` Wolfgang Bumiller
  2023-01-04 14:48     ` Thomas Lamprecht
@ 2023-01-09 11:00     ` Lukas Wagner
  1 sibling, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-09 11:00 UTC (permalink / raw)
  To: Wolfgang Bumiller; +Cc: pbs-devel



On 1/4/23 14:32, Wolfgang Bumiller wrote:
>> +impl ProxmoxAuthenticator for LDAP {
>> +    /// Authenticate user in LDAP realm
>> +    fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error> {
>> +        let ldap_config = ldap_api_type_to_ldap_config(&self.config)?;
>> +
>> +        let ldap = LdapConnection::new(ldap_config);
>> +
>> +        proxmox_async::runtime::block_on(ldap.authenticate_user(username.as_str(), password))
>> +    }
>> +
>> +    fn store_password(&self, _username: &UsernameRef, _password: &str) -> Result<(), Error> {
>> +        // do not store password for LDAP users
>> +        Ok(())
> Actually this should fail.
> Otherwise this will make change-password API calls "succeed" without
> actually doing anything, but IMO it makes more sense to return a
> meaningful error there.
> 
> (Perhaps even a http_bail!(NOT_IMPLEMENTED) though I'm not really sure
> how the GUI would deal with that 😉 )
> 

Good point. I considered returning a failure here as well, but in the end I decided against it because
the PAM authenticator also returns no failure if one attempts to `remove_password`.
I guess it would make sense then to return a failure there as well?


-- 
- Lukas




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

* Re: [pbs-devel] [PATCH proxmox-backup 07/17] api-types: add config options for LDAP user sync
  2023-01-04 13:40   ` Wolfgang Bumiller
@ 2023-01-09 13:58     ` Lukas Wagner
  0 siblings, 0 replies; 28+ messages in thread
From: Lukas Wagner @ 2023-01-09 13:58 UTC (permalink / raw)
  To: Wolfgang Bumiller; +Cc: pbs-devel

On 1/4/23 14:40, Wolfgang Bumiller wrote:
>> +#[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>,
> 
> ^ Should be possible to actually use `RemoveVanished` as a type here?
> (and replace `schema: REMOVE_..` with `type: RemoveVanished,` in the
> `#[api]` block.
> 

remove-vanished is actually an array: `remove-vanished=acl;entries;properties`

Not sure if I'm missing something, but I think your approach would not for for
something like this?

The whole sync-defaults-options configuration key is a bit weird to handle due
to these nested property-string, for example:

   sync-defaults-options enable_new=true remove-vanished=acl;entries

Took me a while to get these to parse/validate correctly :)

>> +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``.";
> 
> ^ seems to need reformatting (100 char limit)
> 

Thanks, I probably rely a bit too much on rustfmt - which of course does
not touch multi-line strings.

-- 
- Lukas




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

end of thread, other threads:[~2023-01-09 13:58 UTC | newest]

Thread overview: 28+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2023-01-03 14:22 [pbs-devel] [PATCH-SERIES proxmox-{backup, widget-toolkit} 00/17] add LDAP realm support Lukas Wagner
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 01/17] pbs-config: add delete_authid to ACL-tree Lukas Wagner
2023-01-04 10:23   ` Wolfgang Bumiller
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 02/17] ui: add 'realm' field in user edit Lukas Wagner
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 03/17] api-types: add LDAP configuration type Lukas Wagner
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 04/17] api: add routes for managing LDAP realms Lukas Wagner
2023-01-04 11:16   ` Wolfgang Bumiller
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 05/17] auth: add LDAP module Lukas Wagner
2023-01-04 13:23   ` Wolfgang Bumiller
2023-01-09 10:52     ` Lukas Wagner
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 06/17] auth: add LDAP realm authenticator Lukas Wagner
2023-01-04 13:32   ` Wolfgang Bumiller
2023-01-04 14:48     ` Thomas Lamprecht
2023-01-09 11:00     ` Lukas Wagner
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 07/17] api-types: add config options for LDAP user sync Lukas Wagner
2023-01-04 13:40   ` Wolfgang Bumiller
2023-01-09 13:58     ` Lukas Wagner
2023-01-03 14:22 ` [pbs-devel] [PATCH proxmox-backup 08/17] server: add LDAP realm sync job Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 09/17] manager: add LDAP commands Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 10/17] manager: add sync command for LDAP realms Lukas Wagner
2023-01-04 13:56   ` Wolfgang Bumiller
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 11/17] docs: add configuration file reference for domains.cfg Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 12/17] docs: add documentation for LDAP realms Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-backup 13/17] auth ldap: add `certificate-path` option Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 14/17] auth ui: add LDAP realm edit panel Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 15/17] auth ui: add LDAP sync UI Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 16/17] auth ui: add `onlineHelp` for AuthEditLDAP Lukas Wagner
2023-01-03 14:23 ` [pbs-devel] [PATCH proxmox-widget-toolkit 17/17] auth ui: add `firstname` and `lastname` sync-attribute fields Lukas Wagner

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