public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support
@ 2023-01-18  7:36 Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox 01/16] rest-server: add handle_worker from backup debug cli Lukas Wagner
                   ` (15 more replies)
  0 siblings, 16 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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

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.

This patch series adds a new dependency to the `proxmox-ldap` crate,
introduced in [1]. This also brings in `ldap3` and `lber` as new transitive
dependencies. Both crates were already packaged and are available on the
repository, thanks to Fabian.

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 testers:
  * I can provide a .deb for `proxmox-ldap` if needed.
  * 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
[2]. The functionality was needed for user sync.

Changes v1 --> v2:
  * add pbs_config::exists helper function
  * Remove now unused `password` field from `LdapRealmConfig`, add
    additional password paramter to routes which need it 
  * Only log a warning instead of failing completely when removing a
    stored password does not work
  * Proper naming for `DeleteableProperty` struct
  * Document that the domain config lock must be held when 
    the LDAP password helper functions are called.
    Also added a &BackupLockGuard as a parameter, to make sure that 
    at least *something* is locked.
  * moved `handle_worker` function to the `proxmox_rest_server` crate,
    so that it is usable for both, the LDAP management CLI and the debug
    CLI.
  * Made user authentication async,
   `ProxmoxAuthenticator::authenticate_user` now returns a boxed future
  * Promoted `src/server/ldap.rs` to be its own crate - this will be
    useful when PVE uses the same LDAP implemenation via perlmod one
    day.


[1] https://lists.proxmox.com/pipermail/pbs-devel/2023-January/005833.html
[2] https://lists.proxmox.com/pipermail/pbs-devel/2022-December/005774.html 

proxmox:

Lukas Wagner (1):
  rest-server: add handle_worker from backup debug cli
 proxmox-rest-server/src/worker_task.rs | 23 +++++++++++++++++++++++
 1 file changed, 23 insertions(+)

proxmox-backup:

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

Lukas Wagner (10):
  debug cli: use handle_worker in proxmox-rest-server
  ui: add 'realm' field in user edit
  api-types: add LDAP configuration type
  api: add routes for managing LDAP realms
  auth: add LDAP realm authenticator
  api-types: add config options for LDAP user sync
  server: add LDAP realm sync job
  manager: add commands for managing LDAP realms
  docs: add configuration file reference for domains.cfg
  docs: add documentation for LDAP realms

 Cargo.toml                             |   2 +
 docs/Makefile                          |   6 +-
 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              | 199 +++++++++++
 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              |  43 ++-
 src/api2/access/domain.rs              |  85 ++++-
 src/api2/access/mod.rs                 |   8 +-
 src/api2/access/tfa.rs                 |  15 +-
 src/api2/config/access/ldap.rs         | 352 +++++++++++++++++++
 src/api2/config/access/mod.rs          |   7 +-
 src/api2/config/access/openid.rs       |   5 +-
 src/auth.rs                            | 157 +++++++--
 src/auth_helpers.rs                    |  58 ++++
 src/bin/docgen.rs                      |   1 +
 src/bin/proxmox-backup-manager.rs      |   1 +
 src/bin/proxmox_backup_debug/api.rs    |  27 +-
 src/bin/proxmox_backup_manager/ldap.rs | 152 ++++++++
 src/bin/proxmox_backup_manager/mod.rs  |   2 +
 src/server/mod.rs                      |   3 +
 src/server/realm_sync_job.rs           | 463 +++++++++++++++++++++++++
 www/OnlineHelpInfo.js                  |   8 +
 www/Utils.js                           |   4 +-
 www/window/UserEdit.js                 |  95 ++++-
 30 files changed, 1798 insertions(+), 96 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/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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox 01/16] rest-server: add handle_worker from backup debug cli
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 02/16] debug cli: use handle_worker in proxmox-rest-server Lukas Wagner
                   ` (14 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 UTC (permalink / raw)
  To: pbs-devel

The function has now multiple users, so it is moved
here.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 proxmox-rest-server/src/worker_task.rs | 23 +++++++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/proxmox-rest-server/src/worker_task.rs b/proxmox-rest-server/src/worker_task.rs
index 44d8111..6263ce7 100644
--- a/proxmox-rest-server/src/worker_task.rs
+++ b/proxmox-rest-server/src/worker_task.rs
@@ -14,6 +14,7 @@ use nix::fcntl::OFlag;
 use once_cell::sync::OnceCell;
 use serde::{Deserialize, Serialize};
 use serde_json::{json, Value};
+use tokio::signal::unix::SignalKind;
 use tokio::sync::oneshot;
 
 use proxmox_lang::try_block;
@@ -1056,3 +1057,25 @@ pub fn abort_local_worker(upid: UPID) {
         worker.request_abort();
     }
 }
+
+/// Wait for locally running worker, responding to SIGINT properly
+pub async fn handle_worker(upid_str: &str) -> Result<(), Error> {
+    let upid: UPID = upid_str.parse()?;
+    let mut signal_stream = tokio::signal::unix::signal(SignalKind::interrupt())?;
+    let abort_future = async move {
+        while signal_stream.recv().await.is_some() {
+            println!("got shutdown request (SIGINT)");
+            abort_local_worker(upid.clone());
+        }
+        Ok::<_, Error>(())
+    };
+
+    let result_future = wait_for_local_worker(upid_str);
+
+    futures::select! {
+        result = result_future.fuse() => result?,
+        abort = abort_future.fuse() => abort?,
+    };
+
+    Ok(())
+}
-- 
2.30.2





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

* [pbs-devel] [PATCH v2 proxmox-backup 02/16] debug cli: use handle_worker in proxmox-rest-server
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox 01/16] rest-server: add handle_worker from backup debug cli Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 03/16] pbs-config: add delete_authid to ACL-tree Lukas Wagner
                   ` (13 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 UTC (permalink / raw)
  To: pbs-devel

The function was moved to proxmox-rest-server to make it
usable in the proxmox-backup-manager cli binary.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 src/bin/proxmox_backup_debug/api.rs | 27 ++-------------------------
 1 file changed, 2 insertions(+), 25 deletions(-)

diff --git a/src/bin/proxmox_backup_debug/api.rs b/src/bin/proxmox_backup_debug/api.rs
index 5980c180..64c22b96 100644
--- a/src/bin/proxmox_backup_debug/api.rs
+++ b/src/bin/proxmox_backup_debug/api.rs
@@ -1,9 +1,7 @@
 use anyhow::{bail, format_err, Error};
-use futures::FutureExt;
 use hyper::Method;
 use serde::{Deserialize, Serialize};
 use serde_json::{json, Value};
-use tokio::signal::unix::{signal, SignalKind};
 
 use std::collections::HashMap;
 
@@ -11,7 +9,7 @@ use proxmox_router::{cli::*, ApiHandler, ApiMethod, RpcEnvironment, SubRoute};
 use proxmox_schema::format::DocumentationFormat;
 use proxmox_schema::{api, ApiType, ParameterSchema, Schema};
 
-use pbs_api_types::{PROXMOX_UPID_REGEX, UPID};
+use pbs_api_types::PROXMOX_UPID_REGEX;
 use pbs_client::view_task_result;
 use proxmox_rest_server::normalize_uri_path;
 
@@ -247,27 +245,6 @@ async fn call_api_code(
     }
 }
 
-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(())
-}
-
 async fn call_api_and_format_result(
     method: String,
     path: String,
@@ -285,7 +262,7 @@ async fn call_api_and_format_result(
                 return Ok(());
             }
 
-            handle_worker(upid).await?;
+            proxmox_rest_server::handle_worker(upid).await?;
 
             if output_format == "text" {
                 return Ok(());
-- 
2.30.2





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

* [pbs-devel] [PATCH v2 proxmox-backup 03/16] pbs-config: add delete_authid to ACL-tree
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox 01/16] rest-server: add handle_worker from backup debug cli Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 02/16] debug cli: use handle_worker in proxmox-rest-server Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 04/16] ui: add 'realm' field in user edit Lukas Wagner
                   ` (12 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-backup 04/16] ui: add 'realm' field in user edit
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (2 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 03/16] pbs-config: add delete_authid to ACL-tree Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 05/16] api-types: add LDAP configuration type Lukas Wagner
                   ` (11 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-backup 05/16] api-types: add LDAP configuration type
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (3 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 04/16] ui: add 'realm' field in user edit Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 06/16] api: add routes for managing LDAP realms Lukas Wagner
                   ` (10 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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 | 78 +++++++++++++++++++++++++++++++++++++++
 pbs-api-types/src/lib.rs  |  5 +++
 2 files changed, 83 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..45c7320d
--- /dev/null
+++ b/pbs-api-types/src/ldap.rs
@@ -0,0 +1,78 @@
+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>,
+    /// CA certificate to use for the server. The path can point to
+    /// either a file, or a directory. If it points to a file,
+    /// the PEM-formatted X.509 certificate stored at the path
+    /// will be added as a trusted certificate. 
+    /// If the path points to a directory,
+    /// the directory replaces the system's default certificate
+    /// store at `/etc/ssl/certs` - Every file in the directory
+    /// will be loaded as a trusted certificate.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub capath: Option<String>,
+    /// Bind domain to use for looking up users
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub bind_dn: Option<String>,
+}
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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-backup 06/16] api: add routes for managing LDAP realms
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (4 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 05/16] api-types: add LDAP configuration type Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 07/16] auth: add LDAP realm authenticator Lukas Wagner
                   ` (9 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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        |  31 ++-
 src/api2/config/access/ldap.rs   | 319 +++++++++++++++++++++++++++++++
 src/api2/config/access/mod.rs    |   7 +-
 src/api2/config/access/openid.rs |   5 +-
 src/auth_helpers.rs              |  58 ++++++
 5 files changed, 405 insertions(+), 15 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..81d89d5d 100644
--- a/pbs-config/src/domains.rs
+++ b/pbs-config/src/domains.rs
@@ -3,35 +3,41 @@ use std::collections::HashMap;
 use anyhow::Error;
 use lazy_static::lazy_static;
 
-use proxmox_schema::{ApiType, Schema};
+use pbs_buildcfg::configdir;
+use proxmox_schema::{ApiType, ObjectSchema};
 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 obj_schema = match OpenIdRealmConfig::API_SCHEMA {
-        Schema::Object(ref obj_schema) => obj_schema,
-        _ => unreachable!(),
-    };
+    const LDAP_SCHEMA: &ObjectSchema = LdapRealmConfig::API_SCHEMA.unwrap_object_schema();
+    const OPENID_SCHEMA: &ObjectSchema = OpenIdRealmConfig::API_SCHEMA.unwrap_object_schema();
+
+    let mut config = SectionConfig::new(&REALM_ID_SCHEMA);
 
     let plugin = SectionConfigPlugin::new(
         "openid".to_string(),
         Some(String::from("realm")),
-        obj_schema,
+        OPENID_SCHEMA,
     );
-    let mut config = SectionConfig::new(&REALM_ID_SCHEMA);
+
+    config.register_plugin(plugin);
+
+    let plugin =
+        SectionConfigPlugin::new("ldap".to_string(), Some(String::from("realm")), LDAP_SCHEMA);
+
     config.register_plugin(plugin);
 
     config
 }
 
-pub const DOMAINS_CFG_FILENAME: &str = "/etc/proxmox-backup/domains.cfg";
-pub const DOMAINS_CFG_LOCKFILE: &str = "/etc/proxmox-backup/.domains.lck";
+pub const DOMAINS_CFG_FILENAME: &str = configdir!("/domains.cfg");
+pub const DOMAINS_CFG_LOCKFILE: &str = configdir!("/.domains.lck");
 
 /// Get exclusive lock
 pub fn lock_config() -> Result<BackupLockGuard, Error> {
@@ -52,6 +58,11 @@ pub fn save_config(config: &SectionConfigData) -> Result<(), Error> {
     replace_backup_config(DOMAINS_CFG_FILENAME, raw.as_bytes())
 }
 
+/// Check if a realm with the given name exists
+pub fn exists(domains: &SectionConfigData, realm: &str) -> bool {
+    realm == "pbs" || realm == "pam" || domains.sections.get(realm).is_some()
+}
+
 // shell completion helper
 pub fn complete_realm_name(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
     match config() {
diff --git a/src/api2/config/access/ldap.rs b/src/api2/config/access/ldap.rs
new file mode 100644
index 00000000..fa83d8ba
--- /dev/null
+++ b/src/api2/config/access/ldap.rs
@@ -0,0 +1,319 @@
+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,
+            },
+            password: {
+                description: "LDAP bind password",
+                optional: true,
+            }
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+    },
+)]
+/// Create a new LDAP realm
+pub fn create_ldap_realm(config: LdapRealmConfig, password: Option<String>) -> Result<(), Error> {
+    let domain_config_lock = domains::lock_config()?;
+
+    let (mut domains, _digest) = domains::config()?;
+
+    if domains::exists(&domains, &config.realm) {
+        param_bail!("realm", "realm '{}' already exists.", config.realm);
+    }
+
+    if let Some(password) = password {
+        auth_helpers::store_ldap_bind_password(&config.realm, &password, &domain_config_lock)?;
+    }
+
+    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 domain_config_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)?;
+
+    if auth_helpers::remove_ldap_bind_password(&realm, &domain_config_lock).is_err() {
+        log::error!("Could not remove stored LDAP bind password for realm {realm}");
+    }
+
+    Ok(())
+}
+
+#[api(
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+        },
+    },
+    returns:  { type: LdapRealmConfig },
+    access: {
+        permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false),
+    },
+)]
+/// Read the LDAP realm configuration
+pub fn read_ldap_realm(
+    realm: String,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<LdapRealmConfig, Error> {
+    let (domains, digest) = domains::config()?;
+
+    let config = domains.lookup("ldap", &realm)?;
+
+    rpcenv["digest"] = hex::encode(digest).into();
+
+    Ok(config)
+}
+
+#[api()]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Deletable property name
+pub enum DeletableProperty {
+    /// Fallback LDAP server address
+    Server2,
+    /// Port
+    Port,
+    /// Comment
+    Comment,
+    /// Verify server certificate
+    Verify,
+    /// Mode (ldap, ldap+starttls or ldaps),
+    Mode,
+    /// Bind Domain
+    BindDn,
+    /// LDAP bind passwort
+    Password,
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            realm: {
+                schema: REALM_ID_SCHEMA,
+            },
+            update: {
+                type: LdapRealmConfigUpdater,
+                flatten: true,
+            },
+            password: {
+                description: "LDAP bind password",
+                optional: true,
+            },
+            delete: {
+                description: "List of properties to delete.",
+                type: Array,
+                optional: true,
+                items: {
+                    type: DeletableProperty,
+                }
+            },
+            digest: {
+                optional: true,
+                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,
+    password: Option<String>,
+    delete: Option<Vec<DeletableProperty>>,
+    digest: Option<String>,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    let domain_config_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::BindDn => {
+                    config.bind_dn = None;
+                }
+                DeletableProperty::Password => {
+                    auth_helpers::remove_ldap_bind_password(&realm, &domain_config_lock)?;
+                }
+            }
+        }
+    }
+
+    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) = password {
+        auth_helpers::store_ldap_bind_password(&realm, &password, &domain_config_lock)?;
+    }
+
+    domains.set_data(&realm, "ldap", &config)?;
+
+    domains::save_config(&domains)?;
+
+    Ok(())
+}
+
+const ITEM_ROUTER: Router = Router::new()
+    .get(&API_METHOD_READ_LDAP_REALM)
+    .put(&API_METHOD_UPDATE_LDAP_REALM)
+    .delete(&API_METHOD_DELETE_LDAP_REALM);
+
+pub const ROUTER: Router = Router::new()
+    .get(&API_METHOD_LIST_LDAP_REALMS)
+    .post(&API_METHOD_CREATE_LDAP_REALM)
+    .match_all("realm", &ITEM_ROUTER);
diff --git a/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/api2/config/access/openid.rs b/src/api2/config/access/openid.rs
index bb39b574..4901880e 100644
--- a/src/api2/config/access/openid.rs
+++ b/src/api2/config/access/openid.rs
@@ -61,10 +61,7 @@ pub fn create_openid_realm(config: OpenIdRealmConfig) -> Result<(), Error> {
 
     let (mut domains, _digest) = domains::config()?;
 
-    if config.realm == "pbs"
-        || config.realm == "pam"
-        || domains.sections.get(&config.realm).is_some()
-    {
+    if domains::exists(&domains, &config.realm) {
         param_bail!("realm", "realm '{}' already exists.", config.realm);
     }
 
diff --git a/src/auth_helpers.rs b/src/auth_helpers.rs
index 57e02900..f4ac194a 100644
--- a/src/auth_helpers.rs
+++ b/src/auth_helpers.rs
@@ -6,11 +6,13 @@ use openssl::pkey::{PKey, Private, Public};
 use openssl::rsa::Rsa;
 use openssl::sha;
 
+use pbs_config::BackupLockGuard;
 use proxmox_lang::try_block;
 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 +182,59 @@ 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. The domain config must be locked while this
+/// function is executed.
+pub fn store_ldap_bind_password(
+    realm: &str,
+    password: &str,
+    _domain_lock: &BackupLockGuard,
+) -> 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. The domain config must be locked while this
+/// function is executed.
+pub fn remove_ldap_bind_password(realm: &str, _domain_lock: &BackupLockGuard) -> 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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-backup 07/16] auth: add LDAP realm authenticator
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (5 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 06/16] api: add routes for managing LDAP realms Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 08/16] api-types: add config options for LDAP user sync Lukas Wagner
                   ` (8 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 UTC (permalink / raw)
  To: pbs-devel

This commit also makes user authentication async, so that e.g. a not 
responding LDAP server cannot block othen logins.

Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
 Cargo.toml             |   2 +
 src/api2/access/mod.rs |   8 +--
 src/api2/access/tfa.rs |  15 ++--
 src/auth.rs            | 157 +++++++++++++++++++++++++++++++++++------
 4 files changed, 151 insertions(+), 31 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 901dedb3..16272b0d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -62,6 +62,7 @@ proxmox-fuse = "0.1.3"
 proxmox-http = { version = "0.7", features = [ "client", "http-helpers", "websocket" ] } # see below
 proxmox-io = "1.0.1" # tools and client use "tokio" feature
 proxmox-lang = "1.1"
+proxmox-ldap = "0.1"
 proxmox-metrics = "0.2"
 proxmox-rest-server = "0.2.1"
 # some use "cli", some use "cli" and "server", pbs-config uses nothing
@@ -205,6 +206,7 @@ proxmox-compression.workspace = true
 proxmox-http = { workspace = true, features = [ "client-trait", "proxmox-async" ] } # pbs-client doesn't use these
 proxmox-io.workspace = true
 proxmox-lang.workspace = true
+proxmox-ldap.workspace = true
 proxmox-metrics.workspace = true
 proxmox-rest-server.workspace = true
 proxmox-router = { workspace = true, features = [ "cli", "server"] }
diff --git a/src/api2/access/mod.rs b/src/api2/access/mod.rs
index 9274b782..d3e21763 100644
--- a/src/api2/access/mod.rs
+++ b/src/api2/access/mod.rs
@@ -43,7 +43,7 @@ enum AuthResult {
     Partial(Box<TfaChallenge>),
 }
 
-fn authenticate_user(
+async fn authenticate_user(
     userid: &Userid,
     password: &str,
     path: Option<String>,
@@ -107,7 +107,7 @@ fn authenticate_user(
 
     #[allow(clippy::let_unit_value)]
     {
-        let _: () = crate::auth::authenticate_user(userid, password)?;
+        let _: () = crate::auth::authenticate_user(userid, password).await?;
     }
 
     Ok(match crate::config::tfa::login_challenge(userid)? {
@@ -190,7 +190,7 @@ fn authenticate_2nd(
 /// Create or verify authentication ticket.
 ///
 /// Returns: An authentication ticket with additional infos.
-pub fn create_ticket(
+pub async fn create_ticket(
     username: Userid,
     password: String,
     path: Option<String>,
@@ -206,7 +206,7 @@ pub fn create_ticket(
         .downcast_ref::<RestEnvironment>()
         .ok_or_else(|| format_err!("detected wrong RpcEnvironment type"))?;
 
-    match authenticate_user(&username, &password, path, privs, port, tfa_challenge) {
+    match authenticate_user(&username, &password, path, privs, port, tfa_challenge).await {
         Ok(AuthResult::Success) => Ok(json!({ "username": username })),
         Ok(AuthResult::CreateTicket) => {
             let api_ticket = ApiTicket::Full(username.clone());
diff --git a/src/api2/access/tfa.rs b/src/api2/access/tfa.rs
index 7e6d028a..599aee60 100644
--- a/src/api2/access/tfa.rs
+++ b/src/api2/access/tfa.rs
@@ -19,7 +19,7 @@ use crate::config::tfa::UserAccess;
 /// This means that user admins need to type in their own password while editing a user, and
 /// regular users, which can only change their own TFA settings (checked at the API level), can
 /// change their own settings using their own password.
-fn tfa_update_auth(
+async fn tfa_update_auth(
     rpcenv: &mut dyn RpcEnvironment,
     userid: &Userid,
     password: Option<String>,
@@ -32,6 +32,7 @@ fn tfa_update_auth(
         #[allow(clippy::let_unit_value)]
         {
             let _: () = crate::auth::authenticate_user(authid.user(), &password)
+                .await
                 .map_err(|err| http_err!(UNAUTHORIZED, "{}", err))?;
         }
     }
@@ -114,13 +115,13 @@ fn get_tfa_entry(userid: Userid, id: String) -> Result<methods::TypedTfaInfo, Er
     },
 )]
 /// Delete a single TFA entry.
-fn delete_tfa(
+async fn delete_tfa(
     userid: Userid,
     id: String,
     password: Option<String>,
     rpcenv: &mut dyn RpcEnvironment,
 ) -> Result<(), Error> {
-    tfa_update_auth(rpcenv, &userid, password, false)?;
+    tfa_update_auth(rpcenv, &userid, password, false).await?;
 
     let _lock = crate::config::tfa::write_lock()?;
 
@@ -207,7 +208,7 @@ fn list_tfa(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<methods::TfaUser>, Er
 )]
 /// Add a TFA entry to the user.
 #[allow(clippy::too_many_arguments)]
-fn add_tfa_entry(
+async fn add_tfa_entry(
     userid: Userid,
     description: Option<String>,
     totp: Option<String>,
@@ -217,7 +218,7 @@ fn add_tfa_entry(
     r#type: methods::TfaType,
     rpcenv: &mut dyn RpcEnvironment,
 ) -> Result<methods::TfaUpdateInfo, Error> {
-    tfa_update_auth(rpcenv, &userid, password, true)?;
+    tfa_update_auth(rpcenv, &userid, password, true).await?;
 
     let _lock = crate::config::tfa::write_lock()?;
 
@@ -269,7 +270,7 @@ fn add_tfa_entry(
     },
 )]
 /// Update user's TFA entry description.
-fn update_tfa_entry(
+async fn update_tfa_entry(
     userid: Userid,
     id: String,
     description: Option<String>,
@@ -277,7 +278,7 @@ fn update_tfa_entry(
     password: Option<String>,
     rpcenv: &mut dyn RpcEnvironment,
 ) -> Result<(), Error> {
-    tfa_update_auth(rpcenv, &userid, password, true)?;
+    tfa_update_auth(rpcenv, &userid, password, true).await?;
 
     let _lock = crate::config::tfa::write_lock()?;
 
diff --git a/src/auth.rs b/src/auth.rs
index f1d5c0a1..9ed8ae6e 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -3,16 +3,27 @@
 //! This library contains helper to authenticate users.
 
 use std::io::Write;
+use std::path::PathBuf;
+use std::pin::Pin;
 use std::process::{Command, Stdio};
 
 use anyhow::{bail, format_err, Error};
+use futures::Future;
+use proxmox_router::http_bail;
 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 proxmox_ldap::{LdapConfig, LdapConnection, LdapConnectionMode};
+
 pub trait ProxmoxAuthenticator {
-    fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error>;
+    fn authenticate_user<'a>(
+        &'a self,
+        username: &'a UsernameRef,
+        password: &'a str,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>>;
     fn store_password(&self, username: &UsernameRef, password: &str) -> Result<(), Error>;
     fn remove_password(&self, username: &UsernameRef) -> Result<(), Error>;
 }
@@ -21,12 +32,18 @@ pub trait ProxmoxAuthenticator {
 struct PAM();
 
 impl ProxmoxAuthenticator for PAM {
-    fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error> {
-        let mut auth = pam::Authenticator::with_password("proxmox-backup-auth").unwrap();
-        auth.get_handler()
-            .set_credentials(username.as_str(), password);
-        auth.authenticate()?;
-        Ok(())
+    fn authenticate_user<'a>(
+        &self,
+        username: &'a UsernameRef,
+        password: &'a str,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
+        Box::pin(async move {
+            let mut auth = pam::Authenticator::with_password("proxmox-backup-auth").unwrap();
+            auth.get_handler()
+                .set_credentials(username.as_str(), password);
+            auth.authenticate()?;
+            Ok(())
+        })
     }
 
     fn store_password(&self, username: &UsernameRef, password: &str) -> Result<(), Error> {
@@ -67,7 +84,10 @@ impl ProxmoxAuthenticator for PAM {
 
     // do not remove password for pam users
     fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> {
-        Ok(())
+        http_bail!(
+            NOT_IMPLEMENTED,
+            "removing passwords is not implemented for PAM realms"
+        );
     }
 }
 
@@ -77,13 +97,19 @@ struct PBS();
 const SHADOW_CONFIG_FILENAME: &str = configdir!("/shadow.json");
 
 impl ProxmoxAuthenticator for PBS {
-    fn authenticate_user(&self, username: &UsernameRef, password: &str) -> Result<(), Error> {
-        let data = proxmox_sys::fs::file_get_json(SHADOW_CONFIG_FILENAME, Some(json!({})))?;
-        match data[username.as_str()].as_str() {
-            None => bail!("no password set"),
-            Some(enc_password) => proxmox_sys::crypt::verify_crypt_pw(password, enc_password)?,
-        }
-        Ok(())
+    fn authenticate_user<'a>(
+        &self,
+        username: &'a UsernameRef,
+        password: &'a str,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
+        Box::pin(async move {
+            let data = proxmox_sys::fs::file_get_json(SHADOW_CONFIG_FILENAME, Some(json!({})))?;
+            match data[username.as_str()].as_str() {
+                None => bail!("no password set"),
+                Some(enc_password) => proxmox_sys::crypt::verify_crypt_pw(password, enc_password)?,
+            }
+            Ok(())
+        })
     }
 
     fn store_password(&self, username: &UsernameRef, password: &str) -> Result<(), Error> {
@@ -122,16 +148,107 @@ 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<'a>(
+        &'a self,
+        username: &'a UsernameRef,
+        password: &'a str,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
+        Box::pin(async move {
+            let ldap_config = Self::api_type_to_config(&self.config)?;
+            let ldap = LdapConnection::new(ldap_config);
+            ldap.authenticate_user(username.as_str(), password).await?;
+            Ok(())
+        })
+    }
+
+    fn store_password(&self, _username: &UsernameRef, _password: &str) -> Result<(), Error> {
+        http_bail!(
+            NOT_IMPLEMENTED,
+            "storing passwords is not implemented for LDAP realms"
+        );
+    }
+
+    fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> {
+        http_bail!(
+            NOT_IMPLEMENTED,
+            "removing passwords is not implemented for LDAP realms"
+        );
+    }
+}
+
+impl LDAP {
+    pub fn api_type_to_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,
+        };
+
+        let (ca_store, trusted_cert) = if let Some(capath) = config.capath.as_deref() {
+            let path = PathBuf::from(capath);
+            if path.is_dir() {
+                (Some(path.to_owned()), None)
+            } else {
+                (None, Some(vec![path.to_owned()]))
+            }
+        } else {
+            (None, None)
+        };
+
+        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(),
+            additional_trusted_certificates: trusted_cert,
+            certificate_store_path: ca_store,
+        })
+    }
+}
+
 /// Lookup the autenticator for the specified realm
-pub fn lookup_authenticator(realm: &RealmRef) -> Result<Box<dyn ProxmoxAuthenticator>, Error> {
+pub fn lookup_authenticator(
+    realm: &RealmRef,
+) -> Result<Box<dyn ProxmoxAuthenticator + Send + Sync + 'static>, 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);
+            }
+        }
     }
 }
 
 /// Authenticate users
-pub fn authenticate_user(userid: &Userid, password: &str) -> Result<(), Error> {
-    lookup_authenticator(userid.realm())?.authenticate_user(userid.name(), password)
+pub fn authenticate_user<'a>(
+    userid: &'a Userid,
+    password: &'a str,
+) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
+    Box::pin(async move {
+        lookup_authenticator(userid.realm())?
+            .authenticate_user(userid.name(), password)
+            .await?;
+        Ok(())
+    })
 }
-- 
2.30.2





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

* [pbs-devel] [PATCH v2 proxmox-backup 08/16] api-types: add config options for LDAP user sync
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (6 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 07/16] auth: add LDAP realm authenticator Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 09/16] server: add LDAP realm sync job Lukas Wagner
                   ` (7 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 UTC (permalink / raw)
  To: pbs-devel

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

diff --git a/pbs-api-types/src/ldap.rs b/pbs-api-types/src/ldap.rs
index 45c7320d..bfd83f9b 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)]
@@ -75,4 +87,113 @@ pub struct LdapRealmConfig {
     /// Bind domain to use for looking up users
     #[serde(skip_serializing_if = "Option::is_none")]
     pub bind_dn: Option<String>,
+    /// Custom LDAP search filter for user sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub filter: Option<String>,
+    /// Default options for LDAP sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sync_defaults_options: Option<String>,
+    /// List of attributes to sync from LDAP to user config
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sync_attributes: Option<String>,
+    /// User ``objectClass`` classes to sync
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub user_classes: Option<String>,
+}
+
+#[api(
+    properties: {
+        "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 fa83d8ba..90cd43c9 100644
--- a/src/api2/config/access/ldap.rs
+++ b/src/api2/config/access/ldap.rs
@@ -172,6 +172,14 @@ pub enum DeletableProperty {
     BindDn,
     /// LDAP bind passwort
     Password,
+    /// User filter
+    Filter,
+    /// Default options for user sync
+    SyncDefaultsOptions,
+    /// user attributes to sync with LDAP attributes
+    SyncAttributes,
+    /// User classes
+    UserClasses,
 }
 
 #[api(
@@ -252,6 +260,18 @@ pub fn update_ldap_realm(
                 DeletableProperty::Password => {
                     auth_helpers::remove_ldap_bind_password(&realm, &domain_config_lock)?;
                 }
+                DeletableProperty::Filter => {
+                    config.filter = None;
+                }
+                DeletableProperty::SyncDefaultsOptions => {
+                    config.sync_defaults_options = None;
+                }
+                DeletableProperty::SyncAttributes => {
+                    config.sync_attributes = None;
+                }
+                DeletableProperty::UserClasses => {
+                    config.user_classes = None;
+                }
             }
         }
     }
@@ -301,6 +321,19 @@ pub fn update_ldap_realm(
         auth_helpers::store_ldap_bind_password(&realm, &password, &domain_config_lock)?;
     }
 
+    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)?;
 
     domains::save_config(&domains)?;
-- 
2.30.2





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

* [pbs-devel] [PATCH v2 proxmox-backup 09/16] server: add LDAP realm sync job
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (7 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 08/16] api-types: add config options for LDAP user sync Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 10/16] manager: add commands for managing LDAP realms Lukas Wagner
                   ` (6 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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/mod.rs            |   3 +
 src/server/realm_sync_job.rs | 463 +++++++++++++++++++++++++++++++++++
 www/Utils.js                 |   4 +-
 5 files changed, 551 insertions(+), 6 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/mod.rs b/src/server/mod.rs
index 06dcb867..ad865a6f 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -22,6 +22,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..f5e1b57f
--- /dev/null
+++ b/src/server/realm_sync_job.rs
@@ -0,0 +1,463 @@
+use anyhow::{bail, Context, Error};
+use pbs_config::{acl::AclTree, token_shadow, BackupLockGuard};
+use proxmox_ldap::{LdapConfig, LdapConnection, SearchParameters, SearchResult};
+use proxmox_rest_server::WorkerTask;
+use proxmox_schema::ApiType;
+use proxmox_section_config::SectionConfigData;
+use proxmox_sys::task_log;
+
+use std::{collections::HashSet, sync::Arc};
+
+use pbs_api_types::{
+    ApiToken, Authid, LdapRealmConfig, Realm, RemoveVanished, SyncAttributes as LdapSyncAttributes,
+    SyncDefaultsOptions, User, Userid, REMOVE_VANISHED_ARRAY, USER_CLASSES_ARRAY,
+};
+
+use crate::{auth, server::jobstate::Job};
+
+/// 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(config) = domains.lookup::<LdapRealmConfig>("ldap", realm.as_str()) {
+            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_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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-backup 10/16] manager: add commands for managing LDAP realms
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (8 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 09/16] server: add LDAP realm sync job Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 11/16] docs: add configuration file reference for domains.cfg Lukas Wagner
                   ` (5 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 UTC (permalink / raw)
  To: pbs-devel

Adds commands for managing LDAP realms, including user sync, 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 | 152 +++++++++++++++++++++++++
 src/bin/proxmox_backup_manager/mod.rs  |   2 +
 4 files changed, 165 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 81d89d5d..521bf206 100644
--- a/pbs-config/src/domains.rs
+++ b/pbs-config/src/domains.rs
@@ -71,13 +71,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
@@ -87,3 +87,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..538c313b
--- /dev/null
+++ b/src/bin/proxmox_backup_manager/ldap.rs
@@ -0,0 +1,152 @@
+use anyhow::Error;
+use serde_json::Value;
+
+use proxmox_router::{cli::*, ApiHandler, Permission, RpcEnvironment};
+use proxmox_schema::api;
+
+use pbs_api_types::{
+    Realm, PRIV_PERMISSIONS_MODIFY, PROXMOX_UPID_REGEX, REALM_ID_SCHEMA, REMOVE_VANISHED_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)
+}
+
+#[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),
+    },
+)]
+/// Sync a given LDAP realm
+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) {
+            proxmox_rest_server::handle_worker(upid).await?;
+        }
+    }
+
+    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),
+        )
+        .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()
+}
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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-backup 11/16] docs: add configuration file reference for domains.cfg
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (9 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 10/16] manager: add commands for managing LDAP realms Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 12/16] docs: add documentation for LDAP realms Lukas Wagner
                   ` (4 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-backup 12/16] docs: add documentation for LDAP realms
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (10 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 11/16] docs: add configuration file reference for domains.cfg Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 13/16] auth ui: add LDAP realm edit panel Lukas Wagner
                   ` (3 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 UTC (permalink / raw)
  To: pbs-devel

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

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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-widget-toolkit 13/16] auth ui: add LDAP realm edit panel
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (11 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 12/16] docs: add documentation for LDAP realms Lukas Wagner
@ 2023-01-18  7:36 ` Lukas Wagner
  2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 14/16] auth ui: add LDAP sync UI Lukas Wagner
                   ` (2 subsequent siblings)
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:36 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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-widget-toolkit 14/16] auth ui: add LDAP sync UI
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (12 preceding siblings ...)
  2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 13/16] auth ui: add LDAP realm edit panel Lukas Wagner
@ 2023-01-18  7:37 ` Lukas Wagner
  2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 15/16] auth ui: add `onlineHelp` for AuthEditLDAP Lukas Wagner
  2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 16/16] auth ui: add `firstname` and `lastname` sync-attribute fields Lukas Wagner
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:37 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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-widget-toolkit 15/16] auth ui: add `onlineHelp` for AuthEditLDAP
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (13 preceding siblings ...)
  2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 14/16] auth ui: add LDAP sync UI Lukas Wagner
@ 2023-01-18  7:37 ` Lukas Wagner
  2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 16/16] auth ui: add `firstname` and `lastname` sync-attribute fields Lukas Wagner
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:37 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] 17+ messages in thread

* [pbs-devel] [PATCH v2 proxmox-widget-toolkit 16/16] auth ui: add `firstname` and `lastname` sync-attribute fields
  2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
                   ` (14 preceding siblings ...)
  2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 15/16] auth ui: add `onlineHelp` for AuthEditLDAP Lukas Wagner
@ 2023-01-18  7:37 ` Lukas Wagner
  15 siblings, 0 replies; 17+ messages in thread
From: Lukas Wagner @ 2023-01-18  7:37 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] 17+ messages in thread

end of thread, other threads:[~2023-01-18  7:37 UTC | newest]

Thread overview: 17+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2023-01-18  7:36 [pbs-devel] [PATCH v2 proxmox{, -backup, -widget-toolkit} 00/16] add LDAP realm support Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox 01/16] rest-server: add handle_worker from backup debug cli Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 02/16] debug cli: use handle_worker in proxmox-rest-server Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 03/16] pbs-config: add delete_authid to ACL-tree Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 04/16] ui: add 'realm' field in user edit Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 05/16] api-types: add LDAP configuration type Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 06/16] api: add routes for managing LDAP realms Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 07/16] auth: add LDAP realm authenticator Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 08/16] api-types: add config options for LDAP user sync Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 09/16] server: add LDAP realm sync job Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 10/16] manager: add commands for managing LDAP realms Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 11/16] docs: add configuration file reference for domains.cfg Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-backup 12/16] docs: add documentation for LDAP realms Lukas Wagner
2023-01-18  7:36 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 13/16] auth ui: add LDAP realm edit panel Lukas Wagner
2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 14/16] auth ui: add LDAP sync UI Lukas Wagner
2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 15/16] auth ui: add `onlineHelp` for AuthEditLDAP Lukas Wagner
2023-01-18  7:37 ` [pbs-devel] [PATCH v2 proxmox-widget-toolkit 16/16] 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