From a84b57f09097bb1f49a7d23bd26f314e77dc204c Mon Sep 17 00:00:00 2001 From: David Mulder Date: Wed, 9 Oct 2024 15:45:14 -0600 Subject: [PATCH] Implement logon name script mapping Signed-off-by: David Mulder --- platform/debian/himmelblau.conf.example | 8 ++ src/common/src/config.rs | 115 ++++++++++++++++++++++++ src/common/src/idprovider/himmelblau.rs | 12 ++- src/config/himmelblau.conf.example | 8 ++ src/glue/src/unix_config.rs | 35 +++----- src/nss/src/implementation.rs | 9 +- src/pam/src/pam/mod.rs | 6 +- src/sso/src/lib.rs | 1 + 8 files changed, 157 insertions(+), 37 deletions(-) diff --git a/platform/debian/himmelblau.conf.example b/platform/debian/himmelblau.conf.example index 48f5176..5ef1892 100644 --- a/platform/debian/himmelblau.conf.example +++ b/platform/debian/himmelblau.conf.example @@ -69,6 +69,14 @@ local_groups = users # logon_script = # logon_token_scopes = # +# In some cases, mapping the UPN to the CN may be impractical. The following +# option executes the specifed filename (any executable), which MUST accept +# a single argument which will be EITHER the upn, or the mapped name. The +# executable MUST print a single response to stdout, which will be either the +# mapped name, or the upn, respectively. This executable must map the name +# bi-directionally. +# name_mapping_script = +# # authority_host = login.microsoftonline.com # # The location of the cache database diff --git a/src/common/src/config.rs b/src/common/src/config.rs index 3a3e21f..33cad8f 100644 --- a/src/common/src/config.rs +++ b/src/common/src/config.rs @@ -15,10 +15,14 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use crate::unix_passwd::parse_etc_passwd; use configparser::ini::Ini; use std::fmt; +use std::fs::File; use std::io::Error; +use std::io::Read; use std::path::PathBuf; +use std::process::Command; use tracing::{debug, error}; use crate::constants::{ @@ -476,6 +480,28 @@ impl HimmelblauConfig { None => vec![], } } + + pub fn get_name_mapping_script(&self) -> Option { + self.config.get("global", "name_mapping_script") + } + + pub fn map_upn_to_name(&self, account_id: &str) -> String { + map_upn_to_name( + account_id, + &self.get_name_mapping_script(), + self.get_cn_name_mapping(), + &self.get_configured_domains(), + ) + } + + pub fn map_name_to_upn(&self, account_id: &str) -> String { + map_name_to_upn( + account_id, + &self.get_name_mapping_script(), + self.get_cn_name_mapping(), + &self.get_configured_domains(), + ) + } } impl fmt::Debug for HimmelblauConfig { @@ -483,3 +509,92 @@ impl fmt::Debug for HimmelblauConfig { write!(f, "{:?}", self.config) } } + +// This function maps a upn to a local username. If cn name mapping is enabled, +// this will map to the CN. Otherwise it will attempt to map using the name +// mapping script. If no name mapping is enabled, it will respond with the +// supplied UPN (no name mapping). +pub fn map_upn_to_name( + account_id: &str, + name_mapping_script: &Option, + cn_name_mapping: bool, + domains: &[String], +) -> String { + // The name mapping script is expected to convert the input name to a UPN + // if a name is supplied, or to a name if the UPN is supplied. + if let Some(name_mapping_script) = &name_mapping_script { + let output = Command::new(name_mapping_script).arg(account_id).output(); + + match output { + Ok(output) => { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } else { + eprintln!("Script execution failed with error: {:?}", output.status); + } + } + Err(e) => { + eprintln!("Failed to execute script: {}", e); + } + } + } + if cn_name_mapping && account_id.contains('@') && !domains.is_empty() { + if let Some((cn, domain)) = split_username(account_id) { + // We can only name map the default domain + if domain == domains[0] { + return cn.to_string(); + } + } + } + account_id.to_string() +} + +// This function attempts to convert a username to a valid UPN. On failure it +// will leave the name as-is, and respond with the original input. Himmelblau +// will reject the authentication attempt if the username isn't a valid UPN. +pub fn map_name_to_upn( + account_id: &str, + name_mapping_script: &Option, + cn_name_mapping: bool, + domains: &[String], +) -> String { + // Make sure this account_id isn't a local user + let mut contents = vec![]; + if let Ok(mut file) = File::open("/etc/passwd") { + let _ = file.read_to_end(&mut contents); + } + let local_users = parse_etc_passwd(contents.as_slice()).unwrap_or_default(); + if local_users + .into_iter() + .map(|u| u.name.to_string()) + .collect::>() + .contains(&account_id.to_string()) + { + return account_id.to_string(); + } + + // The name mapping script is expected to convert the input name to a UPN + // if a name is supplied, or to a name if the UPN is supplied. + if !account_id.contains('@') { + if let Some(name_mapping_script) = &name_mapping_script { + let output = Command::new(name_mapping_script).arg(account_id).output(); + + match output { + Ok(output) => { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } else { + eprintln!("Script execution failed with error: {:?}", output.status); + } + } + Err(e) => { + eprintln!("Failed to execute script: {}", e); + } + } + } + } + if cn_name_mapping && !account_id.contains('@') && !domains.is_empty() { + return format!("{}@{}", account_id, domains[0]); + } + account_id.to_string() +} diff --git a/src/common/src/idprovider/himmelblau.rs b/src/common/src/idprovider/himmelblau.rs index 87ec361..355b48a 100644 --- a/src/common/src/idprovider/himmelblau.rs +++ b/src/common/src/idprovider/himmelblau.rs @@ -608,6 +608,8 @@ impl IdProvider for HimmelblauProvider { // Generate a UserToken, with invalid uuid. We can // only fetch this from an authenticated token. let config = self.config.read().await; + // Perform logon name mapping + let mapped_name = config.map_upn_to_name(&account_id); let gidnumber = match config.get_id_attr_map() { // If Uuid mapping is enabled, bail out now. // We can only provide a valid idmapping with @@ -625,14 +627,14 @@ impl IdProvider for HimmelblauProvider { }; let fake_uuid = Uuid::new_v4(); let groups = vec![GroupToken { - name: account_id.clone(), + name: mapped_name.clone(), spn: account_id.clone(), uuid: fake_uuid, gidnumber, }]; let config = self.config.read().await; return Ok(UserToken { - name: account_id.clone(), + name: mapped_name.clone(), spn: account_id.clone(), uuid: fake_uuid, gidnumber, @@ -1254,6 +1256,8 @@ impl HimmelblauProvider { return Err(IdpError::BadRequest); } }; + // Perform logon name mapping + let mapped_name = config.map_upn_to_name(&spn); let uuid = match value.uuid() { Ok(uuid) => uuid, Err(e) => { @@ -1304,14 +1308,14 @@ impl HimmelblauProvider { // Add the fake primary group groups.push(GroupToken { - name: spn.clone(), + name: mapped_name.clone(), spn: spn.clone(), uuid, gidnumber, }); Ok(UserToken { - name: spn.clone(), + name: mapped_name.clone(), spn: spn.clone(), uuid, gidnumber, diff --git a/src/config/himmelblau.conf.example b/src/config/himmelblau.conf.example index 7a6ad34..62f5618 100644 --- a/src/config/himmelblau.conf.example +++ b/src/config/himmelblau.conf.example @@ -69,6 +69,14 @@ # logon_script = # logon_token_scopes = # +# In some cases, mapping the UPN to the CN may be impractical. The following +# option executes the specifed filename (any executable), which MUST accept +# a single argument which will be EITHER the upn, or the mapped name. The +# executable MUST print a single response to stdout, which will be either the +# mapped name, or the upn, respectively. This executable must map the name +# bi-directionally. +# name_mapping_script = +# # authority_host = login.microsoftonline.com # # The location of the cache database diff --git a/src/glue/src/unix_config.rs b/src/glue/src/unix_config.rs index d1b550f..fbaca86 100644 --- a/src/glue/src/unix_config.rs +++ b/src/glue/src/unix_config.rs @@ -15,13 +15,10 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use himmelblau_unix_common::config::HimmelblauConfig; +use himmelblau_unix_common::config::{map_name_to_upn, HimmelblauConfig}; use himmelblau_unix_common::constants::{ DEFAULT_CONN_TIMEOUT, DEFAULT_HELLO_PIN_MIN_LEN, DEFAULT_SOCK_PATH, }; -use himmelblau_unix_common::unix_passwd::parse_etc_passwd; -use std::fs::File; -use std::io::Read; pub struct KanidmUnixdConfig { pub domains: Vec, @@ -29,6 +26,7 @@ pub struct KanidmUnixdConfig { pub sock_path: String, pub cn_name_mapping: bool, pub hello_pin_min_length: usize, + name_mapping_script: Option, } impl KanidmUnixdConfig { @@ -39,6 +37,7 @@ impl KanidmUnixdConfig { unix_sock_timeout: DEFAULT_CONN_TIMEOUT * 2, cn_name_mapping: false, hello_pin_min_length: DEFAULT_HELLO_PIN_MIN_LEN, + name_mapping_script: None, } } @@ -50,29 +49,17 @@ impl KanidmUnixdConfig { unix_sock_timeout: config.get_connection_timeout() * 2, cn_name_mapping: config.get_cn_name_mapping(), hello_pin_min_length: config.get_hello_pin_min_length(), + name_mapping_script: config.get_name_mapping_script(), }) } - pub fn map_cn_name(&self, account_id: &str) -> String { - // Make sure this account_id isn't a local user - let mut contents = vec![]; - if let Ok(mut file) = File::open("/etc/passwd") { - let _ = file.read_to_end(&mut contents); - } - let local_users = parse_etc_passwd(contents.as_slice()).unwrap_or_default(); - if local_users - .into_iter() - .map(|u| u.name.to_string()) - .collect::>() - .contains(&account_id.to_string()) - { - return account_id.to_string(); - } - - if self.cn_name_mapping && !account_id.contains('@') && !self.domains.is_empty() { - return format!("{}@{}", account_id, self.domains[0]); - } - account_id.to_string() + pub fn map_name_to_upn(&self, account_id: &str) -> String { + map_name_to_upn( + account_id, + &self.name_mapping_script, + self.cn_name_mapping, + &self.domains, + ) } } diff --git a/src/nss/src/implementation.rs b/src/nss/src/implementation.rs index d238e2e..27a7019 100644 --- a/src/nss/src/implementation.rs +++ b/src/nss/src/implementation.rs @@ -83,7 +83,7 @@ impl PasswdHooks for HimmelblauPasswd { return Response::Unavail; } }; - let name = cfg.map_cn_name(&name); + let name = cfg.map_name_to_upn(&name); let req = ClientRequest::NssAccountByName(name.clone()); let mut daemon_client = match DaemonClientBlocking::new(cfg.sock_path.as_str()) { Ok(dc) => dc, @@ -96,11 +96,8 @@ impl PasswdHooks for HimmelblauPasswd { .call_and_wait(&req, cfg.unix_sock_timeout) .map(|r| match r { ClientResponse::NssAccount(opt) => opt - .map(|nu| { - let mut passwd = passwd_from_nssuser(nu); - passwd.name = name; - Response::Success(passwd) - }) + .map(passwd_from_nssuser) + .map(Response::Success) .unwrap_or_else(|| Response::NotFound), _ => Response::NotFound, }) diff --git a/src/pam/src/pam/mod.rs b/src/pam/src/pam/mod.rs index 4572c85..8f1097f 100755 --- a/src/pam/src/pam/mod.rs +++ b/src/pam/src/pam/mod.rs @@ -301,7 +301,7 @@ impl PamHooks for PamKanidm { Ok(cfg) => cfg, Err(e) => return e, }; - let account_id = cfg.map_cn_name(&account_id); + let account_id = cfg.map_name_to_upn(&account_id); let req = ClientRequest::PamAccountAllowed(account_id); // PamResultCode::PAM_IGNORE @@ -371,7 +371,7 @@ impl PamHooks for PamKanidm { Ok(cfg) => cfg, Err(e) => return e, }; - let account_id = cfg.map_cn_name(&account_id); + let account_id = cfg.map_name_to_upn(&account_id); let mut timeout = cfg.unix_sock_timeout; let mut daemon_client = match DaemonClientBlocking::new(cfg.sock_path.as_str()) { @@ -573,7 +573,7 @@ impl PamHooks for PamKanidm { Ok(cfg) => cfg, Err(e) => return e, }; - let account_id = cfg.map_cn_name(&account_id); + let account_id = cfg.map_name_to_upn(&account_id); let req = ClientRequest::PamAccountBeginSession(account_id); let mut daemon_client = match DaemonClientBlocking::new(cfg.sock_path.as_str()) { diff --git a/src/sso/src/lib.rs b/src/sso/src/lib.rs index e69de29..8b13789 100644 --- a/src/sso/src/lib.rs +++ b/src/sso/src/lib.rs @@ -0,0 +1 @@ +