[pbs-devel] [PATCH proxmox-backup v3 07/13] api: access: add routes for managing AD realms
Christoph Heiss
c.heiss at proxmox.com
Fri Jan 12 17:16:02 CET 2024
Signed-off-by: Christoph Heiss <c.heiss at proxmox.com>
---
pbs-api-types/src/ad.rs | 98 ++++++++++
pbs-api-types/src/lib.rs | 3 +
src/api2/config/access/ad.rs | 348 ++++++++++++++++++++++++++++++++++
src/api2/config/access/mod.rs | 2 +
src/auth.rs | 78 +++++++-
5 files changed, 528 insertions(+), 1 deletion(-)
create mode 100644 pbs-api-types/src/ad.rs
create mode 100644 src/api2/config/access/ad.rs
diff --git a/pbs-api-types/src/ad.rs b/pbs-api-types/src/ad.rs
new file mode 100644
index 00000000..910571a0
--- /dev/null
+++ b/pbs-api-types/src/ad.rs
@@ -0,0 +1,98 @@
+use serde::{Deserialize, Serialize};
+
+use proxmox_schema::{api, Updater};
+
+use super::{
+ LdapMode, LDAP_DOMAIN_SCHEMA, REALM_ID_SCHEMA, SINGLE_LINE_COMMENT_SCHEMA,
+ SYNC_ATTRIBUTES_SCHEMA, SYNC_DEFAULTS_STRING_SCHEMA, USER_CLASSES_SCHEMA,
+};
+
+#[api(
+ properties: {
+ "realm": {
+ schema: REALM_ID_SCHEMA,
+ },
+ "comment": {
+ optional: true,
+ schema: SINGLE_LINE_COMMENT_SCHEMA,
+ },
+ "verify": {
+ optional: true,
+ default: false,
+ },
+ "sync-defaults-options": {
+ schema: SYNC_DEFAULTS_STRING_SCHEMA,
+ optional: true,
+ },
+ "sync-attributes": {
+ schema: SYNC_ATTRIBUTES_SCHEMA,
+ optional: true,
+ },
+ "user-classes" : {
+ optional: true,
+ schema: USER_CLASSES_SCHEMA,
+ },
+ "base-dn" : {
+ schema: LDAP_DOMAIN_SCHEMA,
+ optional: true,
+ },
+ "bind-dn" : {
+ schema: LDAP_DOMAIN_SCHEMA,
+ optional: true,
+ }
+ },
+)]
+#[derive(Serialize, Deserialize, Updater, Clone)]
+#[serde(rename_all = "kebab-case")]
+/// AD realm configuration properties.
+pub struct AdRealmConfig {
+ #[updater(skip)]
+ pub realm: String,
+ /// AD server address
+ pub server1: String,
+ /// Fallback AD server address
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub server2: Option<String>,
+ /// AD server Port
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub port: Option<u16>,
+ /// Base domain name. Users are searched under this domain using a `subtree search`.
+ /// Expected to be set only internally to `defaultNamingContext` of the AD server, but can be
+ /// overridden if the need arises.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub base_dn: Option<String>,
+ /// Comment
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub comment: Option<String>,
+ /// Connection security
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub mode: Option<LdapMode>,
+ /// Verify server certificate
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub verify: Option<bool>,
+ /// CA certificate to use for the server. The path can point to
+ /// either a file, or a directory. If it points to a file,
+ /// the PEM-formatted X.509 certificate stored at the path
+ /// will be added as a trusted certificate.
+ /// If the path points to a directory,
+ /// the directory replaces the system's default certificate
+ /// store at `/etc/ssl/certs` - Every file in the directory
+ /// will be loaded as a trusted certificate.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub capath: Option<String>,
+ /// Bind domain to use for looking up users
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub bind_dn: Option<String>,
+ /// Custom LDAP search filter for user sync
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub filter: Option<String>,
+ /// Default options for AD sync
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub sync_defaults_options: Option<String>,
+ /// List of LDAP attributes to sync from AD to user config
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub sync_attributes: Option<String>,
+ /// User ``objectClass`` classes to sync
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_classes: Option<String>,
+}
diff --git a/pbs-api-types/src/lib.rs b/pbs-api-types/src/lib.rs
index 795ff2a6..df3a360c 100644
--- a/pbs-api-types/src/lib.rs
+++ b/pbs-api-types/src/lib.rs
@@ -113,6 +113,9 @@ pub use openid::*;
mod ldap;
pub use ldap::*;
+mod ad;
+pub use ad::*;
+
mod remote;
pub use remote::*;
diff --git a/src/api2/config/access/ad.rs b/src/api2/config/access/ad.rs
new file mode 100644
index 00000000..c202291a
--- /dev/null
+++ b/src/api2/config/access/ad.rs
@@ -0,0 +1,348 @@
+use anyhow::{bail, format_err, Error};
+use hex::FromHex;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use proxmox_ldap::{Config as LdapConfig, Connection};
+use proxmox_router::{Permission, Router, RpcEnvironment};
+use proxmox_schema::{api, param_bail};
+
+use pbs_api_types::{
+ AdRealmConfig, AdRealmConfigUpdater, PRIV_REALM_ALLOCATE, PRIV_SYS_AUDIT,
+ PROXMOX_CONFIG_DIGEST_SCHEMA, REALM_ID_SCHEMA,
+};
+
+use pbs_config::domains;
+
+use crate::{auth::AdAuthenticator, auth_helpers};
+
+#[api(
+ input: {
+ properties: {},
+ },
+ returns: {
+ description: "List of configured AD realms.",
+ type: Array,
+ items: { type: AdRealmConfig },
+ },
+ access: {
+ permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+ },
+)]
+/// List configured AD realms
+pub fn list_ad_realms(
+ _param: Value,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<AdRealmConfig>, Error> {
+ let (config, digest) = domains::config()?;
+
+ let list = config.convert_to_typed_array("ad")?;
+
+ rpcenv["digest"] = hex::encode(digest).into();
+
+ Ok(list)
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ config: {
+ type: AdRealmConfig,
+ flatten: true,
+ },
+ password: {
+ description: "AD bind password",
+ optional: true,
+ }
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+ },
+)]
+/// Create a new AD realm
+pub async fn create_ad_realm(
+ mut config: AdRealmConfig,
+ password: Option<String>,
+) -> Result<(), Error> {
+ let domain_config_lock = domains::lock_config()?;
+
+ let (mut domains, _digest) = domains::config()?;
+
+ if domains::exists(&domains, &config.realm) {
+ param_bail!("realm", "realm '{}' already exists.", config.realm);
+ }
+
+ let mut ldap_config =
+ AdAuthenticator::api_type_to_config_with_password(&config, password.clone())?;
+
+ if config.base_dn.is_none() {
+ ldap_config.base_dn = retrieve_default_naming_context(&ldap_config).await?;
+ config.base_dn = Some(ldap_config.base_dn.clone());
+ }
+
+ let conn = Connection::new(ldap_config);
+ conn.check_connection()
+ .await
+ .map_err(|e| format_err!("{e:#}"))?;
+
+ if let Some(password) = password {
+ auth_helpers::store_ldap_bind_password(&config.realm, &password, &domain_config_lock)?;
+ }
+
+ domains.set_data(&config.realm, "ad", &config)?;
+
+ domains::save_config(&domains)?;
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ realm: {
+ schema: REALM_ID_SCHEMA,
+ },
+ },
+ },
+ returns: { type: AdRealmConfig },
+ access: {
+ permission: &Permission::Privilege(&["access", "domains"], PRIV_SYS_AUDIT, false),
+ },
+)]
+/// Read the AD realm configuration
+pub fn read_ad_realm(
+ realm: String,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<AdRealmConfig, Error> {
+ let (domains, digest) = domains::config()?;
+
+ let config = domains.lookup("ad", &realm)?;
+
+ rpcenv["digest"] = hex::encode(digest).into();
+
+ Ok(config)
+}
+
+#[api()]
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Deletable property name
+pub enum DeletableProperty {
+ /// Fallback AD server address
+ Server2,
+ /// Port
+ Port,
+ /// Comment
+ Comment,
+ /// Verify server certificate
+ Verify,
+ /// Mode (ldap, ldap+starttls or ldaps),
+ Mode,
+ /// Bind Domain
+ BindDn,
+ /// LDAP bind passwort
+ Password,
+ /// User filter
+ Filter,
+ /// Default options for user sync
+ SyncDefaultsOptions,
+ /// user attributes to sync with AD attributes
+ SyncAttributes,
+ /// User classes
+ UserClasses,
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ realm: {
+ schema: REALM_ID_SCHEMA,
+ },
+ update: {
+ type: AdRealmConfigUpdater,
+ flatten: true,
+ },
+ password: {
+ description: "AD bind password",
+ optional: true,
+ },
+ delete: {
+ description: "List of properties to delete.",
+ type: Array,
+ optional: true,
+ items: {
+ type: DeletableProperty,
+ }
+ },
+ digest: {
+ optional: true,
+ schema: PROXMOX_CONFIG_DIGEST_SCHEMA,
+ },
+ },
+ },
+ returns: { type: AdRealmConfig },
+ access: {
+ permission: &Permission::Privilege(&["access", "domains"], PRIV_REALM_ALLOCATE, false),
+ },
+)]
+/// Update an AD realm configuration
+pub async fn update_ad_realm(
+ realm: String,
+ update: AdRealmConfigUpdater,
+ password: Option<String>,
+ delete: Option<Vec<DeletableProperty>>,
+ digest: Option<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: AdRealmConfig = domains.lookup("ad", &realm)?;
+
+ if let Some(delete) = delete {
+ for delete_prop in delete {
+ match delete_prop {
+ DeletableProperty::Server2 => {
+ config.server2 = None;
+ }
+ DeletableProperty::Comment => {
+ config.comment = None;
+ }
+ DeletableProperty::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)?;
+ }
+ DeletableProperty::Filter => {
+ config.filter = None;
+ }
+ DeletableProperty::SyncDefaultsOptions => {
+ config.sync_defaults_options = None;
+ }
+ DeletableProperty::SyncAttributes => {
+ config.sync_attributes = None;
+ }
+ DeletableProperty::UserClasses => {
+ config.user_classes = None;
+ }
+ }
+ }
+ }
+
+ if let Some(server1) = update.server1 {
+ config.server1 = server1;
+ }
+
+ if let Some(server2) = update.server2 {
+ config.server2 = Some(server2);
+ }
+
+ if let Some(port) = update.port {
+ config.port = Some(port);
+ }
+
+ if let Some(base_dn) = update.base_dn {
+ config.base_dn = Some(base_dn);
+ }
+
+ if let Some(comment) = update.comment {
+ let comment = comment.trim().to_string();
+ if comment.is_empty() {
+ config.comment = None;
+ } else {
+ config.comment = Some(comment);
+ }
+ }
+
+ if let Some(mode) = update.mode {
+ config.mode = Some(mode);
+ }
+
+ if let Some(verify) = update.verify {
+ config.verify = Some(verify);
+ }
+
+ if let Some(bind_dn) = update.bind_dn {
+ config.bind_dn = Some(bind_dn);
+ }
+
+ if let Some(filter) = update.filter {
+ config.filter = Some(filter);
+ }
+
+ if let Some(sync_defaults_options) = update.sync_defaults_options {
+ config.sync_defaults_options = Some(sync_defaults_options);
+ }
+
+ if let Some(sync_attributes) = update.sync_attributes {
+ config.sync_attributes = Some(sync_attributes);
+ }
+
+ if let Some(user_classes) = update.user_classes {
+ config.user_classes = Some(user_classes);
+ }
+
+ let mut ldap_config = if password.is_some() {
+ AdAuthenticator::api_type_to_config_with_password(&config, password.clone())?
+ } else {
+ AdAuthenticator::api_type_to_config(&config)?
+ };
+
+ if config.base_dn.is_none() {
+ ldap_config.base_dn = retrieve_default_naming_context(&ldap_config).await?;
+ config.base_dn = Some(ldap_config.base_dn.clone());
+ }
+
+ let conn = Connection::new(ldap_config);
+ conn.check_connection()
+ .await
+ .map_err(|e| format_err!("{e:#}"))?;
+
+ if let Some(password) = password {
+ auth_helpers::store_ldap_bind_password(&realm, &password, &domain_config_lock)?;
+ }
+
+ domains.set_data(&realm, "ad", &config)?;
+
+ domains::save_config(&domains)?;
+
+ Ok(())
+}
+
+async fn retrieve_default_naming_context(ldap_config: &LdapConfig) -> Result<String, Error> {
+ let conn = Connection::new(ldap_config.clone());
+ match conn.retrieve_root_dse_attr("defaultNamingContext").await {
+ Ok(base_dn) if !base_dn.is_empty() => Ok(base_dn[0].clone()),
+ Ok(_) => bail!("server did not provide `defaultNamingContext`"),
+ Err(err) => bail!("failed to determine base_dn: {err}"),
+ }
+}
+
+const ITEM_ROUTER: Router = Router::new()
+ .get(&API_METHOD_READ_AD_REALM)
+ .put(&API_METHOD_UPDATE_AD_REALM)
+ .delete(&super::ldap::API_METHOD_DELETE_LDAP_REALM);
+
+pub const ROUTER: Router = Router::new()
+ .get(&API_METHOD_LIST_AD_REALMS)
+ .post(&API_METHOD_CREATE_AD_REALM)
+ .match_all("realm", &ITEM_ROUTER);
diff --git a/src/api2/config/access/mod.rs b/src/api2/config/access/mod.rs
index 614bd5e6..b551e662 100644
--- a/src/api2/config/access/mod.rs
+++ b/src/api2/config/access/mod.rs
@@ -2,12 +2,14 @@ use proxmox_router::list_subdirs_api_method;
use proxmox_router::{Router, SubdirMap};
use proxmox_sortable_macro::sortable;
+pub mod ad;
pub mod ldap;
pub mod openid;
pub mod tfa;
#[sortable]
const SUBDIRS: SubdirMap = &sorted!([
+ ("ad", &ad::ROUTER),
("ldap", &ldap::ROUTER),
("openid", &openid::ROUTER),
("tfa", &tfa::ROUTER),
diff --git a/src/auth.rs b/src/auth.rs
index 04fb3a1d..745252ec 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -19,7 +19,9 @@ use proxmox_auth_api::Keyring;
use proxmox_ldap::{Config, Connection, ConnectionMode};
use proxmox_tfa::api::{OpenUserChallengeData, TfaConfig};
-use pbs_api_types::{LdapMode, LdapRealmConfig, OpenIdRealmConfig, RealmRef, Userid, UsernameRef};
+use pbs_api_types::{
+ AdRealmConfig, LdapMode, LdapRealmConfig, OpenIdRealmConfig, RealmRef, Userid, UsernameRef,
+};
use pbs_buildcfg::configdir;
use crate::auth_helpers;
@@ -202,6 +204,80 @@ impl LdapAuthenticator {
}
}
+pub struct AdAuthenticator {
+ config: AdRealmConfig,
+}
+
+impl AdAuthenticator {
+ pub fn api_type_to_config(config: &AdRealmConfig) -> Result<Config, Error> {
+ Self::api_type_to_config_with_password(
+ config,
+ auth_helpers::get_ldap_bind_password(&config.realm)?,
+ )
+ }
+
+ pub fn api_type_to_config_with_password(
+ config: &AdRealmConfig,
+ password: Option<String>,
+ ) -> Result<Config, Error> {
+ let mut servers = vec![config.server1.clone()];
+ if let Some(server) = &config.server2 {
+ servers.push(server.clone());
+ }
+
+ let (ca_store, trusted_cert) = lookup_ca_store_or_cert_path(config.capath.as_deref());
+
+ Ok(Config {
+ servers,
+ port: config.port,
+ user_attr: "sAMAccountName".to_owned(),
+ base_dn: config.base_dn.clone().unwrap_or_default(),
+ bind_dn: config.bind_dn.clone(),
+ bind_password: password,
+ tls_mode: ldap_to_conn_mode(config.mode.unwrap_or_default()),
+ verify_certificate: config.verify.unwrap_or_default(),
+ additional_trusted_certificates: trusted_cert,
+ certificate_store_path: ca_store,
+ })
+ }
+}
+
+impl Authenticator for AdAuthenticator {
+ /// Authenticate user in AD realm
+ fn authenticate_user<'a>(
+ &'a self,
+ username: &'a UsernameRef,
+ password: &'a str,
+ _client_ip: Option<&'a IpAddr>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
+ Box::pin(async move {
+ let ldap_config = Self::api_type_to_config(&self.config)?;
+ let ldap = Connection::new(ldap_config);
+ ldap.authenticate_user(username.as_str(), password).await?;
+ Ok(())
+ })
+ }
+
+ fn store_password(
+ &self,
+ _username: &UsernameRef,
+ _password: &str,
+ _client_ip: Option<&IpAddr>,
+ ) -> Result<(), Error> {
+ http_bail!(
+ NOT_IMPLEMENTED,
+ "storing passwords is not implemented for Active Directory realms"
+ );
+ }
+
+ fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> {
+ http_bail!(
+ NOT_IMPLEMENTED,
+ "removing passwords is not implemented for Active Directory realms"
+ );
+ }
+}
+
fn ldap_to_conn_mode(mode: LdapMode) -> ConnectionMode {
match mode {
LdapMode::Ldap => ConnectionMode::Ldap,
--
2.42.0
More information about the pbs-devel
mailing list