diff --git a/examples/kanidm-ipa-sync b/examples/kanidm-ipa-sync index 53e12cc57..b814f679b 100644 --- a/examples/kanidm-ipa-sync +++ b/examples/kanidm-ipa-sync @@ -26,6 +26,15 @@ ipa_sync_pw = "directory manager password" # The basedn to examine. ipa_sync_base_dn = "dc=ipa,dc=dev,dc=kanidm,dc=com" +# By default Kanidm seperates the primary account password and credentials from +# the unix credential. This allows the unix password to be isolated from the +# account password so that compromise of one doesn't compromise the other. However +# this can be surprising for new users during a migration. This boolean allows the +# user password to be set as the unix password during the migration for consistency +# and then after the migration they are "unlinked". +# +# sync_password_as_unix_password = false + # The sync tool can alter or exclude entries. These are mapped by their syncuuid # (not their ipa-object-uuid). The syncuuid is derived from nsUniqueId in 389-ds. # This is chosen oven DN because DN's can change with modrdn where nsUniqueId is diff --git a/examples/kanidm-ldap-sync b/examples/kanidm-ldap-sync index ad62562af..eaaea1d2b 100644 --- a/examples/kanidm-ldap-sync +++ b/examples/kanidm-ldap-sync @@ -32,6 +32,15 @@ ldap_sync_base_dn = "dc=ldap,dc=dev,dc=kanidm,dc=com" ldap_filter = "(|(objectclass=person)(objectclass=posixgroup))" # ldap_filter = "(cn=\"my value\")" +# By default Kanidm seperates the primary account password and credentials from +# the unix credential. This allows the unix password to be isolated from the +# account password so that compromise of one doesn't compromise the other. However +# this can be surprising for new users during a migration. This boolean allows the +# user password to be set as the unix password during the migration for consistency +# and then after the migration they are "unlinked". +# +# sync_password_as_unix_password = false + # The objectclass used to identify persons to import to Kanidm. # # If not set, defaults to "person" diff --git a/proto/src/constants.rs b/proto/src/constants.rs index bca54adff..de7e3c434 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -172,6 +172,7 @@ pub const ATTR_UID: &str = "uid"; pub const ATTR_UIDNUMBER: &str = "uidnumber"; pub const ATTR_UNIQUE: &str = "unique"; pub const ATTR_UNIX_PASSWORD: &str = "unix_password"; +pub const ATTR_UNIX_PASSWORD_IMPORT: &str = "unix_password_import"; pub const ATTR_USER_AUTH_TOKEN_SESSION: &str = "user_auth_token_session"; pub const ATTR_USERID: &str = "userid"; pub const ATTR_USERPASSWORD: &str = "userpassword"; diff --git a/proto/src/scim_v1.rs b/proto/src/scim_v1.rs index 521178164..a645b3c57 100644 --- a/proto/src/scim_v1.rs +++ b/proto/src/scim_v1.rs @@ -10,7 +10,8 @@ use scim_proto::*; use crate::constants::{ ATTR_ACCOUNT_EXPIRE, ATTR_ACCOUNT_VALID_FROM, ATTR_DESCRIPTION, ATTR_DISPLAYNAME, - ATTR_GIDNUMBER, ATTR_LOGINSHELL, ATTR_MAIL, ATTR_MEMBER, ATTR_NAME, ATTR_SSH_PUBLICKEY, + ATTR_GIDNUMBER, ATTR_LOGINSHELL, ATTR_MAIL, ATTR_MEMBER, ATTR_NAME, ATTR_PASSWORD_IMPORT, + ATTR_SSH_PUBLICKEY, ATTR_UNIX_PASSWORD_IMPORT, ATTR_TOTP_IMPORT }; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)] @@ -138,6 +139,7 @@ pub struct ScimSyncPerson { pub display_name: String, pub gidnumber: Option, pub password_import: Option, + pub unix_password_import: Option, pub totp_import: Vec, pub login_shell: Option, pub mail: Vec, @@ -158,6 +160,7 @@ impl Into for ScimSyncPerson { display_name, gidnumber, password_import, + unix_password_import, totp_import, login_shell, mail, @@ -184,8 +187,9 @@ impl Into for ScimSyncPerson { set_string!(attrs, ATTR_NAME, user_name); set_string!(attrs, ATTR_DISPLAYNAME, display_name); set_option_u32!(attrs, ATTR_GIDNUMBER, gidnumber); - set_option_string!(attrs, "password_import", password_import); - set_multi_complex!(attrs, "totp_import", totp_import); + set_option_string!(attrs, ATTR_PASSWORD_IMPORT, password_import); + set_option_string!(attrs, ATTR_UNIX_PASSWORD_IMPORT, unix_password_import); + set_multi_complex!(attrs, ATTR_TOTP_IMPORT, totp_import); set_option_string!(attrs, ATTR_LOGINSHELL, login_shell); set_multi_complex!(attrs, ATTR_MAIL, mail); set_multi_complex!(attrs, ATTR_SSH_PUBLICKEY, ssh_publickey); // with the underscore diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index 97d4285d6..c5f9c4cb3 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -169,6 +169,7 @@ pub enum Attribute { UidNumber, Unique, UnixPassword, + UnixPasswordImport, UserAuthTokenSession, UserId, UserPassword, @@ -351,6 +352,7 @@ impl TryFrom for Attribute { ATTR_UIDNUMBER => Attribute::UidNumber, ATTR_UNIQUE => Attribute::Unique, ATTR_UNIX_PASSWORD => Attribute::UnixPassword, + ATTR_UNIX_PASSWORD_IMPORT => Attribute::UnixPasswordImport, ATTR_USER_AUTH_TOKEN_SESSION => Attribute::UserAuthTokenSession, ATTR_USERID => Attribute::UserId, ATTR_USERPASSWORD => Attribute::UserPassword, @@ -511,6 +513,7 @@ impl From for &'static str { Attribute::UidNumber => ATTR_UIDNUMBER, Attribute::Unique => ATTR_UNIQUE, Attribute::UnixPassword => ATTR_UNIX_PASSWORD, + Attribute::UnixPasswordImport => ATTR_UNIX_PASSWORD_IMPORT, Attribute::UserAuthTokenSession => ATTR_USER_AUTH_TOKEN_SESSION, Attribute::UserId => ATTR_USERID, Attribute::UserPassword => ATTR_USERPASSWORD, diff --git a/server/lib/src/constants/uuids.rs b/server/lib/src/constants/uuids.rs index 0c8e18389..f2ae373e1 100644 --- a/server/lib/src/constants/uuids.rs +++ b/server/lib/src/constants/uuids.rs @@ -268,6 +268,8 @@ pub const UUID_SCHEMA_CLASS_ACCESS_CONTROL_RECEIVER_ENTRY_MANAGER: Uuid = pub const UUID_SCHEMA_CLASS_ACCESS_CONTROL_TARGET_SCOPE: Uuid = uuid!("00000000-0000-0000-0000-ffff00000155"); pub const UUID_SCHEMA_ATTR_ENTRY_MANAGED_BY: Uuid = uuid!("00000000-0000-0000-0000-ffff00000156"); +pub const UUID_SCHEMA_ATTR_UNIX_PASSWORD_IMPORT: Uuid = + uuid!("00000000-0000-0000-0000-ffff00000157"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. diff --git a/server/lib/src/idm/scim.rs b/server/lib/src/idm/scim.rs index 71eddc7a7..086c5f605 100644 --- a/server/lib/src/idm/scim.rs +++ b/server/lib/src/idm/scim.rs @@ -820,13 +820,11 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { false, ScimAttr::SingleSimple(ScimSimpleAttr::String(value)), ) => Ok(vec![Value::new_utf8(value.clone())]), - ( SyntaxType::Utf8StringInsensitive, false, ScimAttr::SingleSimple(ScimSimpleAttr::String(value)), ) => Ok(vec![Value::new_iutf8(value)]), - ( SyntaxType::Uint32, false, @@ -848,7 +846,6 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { }) }) .map(|value| vec![Value::Uint32(value)]), - (SyntaxType::ReferenceUuid, true, ScimAttr::MultiComplex(values)) => { // In this case, because it's a reference uuid only, despite the multicomplex structure, it's a list of // "external_id" to external_ids. These *might* also be uuids. So we need to use sync_external_id_to_uuid @@ -3191,6 +3188,7 @@ mod tests { "value": "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBENubZikrb8hu+HeVRdZ0pp/VAk2qv4JDbuJhvD0yNdWDL2e3cBbERiDeNPkWx58Q4rVnxkbV1fa8E2waRtT91wAAAAEc3NoOg== testuser@fidokey" } ], + "unix_password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA", "password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA" }, { @@ -3302,6 +3300,7 @@ mod tests { "loginshell": "/bin/sh", "name": "testuser", "password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA", + "unix_password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA", "account_valid_from": "2021-11-28T04:57:55Z", "account_expire": "2023-11-28T04:57:55Z" }, diff --git a/server/lib/src/plugins/cred_import.rs b/server/lib/src/plugins/cred_import.rs index 03a055ce5..d0bcd3a9a 100644 --- a/server/lib/src/plugins/cred_import.rs +++ b/server/lib/src/plugins/cred_import.rs @@ -73,7 +73,7 @@ impl CredImport { let hint = im_pw.split_at(len).0; let id = e.get_display_id(); - error!(%hint, entry_id = %id, "password_import was unable to convert hash format"); + error!(%hint, entry_id = %id, "{} was unable to convert hash format", Attribute::PasswordImport); OperationError::Plugin(PluginError::CredImport( "password_import was unable to convert hash format".to_string(), @@ -126,6 +126,40 @@ impl CredImport { } } + // UNIX PASSWORD IMPORT + if let Some(vs) = e.pop_ava(Attribute::UnixPasswordImport) { + // if there are multiple, fail. + let im_pw = vs.to_utf8_single().ok_or_else(|| { + OperationError::Plugin(PluginError::CredImport( + format!("{} has incorrect value type - should be a single utf8 string", Attribute::UnixPasswordImport), + )) + })?; + + // convert the import_password_string to a password + let pw = Password::try_from(im_pw).map_err(|_| { + let len = if im_pw.len() > 5 { + 4 + } else { + im_pw.len() - 1 + }; + let hint = im_pw.split_at(len).0; + let id = e.get_display_id(); + + error!(%hint, entry_id = %id, "{} was unable to convert hash format", Attribute::UnixPasswordImport); + + OperationError::Plugin(PluginError::CredImport( + "unix_password_import was unable to convert hash format".to_string(), + )) + })?; + + // Unix pw's aren't like primary, we can just splat them here. + let c = Credential::new_from_password(pw); + e.set_ava( + Attribute::UnixPassword, + once(Value::new_credential("primary", c)), + ); + }; + Ok(()) }) } @@ -147,17 +181,29 @@ mod tests { fn test_pre_create_password_import_1() { let preload: Vec> = Vec::new(); - let e: Entry = Entry::unsafe_from_entry_str( - r#"{ - "attrs": { - "class": ["person", "account"], - "name": ["testperson"], - "description": ["testperson"], - "displayname": ["testperson"], - "uuid": ["d2b496bd-8493-47b7-8142-f568b5cf47ee"], - "password_import": ["pbkdf2_sha256$36000$xIEozuZVAoYm$uW1b35DUKyhvQAf1mBqMvoBDcqSD06juzyO/nmyV0+w="] - } - }"#, + let e = entry_init!( + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson")), + ( + Attribute::Description, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::DisplayName, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::Uuid, + Value::Uuid(uuid!("d2b496bd-8493-47b7-8142-f568b5cf47ee")) + ), + ( + Attribute::PasswordImport, + Value::Utf8( + "pbkdf2_sha256$36000$xIEozuZVAoYm$uW1b35DUKyhvQAf1mBqMvoBDcqSD06juzyO/nmyV0+w=" + .into() + ) + ) ); let create = vec![e]; @@ -167,17 +213,22 @@ mod tests { #[test] fn test_modify_password_import_1() { - // Add another uuid to a type - let ea: Entry = Entry::unsafe_from_entry_str( - r#"{ - "attrs": { - "class": ["account", "person"], - "name": ["testperson"], - "description": ["testperson"], - "displayname": ["testperson"], - "uuid": ["d2b496bd-8493-47b7-8142-f568b5cf47ee"] - } - }"#, + let ea = entry_init!( + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson")), + ( + Attribute::Description, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::DisplayName, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::Uuid, + Value::Uuid(uuid!("d2b496bd-8493-47b7-8142-f568b5cf47ee")) + ) ); let preload = vec![ea]; @@ -198,17 +249,22 @@ mod tests { #[test] fn test_modify_password_import_2() { - // Add another uuid to a type - let mut ea: Entry = Entry::unsafe_from_entry_str( - r#"{ - "attrs": { - "class": ["account", "person"], - "name": ["testperson"], - "description": ["testperson"], - "displayname": ["testperson"], - "uuid": ["d2b496bd-8493-47b7-8142-f568b5cf47ee"] - } - }"#, + let mut ea = entry_init!( + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson")), + ( + Attribute::Description, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::DisplayName, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::Uuid, + Value::Uuid(uuid!("d2b496bd-8493-47b7-8142-f568b5cf47ee")) + ) ); let p = CryptoPolicy::minimum(); @@ -236,17 +292,22 @@ mod tests { #[test] fn test_modify_password_import_3_totp() { - // Add another uuid to a type - let mut ea: Entry = Entry::unsafe_from_entry_str( - r#"{ - "attrs": { - "class": ["account", "person"], - "name": ["testperson"], - "description": ["testperson"], - "displayname": ["testperson"], - "uuid": ["d2b496bd-8493-47b7-8142-f568b5cf47ee"] - } - }"#, + let mut ea = entry_init!( + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson")), + ( + Attribute::Description, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::DisplayName, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::Uuid, + Value::Uuid(uuid!("d2b496bd-8493-47b7-8142-f568b5cf47ee")) + ) ); let totp = Totp::generate_secure(TOTP_DEFAULT_STEP); @@ -393,4 +454,50 @@ mod tests { |_| {} ); } + + #[test] + fn test_modify_unix_password_import() { + let ea = entry_init!( + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::PosixAccount.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson")), + ( + Attribute::Description, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::DisplayName, + Value::Utf8("testperson".to_string()) + ), + ( + Attribute::Uuid, + Value::Uuid(uuid!("d2b496bd-8493-47b7-8142-f568b5cf47ee")) + ) + ); + + let preload = vec![ea]; + + run_modify_test!( + Ok(()), + preload, + filter!(f_eq(Attribute::Name, PartialValue::new_iutf8("testperson"))), + ModifyList::new_list(vec![Modify::Present( + Attribute::UnixPasswordImport.into(), + Value::from(IMPORT_HASH) + )]), + None, + |_| {}, + |qs: &mut QueryServerWriteTransaction| { + let e = qs + .internal_search_uuid(uuid!("d2b496bd-8493-47b7-8142-f568b5cf47ee")) + .expect("failed to get entry"); + let c = e + .get_ava_single_credential(Attribute::UnixPassword) + .expect("failed to get unix cred."); + + assert!(matches!(&c.type_, CredentialType::Password(_pw))); + } + ); + } } diff --git a/server/lib/src/schema.rs b/server/lib/src/schema.rs index a1f4934c7..8ba89a143 100644 --- a/server/lib/src/schema.rs +++ b/server/lib/src/schema.rs @@ -1588,6 +1588,24 @@ impl<'a> SchemaWriteTransaction<'a> { }, ); + self.attributes.insert( + Attribute::UnixPasswordImport.into(), + SchemaAttribute { + name: Attribute::UnixPasswordImport.into(), + uuid: UUID_SCHEMA_ATTR_UNIX_PASSWORD_IMPORT, + description: String::from( + "An imported unix password hash from an external system.", + ), + multivalue: false, + unique: false, + phantom: true, + sync_allowed: true, + replicated: false, + index: vec![], + syntax: SyntaxType::Utf8String, + }, + ); + self.attributes.insert( Attribute::TotpImport.into(), SchemaAttribute { diff --git a/tools/iam_migrations/freeipa/src/config.rs b/tools/iam_migrations/freeipa/src/config.rs index f761b8423..e13876b6c 100644 --- a/tools/iam_migrations/freeipa/src/config.rs +++ b/tools/iam_migrations/freeipa/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub ipa_sync_pw: String, pub ipa_sync_base_dn: String, + pub sync_password_as_unix_password: Option, + // pub entry: Option>, #[serde(flatten)] pub entry_map: BTreeMap, diff --git a/tools/iam_migrations/freeipa/src/main.rs b/tools/iam_migrations/freeipa/src/main.rs index 97d343f2b..1a8a74335 100644 --- a/tools/iam_migrations/freeipa/src/main.rs +++ b/tools/iam_migrations/freeipa/src/main.rs @@ -456,6 +456,9 @@ async fn run_sync( entries, &sync_config.entry_map, is_initialise, + sync_config + .sync_password_as_unix_password + .unwrap_or_default(), ) .await { @@ -521,6 +524,7 @@ async fn process_ipa_sync_result( ldap_entries: Vec, entry_config_map: &BTreeMap, is_initialise: bool, + sync_password_as_unix_password: bool, ) -> Result, ()> { // Because of how TOTP works with freeipa it's a soft referral from // the totp toward the user. This means if a TOTP is added or removed @@ -758,7 +762,7 @@ async fn process_ipa_sync_result( let totp = totp_entries.get(&dn).unwrap_or(&empty_slice); - match ipa_to_scim_entry(e, &e_config, totp) { + match ipa_to_scim_entry(e, &e_config, totp, sync_password_as_unix_password) { Ok(Some(e)) => Some(Ok(e)), Ok(None) => None, Err(()) => Some(Err(())), @@ -767,12 +771,11 @@ async fn process_ipa_sync_result( .collect::, _>>() } -// TODO: Allow re-map of uuid -> uuid - fn ipa_to_scim_entry( sync_entry: LdapSyncReplEntry, entry_config: &EntryConfig, totp: &[LdapSyncReplEntry], + sync_password_as_unix_password: bool, ) -> Result, ()> { debug!("{:#?}", sync_entry); @@ -857,6 +860,12 @@ fn ipa_to_scim_entry( // pw hash formats in 389-ds we don't support! .or_else(|| entry.remove_ava_single(Attribute::UserPassword.as_ref())); + let unix_password_import = if sync_password_as_unix_password { + password_import.clone() + } else { + None + }; + let mail: Vec<_> = entry .remove_ava(Attribute::Mail.as_ref()) .map(|set| { @@ -928,6 +937,7 @@ fn ipa_to_scim_entry( display_name, gidnumber, password_import, + unix_password_import, totp_import, login_shell, mail, diff --git a/tools/iam_migrations/ldap/src/config.rs b/tools/iam_migrations/ldap/src/config.rs index e6827bd77..cd61646e9 100644 --- a/tools/iam_migrations/ldap/src/config.rs +++ b/tools/iam_migrations/ldap/src/config.rs @@ -72,6 +72,8 @@ pub struct Config { pub ldap_filter: LdapFilter, + pub sync_password_as_unix_password: Option, + #[serde(default = "person_objectclass")] pub person_objectclass: String, #[serde(default = "person_attr_user_name")] diff --git a/tools/iam_migrations/ldap/src/main.rs b/tools/iam_migrations/ldap/src/main.rs index 75238e86c..f173805b0 100644 --- a/tools/iam_migrations/ldap/src/main.rs +++ b/tools/iam_migrations/ldap/src/main.rs @@ -553,6 +553,15 @@ fn ldap_to_scim_entry( password_import }; + let unix_password_import = if sync_config + .sync_password_as_unix_password + .unwrap_or_default() + { + password_import.clone() + } else { + None + }; + let mail: Vec<_> = entry .remove_ava(&sync_config.person_attr_mail) .map(|set| { @@ -610,6 +619,7 @@ fn ldap_to_scim_entry( display_name, gidnumber, password_import, + unix_password_import, totp_import, login_shell, mail,