api: access: add routes for managing AD realms
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com> Reviewed-by: Lukas Wagner <l.wagner@proxmox.com> Tested-by: Lukas Wagner <l.wagner@proxmox.com>
This commit is contained in:
parent
b18a8170cb
commit
c7051f3342
98
pbs-api-types/src/ad.rs
Normal file
98
pbs-api-types/src/ad.rs
Normal file
@ -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>,
|
||||
}
|
@ -130,6 +130,9 @@ pub use openid::*;
|
||||
mod ldap;
|
||||
pub use ldap::*;
|
||||
|
||||
mod ad;
|
||||
pub use ad::*;
|
||||
|
||||
mod remote;
|
||||
pub use remote::*;
|
||||
|
||||
|
348
src/api2/config/access/ad.rs
Normal file
348
src/api2/config/access/ad.rs
Normal file
@ -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);
|
@ -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),
|
||||
|
78
src/auth.rs
78
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,
|
||||
|
Loading…
Reference in New Issue
Block a user