diff --git a/book/src/developers/designs/application_passwords.md b/book/src/developers/designs/application_passwords.md index 01a382a5e..483683b00 100644 --- a/book/src/developers/designs/application_passwords.md +++ b/book/src/developers/designs/application_passwords.md @@ -22,8 +22,8 @@ based authentication and remove the ability to bind with the UNIX password. # User experience The administrator configures two applications on their Kanidm instance. One is "mail" for a generic -SMTP+IMAP service. The other is HTTP basic auth to a legacy web server. Then the administrator -configures which users will be able to use application passwords for each application. +SMTP+IMAP service. The other is HTTP basic auth to a legacy web server. Applications have a linked +group to determine which users will be able to use application passwords for each application. The mail services and web services are configured to point to Kanidm's LDAP gateway with a customized search base DN. @@ -79,10 +79,10 @@ A new class for applications will be added. Each application will have a single only members of this group will be able to bind with the application password for the associated application. -Creating a new application will not create an associated group automatically. It will be the -administrator who will configure the association after creating the application and optionally a new -group. It will be possible to associate `idm_all_persons` to an application. Removing an application -will not delete the associated group nor its members. +Creating a new application will not create an associated group automatically, an existing group +must be provided. It will be possible to associate `idm_all_persons` to an application. Removing +an application will not delete the associated group nor its members. It will be possible to change +the linked group after creation. When users are removed from a group associated to an application all of their application passwords for the application will be disabled. @@ -150,6 +150,9 @@ We do not need temporary locks or holds - users can delete and recreate as neede Since application passwords are related to applications, on delete of an application all entries that have a bound application password should be removed from user accounts. +Trying to delete a group linked to an application will raise an error showing the user that +something still requires it. + ### Access controls The "Application administrators" group will manage the applications, and applications will allow diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 3e3e6cff2..56e36b8c5 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -691,12 +691,10 @@ impl KanidmClient { } #[cfg(any(test, debug_assertions))] - if !matching { - if !std::env::var("KANIDM_DEV_YOLO").is_ok() { - eprintln!("⚠️ You're in debug/dev mode, so we're going to quit here."); - eprintln!("If you really must do this, set KANIDM_DEV_YOLO=1"); - std::process::exit(1); - } + if !matching && std::env::var("KANIDM_DEV_YOLO").is_err() { + eprintln!("⚠️ You're in debug/dev mode, so we're going to quit here."); + eprintln!("If you really must do this, set KANIDM_DEV_YOLO=1"); + std::process::exit(1); } // Check is done once, mark as no longer needing to occur diff --git a/libs/crypto/src/lib.rs b/libs/crypto/src/lib.rs index bf5605480..103702a12 100644 --- a/libs/crypto/src/lib.rs +++ b/libs/crypto/src/lib.rs @@ -108,7 +108,7 @@ impl Into<OperationError> for CryptoError { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)] #[allow(non_camel_case_types)] pub enum DbPasswordV1 { TPM_ARGON2ID { diff --git a/proto/src/constants.rs b/proto/src/constants.rs index a43fa2ab1..9794ca693 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -67,6 +67,7 @@ pub const ATTR_ACP_RECEIVER: &str = "acp_receiver"; pub const ATTR_ACP_SEARCH_ATTR: &str = "acp_search_attr"; pub const ATTR_ACP_TARGET_SCOPE: &str = "acp_targetscope"; pub const ATTR_API_TOKEN_SESSION: &str = "api_token_session"; +pub const ATTR_APPLICATION_PASSWORD: &str = "application_password"; pub const ATTR_ATTESTED_PASSKEYS: &str = "attested_passkeys"; pub const ATTR_ATTR: &str = "attr"; pub const ATTR_ATTRIBUTENAME: &str = "attributename"; @@ -129,6 +130,7 @@ pub const ATTR_KEY_PROVIDER: &str = "key_provider"; pub const ATTR_LAST_MODIFIED_CID: &str = "last_modified_cid"; pub const ATTR_LDAP_ALLOW_UNIX_PW_BIND: &str = "ldap_allow_unix_pw_bind"; pub const ATTR_LEGALNAME: &str = "legalname"; +pub const ATTR_LINKEDGROUP: &str = "linked_group"; pub const ATTR_LOGINSHELL: &str = "loginshell"; pub const ATTR_MAIL: &str = "mail"; pub const ATTR_MAY: &str = "may"; diff --git a/proto/src/internal/error.rs b/proto/src/internal/error.rs index e28b06e7f..d951f9bc8 100644 --- a/proto/src/internal/error.rs +++ b/proto/src/internal/error.rs @@ -147,9 +147,13 @@ pub enum OperationError { // Value Errors VL0001ValueSshPublicKeyString, + // LDAP Errors + LD0001AnonymousNotAllowed, + // DB low level errors. DB0001MismatchedRestoreVersion, DB0002MismatchedRestoreVersion, + DB0003FilterResolveCacheBuild, // SCIM SC0001IncomingSshPublicKey, @@ -211,6 +215,10 @@ pub enum OperationError { // Plugins PL0001GidOverlapsSystemRange, + + // Web UI + UI0001ChallengeSerialisation, + UI0002InvalidState, } impl PartialEq for OperationError { @@ -307,12 +315,14 @@ impl OperationError { Self::VS0004CertificatePublicKeyDigest | Self::VS0005CertificatePublicKeyDigest => Some("The certificates public key is unabled to be digested."), Self::VL0001ValueSshPublicKeyString => None, + Self::LD0001AnonymousNotAllowed => Some("Anonymous is not allowed to access LDAP with this method."), Self::SC0001IncomingSshPublicKey => None, Self::MG0001InvalidReMigrationLevel => None, Self::MG0002RaiseDomainLevelExceedsMaximum => None, Self::MG0003ServerPhaseInvalidForMigration => None, Self::DB0001MismatchedRestoreVersion => None, Self::DB0002MismatchedRestoreVersion => None, + Self::DB0003FilterResolveCacheBuild => None, Self::MG0004DomainLevelInDevelopment => None, Self::MG0005GidConstraintsNotMet => None, Self::MG0006SKConstraintsNotMet => Some("Migration Constraints Not Met - Security Keys should not be present."), @@ -364,6 +374,8 @@ impl OperationError { Self::KP0043KeyObjectJweA128GCMEncryption => None, Self::KP0044KeyObjectJwsPublicJwk => None, Self::PL0001GidOverlapsSystemRange => None, + Self::UI0001ChallengeSerialisation => Some("The WebAuthn challenge was unable to be serialised."), + Self::UI0002InvalidState => Some("The credential update process returned an invalid state transition."), } } } diff --git a/server/core/src/https/views/login.rs b/server/core/src/https/views/login.rs index 555e05207..0e060c8d8 100644 --- a/server/core/src/https/views/login.rs +++ b/server/core/src/https/views/login.rs @@ -207,7 +207,7 @@ pub async fn view_reauth_get( .into_response(), }; - return Ok(res); + Ok(res) } pub async fn view_index_get( diff --git a/server/core/src/https/views/oauth2.rs b/server/core/src/https/views/oauth2.rs index f62bf50f1..86511a74b 100644 --- a/server/core/src/https/views/oauth2.rs +++ b/server/core/src/https/views/oauth2.rs @@ -141,13 +141,11 @@ async fn oauth2_auth_req( match maybe_jar { Ok(jar) => (jar, Redirect::to("/ui/login")).into_response(), - Err(err_code) => { - return HtmlTemplate(UnrecoverableErrorView { - err_code, - operation_id: kopid.eventid, - }) - .into_response(); - } + Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + err_code, + operation_id: kopid.eventid, + }) + .into_response(), } } Err(Oauth2Error::AccessDenied) => { diff --git a/server/core/src/https/views/reset.rs b/server/core/src/https/views/reset.rs index 2791b72c5..7f9573f1d 100644 --- a/server/core/src/https/views/reset.rs +++ b/server/core/src/https/views/reset.rs @@ -27,9 +27,10 @@ use kanidm_proto::internal::{ use crate::https::extractors::VerifiedClientInformation; use crate::https::middleware::KOpId; use crate::https::views::errors::HtmxError; -use crate::https::views::HtmlTemplate; use crate::https::ServerState; +use super::{HtmlTemplate, UnrecoverableErrorView}; + #[derive(Template)] #[template(path = "credentials_reset_form.html")] struct ResetCredFormView { @@ -66,7 +67,7 @@ pub(crate) struct ResetTokenParam { } #[derive(Template)] -#[template(path = "cred_update/add_password_partial.html")] +#[template(path = "credential_update_add_password_partial.html")] struct AddPasswordPartial { check_res: PwdCheckResult, } @@ -97,7 +98,7 @@ pub(crate) struct NewTotp { } #[derive(Template)] -#[template(path = "cred_update/add_passkey_partial.html")] +#[template(path = "credential_update_add_passkey_partial.html")] struct AddPasskeyPartial { // Passkey challenge for adding a new passkey challenge: String, @@ -169,7 +170,7 @@ impl Display for TotpFeedback { } #[derive(Template)] -#[template(path = "cred_update/add_totp_partial.html")] +#[template(path = "credential_update_add_totp_partial.html")] struct AddTotpPartial { check_res: TotpCheckResult, } @@ -367,17 +368,25 @@ pub(crate) async fn view_new_passkey( let response = match cu_status.mfaregstate { CURegState::Passkey(chal) | CURegState::AttestedPasskey(chal) => { - HtmlTemplate(AddPasskeyPartial { - challenge: serde_json::to_string(&chal).unwrap(), - class: init_form.class, - }) - .into_response() + if let Ok(challenge) = serde_json::to_string(&chal) { + HtmlTemplate(AddPasskeyPartial { + challenge, + class: init_form.class, + }) + .into_response() + } else { + HtmlTemplate(UnrecoverableErrorView { + err_code: OperationError::UI0001ChallengeSerialisation, + operation_id: kopid.eventid, + }) + .into_response() + } } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - HtmxError::new(&kopid, OperationError::Backend).into_response(), - ) - .into_response(), + _ => HtmlTemplate(UnrecoverableErrorView { + err_code: OperationError::UI0002InvalidState, + operation_id: kopid.eventid, + }) + .into_response(), }; let passkey_init_trigger = @@ -657,7 +666,7 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView { .. } = cu_status; - return CredResetPartialView { + CredResetPartialView { ext_cred_portal, warnings, attested_passkeys_state, @@ -666,19 +675,19 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView { passkeys, primary_state, primary, - }; + } } fn get_cu_partial_response(cu_status: CUStatus) -> Response { let credentials_update_partial = get_cu_partial(cu_status); - return ( + ( HxPushUrl(Uri::from_static("/ui/reset")), HxRetarget("#credentialUpdateDynamicSection".to_string()), HxReselect("#credentialUpdateDynamicSection".to_string()), HxReswap(SwapOption::OuterHtml), HtmlTemplate(credentials_update_partial), ) - .into_response(); + .into_response() } fn get_cu_response(domain: String, cu_status: CUStatus) -> Response { @@ -710,34 +719,3 @@ async fn get_cu_session(jar: CookieJar) -> Result<CUSessionToken, Response> { Err((StatusCode::FORBIDDEN, Redirect::to("/ui/reset")).into_response()) }; } - -// Any filter defined in the module `filters` is accessible in your template. -mod filters { - pub fn blank_if<T: std::fmt::Display>( - implicit_arg: T, - condition: bool, - ) -> ::askama::Result<String> { - blank_iff(implicit_arg, &condition) - } - pub fn ternary<T: std::fmt::Display, F: std::fmt::Display>( - implicit_arg: &bool, - true_case: T, - false_case: F, - ) -> ::askama::Result<String> { - if *implicit_arg { - Ok(format!("{true_case}")) - } else { - Ok(format!("{false_case}")) - } - } - pub fn blank_iff<T: std::fmt::Display>( - implicit_arg: T, - condition: &bool, - ) -> ::askama::Result<String> { - return if *condition { - Ok("".into()) - } else { - Ok(format!("{implicit_arg}")) - }; - } -} diff --git a/server/core/templates/cred_update/add_passkey_partial.html b/server/core/templates/credential_update_add_passkey_partial.html similarity index 99% rename from server/core/templates/cred_update/add_passkey_partial.html rename to server/core/templates/credential_update_add_passkey_partial.html index a6f46286b..90aac6309 100644 --- a/server/core/templates/cred_update/add_passkey_partial.html +++ b/server/core/templates/credential_update_add_passkey_partial.html @@ -24,4 +24,4 @@ </div> </form> </div> -</div> \ No newline at end of file +</div> diff --git a/server/core/templates/cred_update/add_password_partial.html b/server/core/templates/credential_update_add_password_partial.html similarity index 90% rename from server/core/templates/cred_update/add_password_partial.html rename to server/core/templates/credential_update_add_password_partial.html index 8727ac389..8f900b9c2 100644 --- a/server/core/templates/cred_update/add_password_partial.html +++ b/server/core/templates/credential_update_add_password_partial.html @@ -7,8 +7,12 @@ (% if let PwdCheckResult::Failure with { pwd_equal, warnings } = check_res %) (% let pwd_equal = pwd_equal.clone() %) - (% let potentially_invalid_input_class = "is-invalid"|blank_if(warnings.len() == 0) %) - (% let potentially_invalid_reinput_class = "is-invalid"|blank_iff(pwd_equal) %) + (% if !warnings.is_empty() %) + (% let potentially_invalid_input_class = "is-invalid" %) + (% endif %) + (% if pwd_equal %) + (% let potentially_invalid_reinput_class = "is-invalid" %) + (% endif %) (% endif %) <label for="new-password" class="form-label">Enter New Password</label> diff --git a/server/core/templates/cred_update/add_totp_partial.html b/server/core/templates/credential_update_add_totp_partial.html similarity index 93% rename from server/core/templates/cred_update/add_totp_partial.html rename to server/core/templates/credential_update_add_totp_partial.html index 05dd0e286..4585c74a9 100644 --- a/server/core/templates/cred_update/add_totp_partial.html +++ b/server/core/templates/credential_update_add_totp_partial.html @@ -23,8 +23,12 @@ (% if let TotpCheckResult::Failure with { wrong_code, broken_app, warnings } = check_res %) (% let wrong_code = wrong_code.clone() %) (% let broken_app = broken_app.clone() %) - (% let potentially_invalid_name_class = "is-invalid"|blank_if(warnings.len() == 0) %) - (% let potentially_invalid_check_class = "is-invalid"|blank_iff(wrong_code) %) + (% if !warnings.is_empty() %) + (% let potentially_invalid_name_class = "is-invalid" %) + (% endif %) + (% if wrong_code %) + (% let potentially_invalid_check_class = "is-invalid" %) + (% endif %) (% endif %) <label for="new-totp-name" class="form-label">Enter a name for your TOTP</label> diff --git a/server/core/templates/credentials_reset_form.html b/server/core/templates/credentials_reset_form.html index 9080ced3c..c47c2f3a7 100644 --- a/server/core/templates/credentials_reset_form.html +++ b/server/core/templates/credentials_reset_form.html @@ -23,7 +23,11 @@ name="token" autofocus aria-describedby="unknown-reset-token-validation-feedback" - class='form-control (( "is-invalid"|blank_iff(!wrong_code) ))' + (% if wrong_code %) + class='form-control is-invalid' + (% else %) + class='form-control' + (% endif %) > (% if wrong_code %) <div id="unknown-reset-token-validation-feedback" class="invalid-feedback"> @@ -45,4 +49,4 @@ </button> </p> </main> -(% endblock %) \ No newline at end of file +(% endblock %) diff --git a/server/core/templates/credentials_update_partial.html b/server/core/templates/credentials_update_partial.html index 4fdda6db0..1f13b6ec2 100644 --- a/server/core/templates/credentials_update_partial.html +++ b/server/core/templates/credentials_update_partial.html @@ -15,8 +15,12 @@ <hr class="my-4" > (% for warning in warnings %) (% let is_danger = [CURegWarning::WebauthnAttestationUnsatisfiable, CURegWarning::Unsatisfiable].contains(warning) %) + (% if is_danger %) + <div class='alert alert-danger' role="alert"> + (% else %) + <div class='alert alert-warning' role="alert"> + (% endif %) - <div class='alert alert-(( is_danger|ternary("danger", "warning") ))' role="alert"> (% match warning %) (% when CURegWarning::MfaRequired %) Multi-Factor Authentication is required for your account. Either add TOTP or remove your password in favour of passkeys to submit. @@ -97,7 +101,9 @@ type="submit" hx-post="/ui/api/cu_commit" hx-boost="false" - (( "disabled"|blank_if(warnings.len() == 0) )) + (% if !warnings.is_empty() %) + disabled + (% endif %) >Submit Changes</button> </span> </div> diff --git a/server/daemon/run_insecure_dev_server.sh b/server/daemon/run_insecure_dev_server.sh index 38b6c57ad..83ee6c74b 100755 --- a/server/daemon/run_insecure_dev_server.sh +++ b/server/daemon/run_insecure_dev_server.sh @@ -2,6 +2,8 @@ set -e +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + # This script based on the developer readme and allows you to run a test server. if [ -z "$KANI_CARGO_OPTS" ]; then @@ -20,14 +22,14 @@ fi mkdir -p "${KANI_TMP}"/client_ca -CONFIG_FILE=${CONFIG_FILE:="../../examples/insecure_server.toml"} +CONFIG_FILE=${CONFIG_FILE:="${SCRIPT_DIR}/../../examples/insecure_server.toml"} if [ ! -f "${CONFIG_FILE}" ]; then - SCRIPT_DIR="$(dirname -a "$0")" echo "Couldn't find configuration file at ${CONFIG_FILE}, please ensure you're running this script from its base directory (${SCRIPT_DIR})." exit 1 fi +pushd "${SCRIPT_DIR}" > /dev/null 2>&1 if [ -n "${1}" ]; then COMMAND=$* #shellcheck disable=SC2086 @@ -38,4 +40,4 @@ else #shellcheck disable=SC2086 cargo run ${KANI_CARGO_OPTS} --bin kanidmd -- server -c "${CONFIG_FILE}" fi - +popd > /dev/null 2>&1 diff --git a/server/lib/src/be/dbvalue.rs b/server/lib/src/be/dbvalue.rs index 7865ae0f3..0c136be20 100644 --- a/server/lib/src/be/dbvalue.rs +++ b/server/lib/src/be/dbvalue.rs @@ -337,16 +337,6 @@ pub struct DbValueCredV1 { pub data: DbCred, } -#[derive(Serialize, Deserialize, Debug)] -pub enum DbApiToken { - V1 { - #[serde(rename = "u")] - uuid: Uuid, - #[serde(rename = "s")] - secret: DbPasswordV1, - }, -} - #[derive(Serialize, Deserialize, Debug)] pub enum DbValuePasskeyV1 { V4 { u: Uuid, t: String, k: PasskeyV4 }, @@ -663,6 +653,20 @@ pub enum DbValueCertificate { V1 { certificate_der: Vec<u8> }, } +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub enum DbValueApplicationPassword { + V1 { + #[serde(rename = "u")] + refer: Uuid, + #[serde(rename = "a")] + application_refer: Uuid, + #[serde(rename = "l")] + label: String, + #[serde(rename = "p")] + password: DbPasswordV1, + }, +} + #[derive(Serialize, Deserialize, Debug)] pub enum DbValueV1 { #[serde(rename = "U8")] @@ -822,6 +826,8 @@ pub enum DbValueSetV2 { HexString(Vec<String>), #[serde(rename = "X509")] Certificate(Vec<DbValueCertificate>), + #[serde(rename = "AP")] + ApplicationPassword(Vec<DbValueApplicationPassword>), } impl DbValueSetV2 { @@ -874,6 +880,7 @@ impl DbValueSetV2 { DbValueSetV2::WebauthnAttestationCaList { ca_list } => ca_list.len(), DbValueSetV2::KeyInternal(set) => set.len(), DbValueSetV2::Certificate(set) => set.len(), + DbValueSetV2::ApplicationPassword(set) => set.len(), } } diff --git a/server/lib/src/constants/acp.rs b/server/lib/src/constants/acp.rs index 283ae5a0c..f0ee69515 100644 --- a/server/lib/src/constants/acp.rs +++ b/server/lib/src/constants/acp.rs @@ -1076,7 +1076,7 @@ lazy_static! { lazy_static! { pub static ref IDM_ACP_SELF_READ_V1: BuiltinAcp = BuiltinAcp { name: "idm_acp_self_read", - uuid: UUID_IDM_ACP_SELF_READ_V1, + uuid: UUID_IDM_ACP_SELF_READ, description: "Builtin IDM Control for self read - required for whoami and many other functions", classes: vec![ @@ -1111,6 +1111,45 @@ lazy_static! { }; } +lazy_static! { + pub static ref IDM_ACP_SELF_READ_DL8: BuiltinAcp = BuiltinAcp { + name: "idm_acp_self_read", + uuid: UUID_IDM_ACP_SELF_READ, + description: + "Builtin IDM Control for self read - required for whoami and many other functions", + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlSearch, + ], + receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_ALL_ACCOUNTS]), + target: BuiltinAcpTarget::Filter(ProtoFilter::SelfUuid), + search_attrs: vec![ + Attribute::Class, + Attribute::Name, + Attribute::Spn, + Attribute::DisplayName, + Attribute::LegalName, + Attribute::Class, + Attribute::MemberOf, + Attribute::Mail, + Attribute::RadiusSecret, + Attribute::GidNumber, + Attribute::LoginShell, + Attribute::Uuid, + Attribute::SyncParentUuid, + Attribute::AccountExpire, + Attribute::AccountValidFrom, + Attribute::PrimaryCredential, + Attribute::UserAuthTokenSession, + Attribute::PassKeys, + Attribute::AttestedPasskeys, + Attribute::ApplicationPassword, + ], + ..Default::default() + }; +} + lazy_static! { pub static ref IDM_ACP_SELF_WRITE_V1: BuiltinAcp = BuiltinAcp{ name: "idm_acp_self_write", @@ -1133,6 +1172,7 @@ lazy_static! { Attribute::PassKeys, Attribute::AttestedPasskeys, Attribute::UserAuthTokenSession, + Attribute::ApplicationPassword, ], modify_present_attrs: vec![ Attribute::DisplayName, @@ -1143,6 +1183,7 @@ lazy_static! { Attribute::UnixPassword, Attribute::PassKeys, Attribute::AttestedPasskeys, + Attribute::ApplicationPassword, ], ..Default::default() }; @@ -1181,6 +1222,42 @@ lazy_static! { }; } +lazy_static! { + pub static ref IDM_ACP_SELF_WRITE_DL8: BuiltinAcp = BuiltinAcp{ + name: "idm_acp_self_write", + uuid: UUID_IDM_ACP_SELF_WRITE_V1, + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlModify, + ], + description: "Builtin IDM Control for self write - required for people to update their own credentials in line with best practices.", + receiver: BuiltinAcpReceiver::Group ( vec![UUID_IDM_ALL_PERSONS] ), + target: BuiltinAcpTarget::Filter(ProtoFilter::SelfUuid), + modify_removed_attrs: vec![ + Attribute::RadiusSecret, + Attribute::PrimaryCredential, + Attribute::SshPublicKey, + Attribute::UnixPassword, + Attribute::PassKeys, + Attribute::AttestedPasskeys, + Attribute::UserAuthTokenSession, + Attribute::ApplicationPassword, + ], + modify_present_attrs: vec![ + Attribute::RadiusSecret, + Attribute::PrimaryCredential, + Attribute::SshPublicKey, + Attribute::UnixPassword, + Attribute::PassKeys, + Attribute::AttestedPasskeys, + Attribute::ApplicationPassword, + Attribute::ApplicationPassword, + ], + ..Default::default() + }; +} + lazy_static! { pub static ref IDM_ACP_SELF_NAME_WRITE_V1: BuiltinAcp = BuiltinAcp{ name: "idm_acp_self_name_write", @@ -2054,3 +2131,129 @@ lazy_static! { ..Default::default() }; } + +lazy_static! { + pub static ref IDM_ACP_APPLICATION_MANAGE_DL8: BuiltinAcp = BuiltinAcp{ + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlCreate, + EntryClass::AccessControlDelete, + EntryClass::AccessControlModify, + EntryClass::AccessControlSearch + ], + name: "idm_acp_application_manage", + uuid: UUID_IDM_ACP_APPLICATION_MANAGE, + description: "Builtin IDM Control for creating and deleting applications in the directory", + receiver: BuiltinAcpReceiver::Group ( vec![UUID_IDM_APPLICATION_ADMINS] ), + // Any application + target: BuiltinAcpTarget::Filter( ProtoFilter::And(vec![ + match_class_filter!(EntryClass::Application), + FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone() + ])), + search_attrs: vec![ + Attribute::Class, + Attribute::Uuid, + Attribute::Name, + Attribute::Description, + Attribute::DisplayName, + Attribute::Mail, + Attribute::UnixPassword, + Attribute::ApiTokenSession, + Attribute::UserAuthTokenSession, + Attribute::LinkedGroup, + Attribute::EntryManagedBy, + ], + create_attrs: vec![ + Attribute::Class, + Attribute::Uuid, + Attribute::Name, + Attribute::Description, + Attribute::DisplayName, + Attribute::Mail, + Attribute::LinkedGroup, + Attribute::EntryManagedBy, + ], + create_classes: vec![ + EntryClass::Object, + EntryClass::Account, + EntryClass::ServiceAccount, + EntryClass::Application, + ], + modify_present_attrs: vec![ + Attribute::Name, + Attribute::Description, + Attribute::DisplayName, + Attribute::Mail, + Attribute::UnixPassword, + Attribute::ApiTokenSession, + Attribute::LinkedGroup, + Attribute::EntryManagedBy, + ], + modify_removed_attrs: vec![ + Attribute::Name, + Attribute::Description, + Attribute::DisplayName, + Attribute::Mail, + Attribute::UnixPassword, + Attribute::ApiTokenSession, + Attribute::UserAuthTokenSession, + Attribute::LinkedGroup, + Attribute::EntryManagedBy, + ], + ..Default::default() + }; +} + +lazy_static! { + pub static ref IDM_ACP_APPLICATION_ENTRY_MANAGER_DL8: BuiltinAcp = BuiltinAcp { + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlModify, + EntryClass::AccessControlSearch + ], + name: "idm_acp_application_entry_manager", + uuid: UUID_IDM_ACP_APPLICATION_ENTRY_MANAGER, + description: "Builtin IDM Control for allowing EntryManager to read and modify applications", + receiver: BuiltinAcpReceiver::EntryManager, + // Applications that belong to the Entry Manager. + target: BuiltinAcpTarget::Filter( ProtoFilter::And(vec![ + match_class_filter!(EntryClass::Application), + FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone() + ])), + search_attrs: vec![ + Attribute::Class, + Attribute::Uuid, + Attribute::Name, + Attribute::DisplayName, + Attribute::Mail, + Attribute::UnixPassword, + Attribute::ApiTokenSession, + Attribute::UserAuthTokenSession, + Attribute::Description, + Attribute::LinkedGroup, + Attribute::EntryManagedBy, + ], + modify_present_attrs: vec![ + Attribute::Name, + Attribute::Description, + Attribute::DisplayName, + Attribute::Mail, + Attribute::UnixPassword, + Attribute::ApiTokenSession, + Attribute::LinkedGroup, + ], + modify_removed_attrs: vec![ + Attribute::Name, + Attribute::Description, + Attribute::DisplayName, + Attribute::Mail, + Attribute::UnixPassword, + Attribute::ApiTokenSession, + Attribute::UserAuthTokenSession, + Attribute::LinkedGroup, + ], + ..Default::default() + }; +} diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index 096ce4ad2..bfbe52359 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -52,6 +52,7 @@ pub enum Attribute { AcpSearchAttr, AcpTargetScope, ApiTokenSession, + ApplicationPassword, AttestedPasskeys, Attr, AttributeName, @@ -116,6 +117,7 @@ pub enum Attribute { LegalName, LimitSearchMaxResults, LimitSearchMaxFilterTest, + LinkedGroup, LoginShell, Mail, May, @@ -244,6 +246,7 @@ impl<'a> TryFrom<&'a str> for Attribute { ATTR_ACP_SEARCH_ATTR => Attribute::AcpSearchAttr, ATTR_ACP_TARGET_SCOPE => Attribute::AcpTargetScope, ATTR_API_TOKEN_SESSION => Attribute::ApiTokenSession, + ATTR_APPLICATION_PASSWORD => Attribute::ApplicationPassword, ATTR_ATTESTED_PASSKEYS => Attribute::AttestedPasskeys, ATTR_ATTR => Attribute::Attr, ATTR_ATTRIBUTENAME => Attribute::AttributeName, @@ -305,6 +308,7 @@ impl<'a> TryFrom<&'a str> for Attribute { ATTR_LDAP_KEYS => Attribute::LdapKeys, ATTR_SSH_PUBLICKEY => Attribute::SshPublicKey, ATTR_LEGALNAME => Attribute::LegalName, + ATTR_LINKEDGROUP => Attribute::LinkedGroup, ATTR_LOGINSHELL => Attribute::LoginShell, ATTR_LIMIT_SEARCH_MAX_RESULTS => Attribute::LimitSearchMaxResults, ATTR_LIMIT_SEARCH_MAX_FILTER_TEST => Attribute::LimitSearchMaxFilterTest, @@ -420,6 +424,7 @@ impl From<Attribute> for &'static str { Attribute::AcpSearchAttr => ATTR_ACP_SEARCH_ATTR, Attribute::AcpTargetScope => ATTR_ACP_TARGET_SCOPE, Attribute::ApiTokenSession => ATTR_API_TOKEN_SESSION, + Attribute::ApplicationPassword => ATTR_APPLICATION_PASSWORD, Attribute::AttestedPasskeys => ATTR_ATTESTED_PASSKEYS, Attribute::Attr => ATTR_ATTR, Attribute::AttributeName => ATTR_ATTRIBUTENAME, @@ -483,6 +488,7 @@ impl From<Attribute> for &'static str { Attribute::LegalName => ATTR_LEGALNAME, Attribute::LimitSearchMaxResults => ATTR_LIMIT_SEARCH_MAX_RESULTS, Attribute::LimitSearchMaxFilterTest => ATTR_LIMIT_SEARCH_MAX_FILTER_TEST, + Attribute::LinkedGroup => ATTR_LINKEDGROUP, Attribute::LoginShell => ATTR_LOGINSHELL, Attribute::Mail => ATTR_MAIL, Attribute::May => ATTR_MAY, @@ -621,6 +627,7 @@ pub enum EntryClass { AccessControlTargetScope, Account, AccountPolicy, + Application, AttributeType, Builtin, Class, @@ -675,6 +682,7 @@ impl From<EntryClass> for &'static str { EntryClass::AccessControlTargetScope => "access_control_target_scope", EntryClass::Account => "account", EntryClass::AccountPolicy => "account_policy", + EntryClass::Application => "application", EntryClass::AttributeType => "attributetype", EntryClass::Builtin => ENTRYCLASS_BUILTIN, EntryClass::Class => ATTR_CLASS, diff --git a/server/lib/src/constants/groups.rs b/server/lib/src/constants/groups.rs index bfe0d6aed..8ad1b64f7 100644 --- a/server/lib/src/constants/groups.rs +++ b/server/lib/src/constants/groups.rs @@ -407,6 +407,15 @@ lazy_static! { ], ..Default::default() }; + + pub static ref BUILTIN_GROUP_APPLICATION_ADMINS: BuiltinGroup = BuiltinGroup { + name: "idm_application_admins", + uuid: UUID_IDM_APPLICATION_ADMINS, + description: "Builtin Application Administration Group.", + entry_managed_by: Some(UUID_IDM_ADMINS), + members: vec![UUID_IDM_ADMINS], + ..Default::default() + }; } /// Make a list of all the non-admin BuiltinGroup's that are created by default, doing it in a standard-ish way so we can use it around the platform @@ -426,6 +435,7 @@ pub fn idm_builtin_non_admin_groups() -> Vec<&'static BuiltinGroup> { &BUILTIN_GROUP_PEOPLE_PII_READ, &BUILTIN_GROUP_PEOPLE_ON_BOARDING, &BUILTIN_GROUP_SERVICE_ACCOUNT_ADMINS, + &BUILTIN_GROUP_APPLICATION_ADMINS, &IDM_GROUP_ADMINS_V1, &IDM_ALL_PERSONS, &IDM_ALL_ACCOUNTS, diff --git a/server/lib/src/constants/schema.rs b/server/lib/src/constants/schema.rs index 1c33aab11..5a0664ed0 100644 --- a/server/lib/src/constants/schema.rs +++ b/server/lib/src/constants/schema.rs @@ -769,6 +769,16 @@ pub static ref SCHEMA_ATTR_REFERS_DL7: SchemaAttribute = SchemaAttribute { ..Default::default() }; +pub static ref SCHEMA_ATTR_LINKED_GROUP_DL8: SchemaAttribute = SchemaAttribute { + uuid: UUID_SCHEMA_ATTR_LINKED_GROUP, + name: Attribute::LinkedGroup.into(), + description: "A reference linking a group to an entry".to_string(), + + multivalue: false, + syntax: SyntaxType::ReferenceUuid, + ..Default::default() +}; + pub static ref SCHEMA_ATTR_CERTIFICATE_DL7: SchemaAttribute = SchemaAttribute { uuid: UUID_SCHEMA_ATTR_CERTIFICATE, name: Attribute::Certificate.into(), @@ -778,6 +788,16 @@ pub static ref SCHEMA_ATTR_CERTIFICATE_DL7: SchemaAttribute = SchemaAttribute { ..Default::default() }; +pub static ref SCHEMA_ATTR_APPLICATION_PASSWORD_DL8: SchemaAttribute = SchemaAttribute { + uuid: UUID_SCHEMA_ATTR_APPLICATION_PASSWORD, + name: Attribute::ApplicationPassword.into(), + description: "A set of application passwords".to_string(), + + multivalue: true, + syntax: SyntaxType::ApplicationPassword, + ..Default::default() +}; + // === classes === pub static ref SCHEMA_CLASS_PERSON: SchemaClass = SchemaClass { @@ -819,7 +839,34 @@ pub static ref SCHEMA_CLASS_PERSON_DL5: SchemaClass = SchemaClass { systemmust: vec![ Attribute::IdVerificationEcKey.into() ], - systemexcludes: vec![EntryClass::ServiceAccount.into()], + systemexcludes: vec![EntryClass::ServiceAccount.into(), EntryClass::Application.into()], + ..Default::default() +}; + +pub static ref SCHEMA_CLASS_PERSON_DL8: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_PERSON, + name: EntryClass::Person.into(), + description: "Object representation of a person".to_string(), + + sync_allowed: true, + systemmay: vec![ + Attribute::PrimaryCredential.into(), + Attribute::PassKeys.into(), + Attribute::AttestedPasskeys.into(), + Attribute::CredentialUpdateIntentToken.into(), + Attribute::SshPublicKey.into(), + Attribute::RadiusSecret.into(), + Attribute::OAuth2ConsentScopeMap.into(), + Attribute::UserAuthTokenSession.into(), + Attribute::OAuth2Session.into(), + Attribute::Mail.into(), + Attribute::LegalName.into(), + Attribute::ApplicationPassword.into(), + ], + systemmust: vec![ + Attribute::IdVerificationEcKey.into() + ], + systemexcludes: vec![EntryClass::ServiceAccount.into(), EntryClass::Application.into()], ..Default::default() }; @@ -1291,4 +1338,15 @@ pub static ref SCHEMA_CLASS_CLIENT_CERTIFICATE_DL7: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_APPLICATION_DL8: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_APPLICATION, + name: EntryClass::Application.into(), + + description: "The class representing an application".to_string(), + systemmust: vec![Attribute::Name.into(), Attribute::LinkedGroup.into()], + systemmay: vec![Attribute::Description.into()], + systemsupplements: vec![EntryClass::ServiceAccount.into()], + ..Default::default() +}; + ); diff --git a/server/lib/src/constants/uuids.rs b/server/lib/src/constants/uuids.rs index e804d7891..8d4739ab0 100644 --- a/server/lib/src/constants/uuids.rs +++ b/server/lib/src/constants/uuids.rs @@ -68,6 +68,7 @@ pub const UUID_IDM_SERVICE_ACCOUNT_ADMINS: Uuid = uuid!("00000000-0000-0000-0000 pub const UUID_IDM_ACCOUNT_POLICY_ADMINS: Uuid = uuid!("00000000-0000-0000-0000-000000000047"); pub const UUID_IDM_PEOPLE_SELF_NAME_WRITE: Uuid = uuid!("00000000-0000-0000-0000-000000000048"); pub const UUID_IDM_CLIENT_CERTIFICATE_ADMINS: Uuid = uuid!("00000000-0000-0000-0000-000000000049"); +pub const UUID_IDM_APPLICATION_ADMINS: Uuid = uuid!("00000000-0000-0000-0000-000000000050"); // pub const UUID_IDM_HIGH_PRIVILEGE: Uuid = uuid!("00000000-0000-0000-0000-000000001000"); @@ -311,6 +312,10 @@ pub const UUID_SCHEMA_CLASS_CLIENT_CERTIFICATE: Uuid = uuid!("00000000-0000-0000-0000-ffff00000179"); pub const UUID_SCHEMA_ATTR_OAUTH2_STRICT_REDIRECT_URI: Uuid = uuid!("00000000-0000-0000-0000-ffff00000180"); +pub const UUID_SCHEMA_CLASS_APPLICATION: Uuid = uuid!("00000000-0000-0000-0000-ffff00000181"); +pub const UUID_SCHEMA_ATTR_LINKED_GROUP: Uuid = uuid!("00000000-0000-0000-0000-ffff00000182"); +pub const UUID_SCHEMA_ATTR_APPLICATION_PASSWORD: Uuid = + uuid!("00000000-0000-0000-0000-ffff00000183"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. @@ -324,7 +329,7 @@ pub const UUID_DOMAIN_INFO: Uuid = uuid!("00000000-0000-0000-0000-ffffff000025") // skip 00 / 01 - see system info pub const UUID_IDM_ACP_RECYCLE_BIN_SEARCH_V1: Uuid = uuid!("00000000-0000-0000-0000-ffffff000002"); pub const UUID_IDM_ACP_RECYCLE_BIN_REVIVE_V1: Uuid = uuid!("00000000-0000-0000-0000-ffffff000003"); -pub const UUID_IDM_ACP_SELF_READ_V1: Uuid = uuid!("00000000-0000-0000-0000-ffffff000004"); +pub const UUID_IDM_ACP_SELF_READ: Uuid = uuid!("00000000-0000-0000-0000-ffffff000004"); pub const UUID_IDM_ACP_ALL_ACCOUNTS_POSIX_READ_V1: Uuid = uuid!("00000000-0000-0000-0000-ffffff000006"); pub const UUID_IDM_ACP_PEOPLE_PII_READ_V1: Uuid = uuid!("00000000-0000-0000-0000-ffffff000007"); @@ -427,6 +432,10 @@ pub const UUID_KEY_PROVIDER_INTERNAL: Uuid = uuid!("00000000-0000-0000-0000-ffff pub const UUID_IDM_ACP_HP_CLIENT_CERTIFICATE_MANAGER: Uuid = uuid!("00000000-0000-0000-0000-ffffff000071"); +pub const UUID_IDM_ACP_APPLICATION_ENTRY_MANAGER: Uuid = + uuid!("00000000-0000-0000-0000-ffffff000072"); +pub const UUID_IDM_ACP_APPLICATION_MANAGE: Uuid = uuid!("00000000-0000-0000-0000-ffffff000073"); + // End of system ranges pub const UUID_DOES_NOT_EXIST: Uuid = uuid!("00000000-0000-0000-0000-fffffffffffe"); pub const UUID_ANONYMOUS: Uuid = uuid!("00000000-0000-0000-0000-ffffffffffff"); diff --git a/server/lib/src/credential/apppwd.rs b/server/lib/src/credential/apppwd.rs new file mode 100644 index 000000000..7225208a7 --- /dev/null +++ b/server/lib/src/credential/apppwd.rs @@ -0,0 +1,65 @@ +use crate::credential::{CryptoPolicy, Password}; +use crate::prelude::*; +use kanidm_proto::internal::OperationError; +use std::cmp::Ordering; +use std::fmt; + +#[derive(Clone)] +pub struct ApplicationPassword { + pub uuid: Uuid, + pub(crate) application: Uuid, + pub(crate) label: String, + pub(crate) password: Password, +} + +impl fmt::Debug for ApplicationPassword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ApplicationPassword") + .field("uuid", &self.uuid) + .field("application", &self.application) + .field("label", &self.label) + .finish() + } +} + +impl ApplicationPassword { + pub fn new( + application: Uuid, + label: &str, + cleartext: &str, + policy: &CryptoPolicy, + ) -> Result<ApplicationPassword, OperationError> { + let pw = Password::new(policy, cleartext).map_err(|e| { + error!(crypto_err = ?e); + e.into() + })?; + let ap = ApplicationPassword { + uuid: Uuid::new_v4(), + application, + label: label.to_string(), + password: pw, + }; + Ok(ap) + } +} + +impl PartialEq for ApplicationPassword { + fn eq(&self, other: &Self) -> bool { + self.uuid == other.uuid + || (self.application == other.application && self.label == other.label) + } +} + +impl Eq for ApplicationPassword {} + +impl PartialOrd for ApplicationPassword { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +impl Ord for ApplicationPassword { + fn cmp(&self, other: &Self) -> Ordering { + self.uuid.cmp(&other.uuid) + } +} diff --git a/server/lib/src/credential/mod.rs b/server/lib/src/credential/mod.rs index 7c30597e2..f201afdd5 100644 --- a/server/lib/src/credential/mod.rs +++ b/server/lib/src/credential/mod.rs @@ -11,6 +11,7 @@ use webauthn_rs_core::proto::{Credential as WebauthnCredential, CredentialV3}; use crate::be::dbvalue::{DbBackupCodeV1, DbCred}; use crate::repl::proto::{ReplBackupCodeV1, ReplCredV1, ReplPasskeyV4V1, ReplSecurityKeyV4V1}; +pub mod apppwd; pub mod softlock; pub mod totp; diff --git a/server/lib/src/entry.rs b/server/lib/src/entry.rs index e0ce66317..fa22a71e2 100644 --- a/server/lib/src/entry.rs +++ b/server/lib/src/entry.rs @@ -50,6 +50,7 @@ use webauthn_rs::prelude::{ use crate::be::dbentry::{DbEntry, DbEntryVers}; use crate::be::dbvalue::DbValueSetV2; use crate::be::{IdxKey, IdxSlope}; +use crate::credential::apppwd::ApplicationPassword; use crate::credential::Credential; use crate::filter::{Filter, FilterInvalid, FilterResolved, FilterValidResolved}; use crate::idm::ldap::ldap_vattr_map; @@ -2933,6 +2934,15 @@ impl<VALID, STATE> Entry<VALID, STATE> { .and_then(|vs| vs.as_webauthn_attestation_ca_list()) } + pub fn get_ava_application_password( + &self, + attr: Attribute, + ) -> Option<&BTreeMap<Uuid, Vec<ApplicationPassword>>> { + self.attrs + .get(attr.as_ref()) + .and_then(|vs| vs.as_application_password_map()) + } + #[inline(always)] /// Return a single security principle name, if valid to transform this value. pub(crate) fn generate_spn(&self, domain_name: &str) -> Option<Value> { diff --git a/server/lib/src/idm/account.rs b/server/lib/src/idm/account.rs index fd62bfc9d..f38266235 100644 --- a/server/lib/src/idm/account.rs +++ b/server/lib/src/idm/account.rs @@ -14,10 +14,12 @@ use webauthn_rs::prelude::{ use super::accountpolicy::ResolvedAccountPolicy; use crate::constants::UUID_ANONYMOUS; use crate::credential::softlock::CredSoftLockPolicy; -use crate::credential::Credential; +use crate::credential::{apppwd::ApplicationPassword, Credential}; use crate::entry::{Entry, EntryCommitted, EntryReduced, EntrySealed}; use crate::event::SearchEvent; +use crate::idm::application::Application; use crate::idm::group::Group; +use crate::idm::ldap::{LdapBoundToken, LdapSession}; use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction}; use crate::modify::{ModifyInvalid, ModifyList}; use crate::prelude::*; @@ -65,6 +67,7 @@ pub struct Account { pub mail: Vec<String>, pub credential_update_intent_tokens: BTreeMap<String, IntentTokenState>, pub(crate) unix_extn: Option<UnixExtensions>, + pub apps_pwds: BTreeMap<Uuid, Vec<ApplicationPassword>>, } macro_rules! try_from_entry { @@ -197,6 +200,11 @@ macro_rules! try_from_entry { None }; + let apps_pwds = $value + .get_ava_application_password(Attribute::ApplicationPassword) + .cloned() + .unwrap_or_default(); + Ok(Account { uuid, name, @@ -215,6 +223,7 @@ macro_rules! try_from_entry { mail, credential_update_intent_tokens, unix_extn, + apps_pwds, }) }}; } @@ -738,6 +747,53 @@ impl Account { } } } + + pub(crate) fn verify_application_password( + &self, + application: &Application, + cleartext: &str, + ) -> Result<Option<LdapBoundToken>, OperationError> { + if let Some(v) = self.apps_pwds.get(&application.uuid) { + for ap in v.iter() { + let password_verified = ap.password.verify(cleartext).map_err(|e| { + error!(crypto_err = ?e); + e.into() + })?; + + if password_verified { + let session_id = uuid::Uuid::new_v4(); + security_info!( + "Starting session {} for {} {}", + session_id, + self.spn, + self.uuid + ); + + return Ok(Some(LdapBoundToken { + spn: self.spn.clone(), + session_id, + effective_session: LdapSession::ApplicationPasswordBind( + application.uuid, + self.uuid, + ), + })); + } + } + } + Ok(None) + } + + pub(crate) fn generate_application_password_mod( + &self, + application: Uuid, + label: &str, + cleartext: &str, + policy: &CryptoPolicy, + ) -> Result<ModifyList<ModifyInvalid>, OperationError> { + let ap = ApplicationPassword::new(application, label, cleartext, policy)?; + let vap = Value::ApplicationPassword(ap); + Ok(ModifyList::new_append(Attribute::ApplicationPassword, vap)) + } } // Need to also add a "to UserAuthToken" ... diff --git a/server/lib/src/idm/application.rs b/server/lib/src/idm/application.rs new file mode 100644 index 000000000..313118009 --- /dev/null +++ b/server/lib/src/idm/application.rs @@ -0,0 +1,728 @@ +use super::ldap::{LdapBoundToken, LdapSession}; +use crate::idm::account::Account; +use crate::idm::event::LdapApplicationAuthEvent; +use crate::idm::server::{IdmServerAuthTransaction, IdmServerTransaction}; +use crate::prelude::*; +use concread::cowcell::*; +use hashbrown::HashMap; +use kanidm_proto::internal::OperationError; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub(crate) struct Application { + pub uuid: Uuid, + pub name: String, + pub linked_group: Uuid, +} + +impl Application { + #[cfg(test)] + pub(crate) fn try_from_entry_ro( + value: &Entry<EntrySealed, EntryCommitted>, + _qs: &mut QueryServerReadTransaction, + ) -> Result<Self, OperationError> { + if !value.attribute_equality(Attribute::Class, &EntryClass::Application.to_partialvalue()) { + return Err(OperationError::InvalidAccountState( + "Missing class: application".to_string(), + )); + } + + let uuid = value.get_uuid(); + + let name = value + .get_ava_single_iname(Attribute::Name) + .map(|s| s.to_string()) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::Name + )) + })?; + + let linked_group = value + .get_ava_single_refer(Attribute::LinkedGroup) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::LinkedGroup + )) + })?; + + Ok(Application { + name, + uuid, + linked_group, + }) + } +} + +#[derive(Clone)] +struct LdapApplicationsInner { + set: HashMap<String, Application>, +} + +pub struct LdapApplications { + inner: CowCell<LdapApplicationsInner>, +} + +pub struct LdapApplicationsReadTransaction { + inner: CowCellReadTxn<LdapApplicationsInner>, +} + +pub struct LdapApplicationsWriteTransaction<'a> { + inner: CowCellWriteTxn<'a, LdapApplicationsInner>, +} + +impl<'a> LdapApplicationsWriteTransaction<'a> { + pub fn reload(&mut self, value: Vec<Arc<EntrySealedCommitted>>) -> Result<(), OperationError> { + let app_set: Result<HashMap<_, _>, _> = value + .into_iter() + .map(|ent| { + if !ent.attribute_equality(Attribute::Class, &EntryClass::Application.into()) { + error!("Missing class application"); + return Err(OperationError::InvalidEntryState); + } + + let uuid = ent.get_uuid(); + let name = ent + .get_ava_single_iname(Attribute::Name) + .map(str::to_string) + .ok_or(OperationError::InvalidValueState)?; + + let linked_group = ent + .get_ava_single_refer(Attribute::LinkedGroup) + .ok_or(OperationError::InvalidValueState)?; + + let app = Application { + uuid, + name: name.clone(), + linked_group, + }; + + Ok((name, app)) + }) + .collect(); + + let new_inner = LdapApplicationsInner { set: app_set? }; + self.inner.replace(new_inner); + + Ok(()) + } + + pub fn commit(self) { + self.inner.commit(); + } +} + +impl LdapApplications { + pub fn read(&self) -> LdapApplicationsReadTransaction { + LdapApplicationsReadTransaction { + inner: self.inner.read(), + } + } + + pub fn write(&self) -> LdapApplicationsWriteTransaction { + LdapApplicationsWriteTransaction { + inner: self.inner.write(), + } + } +} + +impl TryFrom<Vec<Arc<EntrySealedCommitted>>> for LdapApplications { + type Error = OperationError; + + fn try_from(value: Vec<Arc<EntrySealedCommitted>>) -> Result<Self, Self::Error> { + let apps = LdapApplications { + inner: CowCell::new(LdapApplicationsInner { + set: HashMap::new(), + }), + }; + + let mut apps_wr = apps.write(); + apps_wr.reload(value)?; + apps_wr.commit(); + Ok(apps) + } +} + +impl<'a> IdmServerAuthTransaction<'a> { + pub async fn application_auth_ldap( + &mut self, + lae: &LdapApplicationAuthEvent, + ct: Duration, + ) -> Result<Option<LdapBoundToken>, OperationError> { + let usr_entry = self.get_qs_txn().internal_search_uuid(lae.target)?; + + let account: Account = + Account::try_from_entry_ro(&usr_entry, &mut self.qs_read).map_err(|e| { + error!("Failed to search account {:?}", e); + e + })?; + + if account.is_anonymous() { + return Err(OperationError::InvalidUuid); + } + + if !account.is_within_valid_time(ct) { + security_info!("Account has expired or is not yet valid, not allowing to proceed"); + return Err(OperationError::SessionExpired); + } + + let application = self + .applications + .inner + .set + .get(&lae.application) + .ok_or_else(|| { + info!("Application {:?} not found", lae.application); + OperationError::NoMatchingEntries + })?; + + // Check linked group membership + let is_memberof = usr_entry + .get_ava_refer(Attribute::MemberOf) + .map(|member_of_set| member_of_set.contains(&application.linked_group)) + .unwrap_or_default(); + + if !is_memberof { + debug!( + "User {:?} not member of application {}:{:?} linked group {:?}", + account.uuid, application.name, application.uuid, application.linked_group, + ); + return Ok(None); + } + + match account.verify_application_password(application, lae.cleartext.as_str())? { + Some(_) => { + let session_id = Uuid::new_v4(); + security_info!( + "Starting session {} for {} {} with application {}:{:?}", + session_id, + account.spn, + account.uuid, + application.name, + application.uuid, + ); + + Ok(Some(LdapBoundToken { + spn: account.spn, + session_id, + effective_session: LdapSession::UnixBind(account.uuid), + })) + } + None => { + security_info!("Account does not have a configured application password."); + Ok(None) + } + } + } +} + +#[derive(Debug)] +pub struct GenerateApplicationPasswordEvent { + pub ident: Identity, + pub target: Uuid, + pub application: Uuid, + pub label: String, +} + +impl GenerateApplicationPasswordEvent { + pub fn from_parts( + ident: Identity, + target: Uuid, + application: Uuid, + label: String, + ) -> Result<Self, OperationError> { + Ok(GenerateApplicationPasswordEvent { + ident, + target, + application, + label, + }) + } + + pub fn new_internal(target: Uuid, application: Uuid, label: String) -> Self { + GenerateApplicationPasswordEvent { + ident: Identity::from_internal(), + target, + application, + label, + } + } +} + +#[cfg(test)] +mod tests { + use crate::event::CreateEvent; + use crate::idm::account::Account; + use crate::idm::application::Application; + use crate::idm::application::GenerateApplicationPasswordEvent; + use crate::idm::server::IdmServerTransaction; + use crate::idm::serviceaccount::{DestroyApiTokenEvent, GenerateApiTokenEvent}; + use crate::prelude::*; + use compact_jwt::{dangernoverify::JwsDangerReleaseWithoutVerify, JwsVerifier}; + use kanidm_proto::internal::ApiToken as ProtoApiToken; + use std::time::Duration; + + const TEST_CURRENT_TIME: u64 = 6000; + + // Tests that only the correct combinations of [Account, Person, Application and + // ServiceAccount] classes are allowed. + #[idm_test] + async fn test_idm_application_excludes(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); + + // ServiceAccount, Application and Person not allowed together + let test_grp_name = "testgroup1"; + let test_grp_uuid = Uuid::new_v4(); + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(test_grp_name)), + (Attribute::Uuid, Value::Uuid(test_grp_uuid)) + ); + let test_entry_uuid = Uuid::new_v4(); + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("test_app_name")), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + ( + Attribute::DisplayName, + Value::new_utf8s("test_app_dispname") + ), + (Attribute::LinkedGroup, Value::Refer(test_grp_uuid)) + ); + let ce = CreateEvent::new_internal(vec![e1, e2]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(!cr.is_ok()); + + // Application and Person not allowed together + let test_grp_name = "testgroup1"; + let test_grp_uuid = Uuid::new_v4(); + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(test_grp_name)), + (Attribute::Uuid, Value::Uuid(test_grp_uuid)) + ); + let test_entry_uuid = Uuid::new_v4(); + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("test_app_name")), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + ( + Attribute::DisplayName, + Value::new_utf8s("test_app_dispname") + ), + (Attribute::LinkedGroup, Value::Refer(test_grp_uuid)) + ); + let ce = CreateEvent::new_internal(vec![e1, e2]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(!cr.is_ok()); + + // Supplements not satisfied, Application supplements ServiceAccount + let test_grp_name = "testgroup1"; + let test_grp_uuid = Uuid::new_v4(); + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(test_grp_name)), + (Attribute::Uuid, Value::Uuid(test_grp_uuid)) + ); + let test_entry_uuid = Uuid::new_v4(); + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname("test_app_name")), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + (Attribute::LinkedGroup, Value::Refer(test_grp_uuid)) + ); + let ce = CreateEvent::new_internal(vec![e1, e2]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(!cr.is_ok()); + + // Supplements not satisfied, Application supplements ServiceAccount + let test_grp_name = "testgroup1"; + let test_grp_uuid = Uuid::new_v4(); + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(test_grp_name)), + (Attribute::Uuid, Value::Uuid(test_grp_uuid)) + ); + let test_entry_uuid = Uuid::new_v4(); + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname("test_app_name")), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + (Attribute::LinkedGroup, Value::Refer(test_grp_uuid)) + ); + let ce = CreateEvent::new_internal(vec![e1, e2]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(!cr.is_ok()); + + // Supplements satisfied, Application supplements ServiceAccount + let test_grp_name = "testgroup1"; + let test_grp_uuid = Uuid::new_v4(); + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(test_grp_name)), + (Attribute::Uuid, Value::Uuid(test_grp_uuid)) + ); + let test_entry_uuid = Uuid::new_v4(); + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Name, Value::new_iname("test_app_name")), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + (Attribute::LinkedGroup, Value::Refer(test_grp_uuid)) + ); + let ce = CreateEvent::new_internal(vec![e1, e2]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(cr.is_ok()); + } + + // Tests it is not possible to create an applicatin without the linked group attribute + #[idm_test] + async fn test_idm_application_no_linked_group( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); + + let test_entry_uuid = Uuid::new_v4(); + + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname("test_app_name")), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + ( + Attribute::DisplayName, + Value::new_utf8s("test_app_dispname") + ) + ); + + let ce = CreateEvent::new_internal(vec![e1]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(!cr.is_ok()); + } + + // Tests creating an applicatin with a real linked group attribute + #[idm_test] + async fn test_idm_application_linked_group( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let test_entry_name = "test_app_name"; + let test_entry_uuid = Uuid::new_v4(); + let test_grp_name = "testgroup1"; + let test_grp_uuid = Uuid::new_v4(); + + { + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(test_grp_name)), + (Attribute::Uuid, Value::Uuid(test_grp_uuid)) + ); + + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname(test_entry_name)), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + ( + Attribute::DisplayName, + Value::new_utf8s("test_app_dispname") + ), + (Attribute::LinkedGroup, Value::Refer(test_grp_uuid)) + ); + + let ce = CreateEvent::new_internal(vec![e1, e2]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(cr.is_ok()); + + let cr = idms_prox_write.qs_write.commit(); + assert!(cr.is_ok()); + } + + { + let mut idms_prox_read = idms.proxy_read().await.unwrap(); + let app = idms_prox_read + .qs_read + .internal_search_uuid(test_entry_uuid) + .and_then(|entry| { + Application::try_from_entry_ro(&entry, &mut idms_prox_read.qs_read) + }) + .map_err(|e| { + trace!("Error: {:?}", e); + e + }); + assert!(app.is_ok()); + + let app = app.unwrap(); + assert_eq!(app.name, "test_app_name"); + assert_eq!(app.uuid, test_entry_uuid); + assert_eq!(app.linked_group, test_grp_uuid); + } + + // Test reference integrity. An attempt to remove a linked group blocks + // the group being deleted + { + let de = DeleteEvent::new_internal_invalid(filter!(f_eq( + Attribute::Uuid, + PartialValue::Uuid(test_grp_uuid) + ))); + let mut idms_proxy_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + assert!(idms_proxy_write.qs_write.delete(&de).is_err()); + } + + { + let de = DeleteEvent::new_internal_invalid(filter!(f_eq( + Attribute::Uuid, + PartialValue::Uuid(test_entry_uuid) + ))); + let mut idms_proxy_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + assert!(idms_proxy_write.qs_write.delete(&de).is_ok()); + assert!(idms_proxy_write.qs_write.commit().is_ok()); + } + + { + let de = DeleteEvent::new_internal_invalid(filter!(f_eq( + Attribute::Uuid, + PartialValue::Uuid(test_grp_uuid) + ))); + let mut idms_proxy_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + assert!(idms_proxy_write.qs_write.delete(&de).is_ok()); + assert!(idms_proxy_write.qs_write.commit().is_ok()); + } + } + + #[idm_test] + async fn test_idm_application_delete(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) { + let test_usr_name = "testuser1"; + let test_usr_uuid = Uuid::new_v4(); + let test_app_name = "testapp1"; + let test_app_uuid = Uuid::new_v4(); + let test_grp_name = "testgroup1"; + let test_grp_uuid = Uuid::new_v4(); + + { + let ct = duration_from_epoch_now(); + let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); + + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname(test_usr_name)), + (Attribute::Uuid, Value::Uuid(test_usr_uuid)), + (Attribute::Description, Value::new_utf8s(test_usr_name)), + (Attribute::DisplayName, Value::new_utf8s(test_usr_name)) + ); + + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(test_grp_name)), + (Attribute::Uuid, Value::Uuid(test_grp_uuid)), + (Attribute::Member, Value::Refer(test_usr_uuid)) + ); + + let e3 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname(test_app_name)), + (Attribute::Uuid, Value::Uuid(test_app_uuid)), + (Attribute::LinkedGroup, Value::Refer(test_grp_uuid)) + ); + + let ce = CreateEvent::new_internal(vec![e1, e2, e3]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(cr.is_ok()); + + let ev = GenerateApplicationPasswordEvent { + ident: Identity::from_internal(), + target: test_usr_uuid, + application: test_app_uuid, + label: "label".to_string(), + }; + idms_prox_write + .generate_application_password(&ev) + .expect("Failed to create application password"); + + let cr = idms_prox_write.qs_write.commit(); + assert!(cr.is_ok()); + } + + { + let mut idms_prox_read = idms.proxy_read().await.unwrap(); + let account = idms_prox_read + .qs_read + .internal_search_uuid(test_usr_uuid) + .and_then(|entry| Account::try_from_entry_ro(&entry, &mut idms_prox_read.qs_read)) + .map_err(|e| { + trace!("Error: {:?}", e); + e + }) + .expect("Failed to search for account"); + + assert!(account.apps_pwds.values().count() > 0); + } + + // Test reference integrity. If app is removed linked application passwords must go + { + let de = DeleteEvent::new_internal_invalid(filter!(f_eq( + Attribute::Uuid, + PartialValue::Uuid(test_app_uuid) + ))); + let mut idms_proxy_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + assert!(idms_proxy_write.qs_write.delete(&de).is_ok()); + assert!(idms_proxy_write.qs_write.commit().is_ok()); + } + + { + let mut idms_prox_read = idms.proxy_read().await.unwrap(); + assert!(idms_prox_read + .qs_read + .internal_search_uuid(test_app_uuid) + .is_err()); + } + + { + let mut idms_prox_read = idms.proxy_read().await.unwrap(); + let account = idms_prox_read + .qs_read + .internal_search_uuid(test_usr_uuid) + .and_then(|entry| Account::try_from_entry_ro(&entry, &mut idms_prox_read.qs_read)) + .map_err(|e| { + trace!("Error: {:?}", e); + e + }) + .expect("Failed to search for account"); + + assert!(account.apps_pwds.values().count() == 0); + } + } + + // Test apitoken for application entries + #[idm_test] + async fn test_idm_application_api_token( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let past_grc = Duration::from_secs(TEST_CURRENT_TIME + 1) + AUTH_TOKEN_GRACE_WINDOW; + let exp = Duration::from_secs(TEST_CURRENT_TIME + 6000); + let post_exp = Duration::from_secs(TEST_CURRENT_TIME + 6010); + let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); + + let test_entry_uuid = Uuid::new_v4(); + let test_group_uuid = Uuid::new_v4(); + + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("test_group")), + (Attribute::Uuid, Value::Uuid(test_group_uuid)) + ); + + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname("test_app_name")), + (Attribute::Uuid, Value::Uuid(test_entry_uuid)), + (Attribute::Description, Value::new_utf8s("test_app_desc")), + (Attribute::LinkedGroup, Value::Refer(test_group_uuid)) + ); + + let ce = CreateEvent::new_internal(vec![e1, e2]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(cr.is_ok()); + + let gte = GenerateApiTokenEvent::new_internal(test_entry_uuid, "TestToken", Some(exp)); + + let api_token = idms_prox_write + .service_account_generate_api_token(>e, ct) + .expect("failed to generate new api token"); + + trace!(?api_token); + + // Deserialise it. + let jws_verifier = JwsDangerReleaseWithoutVerify::default(); + + let apitoken_inner = jws_verifier + .verify(&api_token) + .unwrap() + .from_json::<ProtoApiToken>() + .unwrap(); + + let ident = idms_prox_write + .validate_client_auth_info_to_ident(api_token.clone().into(), ct) + .expect("Unable to verify api token."); + + assert!(ident.get_uuid() == Some(test_entry_uuid)); + + // Check the expiry + assert!( + idms_prox_write + .validate_client_auth_info_to_ident(api_token.clone().into(), post_exp) + .expect_err("Should not succeed") + == OperationError::SessionExpired + ); + + // Delete session + let dte = + DestroyApiTokenEvent::new_internal(apitoken_inner.account_id, apitoken_inner.token_id); + assert!(idms_prox_write + .service_account_destroy_api_token(&dte) + .is_ok()); + + // Within gracewindow? + // This is okay, because we are within the gracewindow. + let ident = idms_prox_write + .validate_client_auth_info_to_ident(api_token.clone().into(), ct) + .expect("Unable to verify api token."); + assert!(ident.get_uuid() == Some(test_entry_uuid)); + + // Past gracewindow? + assert!( + idms_prox_write + .validate_client_auth_info_to_ident(api_token.clone().into(), past_grc) + .expect_err("Should not succeed") + == OperationError::SessionExpired + ); + + assert!(idms_prox_write.commit().is_ok()); + } +} diff --git a/server/lib/src/idm/event.rs b/server/lib/src/idm/event.rs index 09ab3a3b8..a95ed358f 100644 --- a/server/lib/src/idm/event.rs +++ b/server/lib/src/idm/event.rs @@ -276,6 +276,22 @@ impl LdapTokenAuthEvent { } } +pub struct LdapApplicationAuthEvent { + pub application: String, + pub target: Uuid, + pub cleartext: String, +} + +impl LdapApplicationAuthEvent { + pub fn new(app_name: &str, usr_uuid: Uuid, cleartext: String) -> Result<Self, OperationError> { + Ok(LdapApplicationAuthEvent { + application: app_name.to_string(), + target: usr_uuid, + cleartext, + }) + } +} + #[derive(Debug)] pub struct AuthEventStepInit { pub username: String, diff --git a/server/lib/src/idm/ldap.rs b/server/lib/src/idm/ldap.rs index 7efdb50a4..e9e373cbf 100644 --- a/server/lib/src/idm/ldap.rs +++ b/server/lib/src/idm/ldap.rs @@ -9,14 +9,14 @@ use compact_jwt::JwsCompact; use kanidm_proto::constants::*; use kanidm_proto::internal::{ApiToken, UserAuthToken}; use ldap3_proto::simple::*; -use regex::Regex; +use regex::{Regex, RegexBuilder}; use std::net::IpAddr; use tracing::trace; use uuid::Uuid; use crate::event::SearchEvent; -use crate::idm::event::{LdapAuthEvent, LdapTokenAuthEvent}; -use crate::idm::server::{IdmServer, IdmServerTransaction}; +use crate::idm::event::{LdapApplicationAuthEvent, LdapAuthEvent, LdapTokenAuthEvent}; +use crate::idm::server::{IdmServer, IdmServerAuthTransaction, IdmServerTransaction}; use crate::prelude::*; // Clippy doesn't like Bind here. But proto needs unboxed ldapmsg, @@ -38,6 +38,7 @@ pub enum LdapSession { UnixBind(Uuid), UserAuthToken(UserAuthToken), ApiToken(ApiToken), + ApplicationPasswordBind(Uuid, Uuid), } #[derive(Debug, Clone)] @@ -65,12 +66,13 @@ pub struct LdapServer { enum LdapBindTarget { Account(Uuid), ApiToken, + Application(String, Uuid), } impl LdapServer { pub async fn new(idms: &IdmServer) -> Result<Self, OperationError> { // let ct = duration_from_epoch_now(); - let mut idms_prox_read = idms.proxy_read().await.unwrap(); + let mut idms_prox_read = idms.proxy_read().await?; // This is the rootdse path. // get the domain_info item let domain_entry = idms_prox_read @@ -87,11 +89,31 @@ impl LdapServer { }) .ok_or(OperationError::InvalidEntryState)?; - let dnre = Regex::new(format!("^((?P<attr>[^=]+)=(?P<val>[^=]+),)?{basedn}$").as_str()) - .map_err(|_| OperationError::InvalidEntryState)?; + // It is necessary to swap greed to avoid the first group "<attr>=<val>" matching the + // next group "app=<app>", son one can use "app=app1,dc=test,dc=net" as search base: + // Greedy (app=app1,dc=test,dc=net): + // Match 1 - app=app1,dc=test,dc=net + // Group 1 - app=app1, + // Group <attr> - app + // Group <val> - app1 + // Group 6 - dc=test,dc=net + // Ungreedy (app=app1,dc=test,dc=net): + // Match 1 - app=app1,dc=test,dc=net + // Group 4 - app=app1, + // Group <app> - app1 + // Group 6 - dc=test,dc=net + let dnre = RegexBuilder::new( + format!("^((?P<attr>[^=,]+)=(?P<val>[^=,]+),)?(app=(?P<app>[^=,]+),)?({basedn})$") + .as_str(), + ) + .swap_greed(true) + .build() + .map_err(|_| OperationError::InvalidEntryState)?; - let binddnre = Regex::new(format!("^(([^=,]+)=)?(?P<val>[^=,]+)(,{basedn})?$").as_str()) - .map_err(|_| OperationError::InvalidEntryState)?; + let binddnre = Regex::new( + format!("^((([^=,]+)=)?(?P<val>[^=,]+))(,app=(?P<app>[^=,]+))?(,{basedn})?$").as_str(), + ) + .map_err(|_| OperationError::InvalidEntryState)?; let rootdse = LdapSearchResultEntry { dn: "".to_string(), @@ -299,7 +321,7 @@ impl LdapServer { admin_info!(attr = ?k_attrs, "LDAP Search Request Mapped Attrs"); let ct = duration_from_epoch_now(); - let mut idm_read = idms.proxy_read().await.unwrap(); + let mut idm_read = idms.proxy_read().await?; // Now start the txn - we need it for resolving filter components. // join the filter, with ext_filter @@ -406,41 +428,8 @@ impl LdapServer { ); let ct = duration_from_epoch_now(); - let mut idm_auth = idms.auth().await.unwrap(); - - let target: LdapBindTarget = if dn.is_empty() { - if pw.is_empty() { - LdapBindTarget::Account(UUID_ANONYMOUS) - } else { - // This is the path to access api-token logins. - LdapBindTarget::ApiToken - } - } else if dn == "dn=token" { - // Is the passed dn requesting token auth? - // We use dn= here since these are attr=value, and dn is a phantom so it will - // never be present or match a real value. We also make it an ava so that clients - // that over-zealously validate dn syntax are happy. - LdapBindTarget::ApiToken - } else { - let rdn = self - .binddnre - .captures(dn) - .and_then(|caps| caps.name("val")) - .map(|v| v.as_str().to_string()) - .ok_or(OperationError::NoMatchingEntries)?; - - if rdn.is_empty() { - // That's weird ... - return Err(OperationError::NoMatchingEntries); - } - - let uuid = idm_auth.qs_read.name_to_uuid(rdn.as_str()).map_err(|e| { - request_error!(err = ?e, ?rdn, "Error resolving rdn to target"); - e - })?; - - LdapBindTarget::Account(uuid) - }; + let mut idm_auth = idms.auth().await?; + let target = self.bind_target_from_bind_dn(&mut idm_auth, dn, pw).await?; let result = match target { LdapBindTarget::Account(uuid) => { @@ -456,6 +445,11 @@ impl LdapServer { let lae = LdapTokenAuthEvent::from_parts(jwsc)?; idm_auth.token_auth_ldap(&lae, ct).await? } + LdapBindTarget::Application(ref app_name, usr_uuid) => { + let lae = + LdapApplicationAuthEvent::new(app_name.as_str(), usr_uuid, pw.to_string())?; + idm_auth.application_auth_ldap(&lae, ct).await? + } }; idm_auth.commit()?; @@ -507,7 +501,7 @@ impl LdapServer { }; let ct = duration_from_epoch_now(); - let mut idm_read = idms.proxy_read().await.unwrap(); + let mut idm_read = idms.proxy_read().await?; // Now start the txn - we need it for resolving filter components. // join the filter, with ext_filter @@ -698,6 +692,63 @@ impl LdapServer { }, } // end match server op } + + async fn bind_target_from_bind_dn<'a>( + &self, + idm_auth: &mut IdmServerAuthTransaction<'a>, + dn: &str, + pw: &str, + ) -> Result<LdapBindTarget, OperationError> { + if dn.is_empty() { + if pw.is_empty() { + return Ok(LdapBindTarget::Account(UUID_ANONYMOUS)); + } else { + // This is the path to access api-token logins. + return Ok(LdapBindTarget::ApiToken); + } + } else if dn == "dn=token" { + // Is the passed dn requesting token auth? + // We use dn= here since these are attr=value, and dn is a phantom so it will + // never be present or match a real value. We also make it an ava so that clients + // that over-zealously validate dn syntax are happy. + return Ok(LdapBindTarget::ApiToken); + } + + if let Some(captures) = self.binddnre.captures(dn) { + if let Some(usr) = captures.name("val") { + let usr = usr.as_str(); + + if usr.is_empty() { + error!("Failed to parse user name from bind DN, it is empty (capture group is {:#?})", captures.name("val")); + return Err(OperationError::NoMatchingEntries); + } + + let usr_uuid = idm_auth.qs_read.name_to_uuid(usr).map_err(|e| { + error!(err = ?e, ?usr, "Error resolving rdn to target"); + e + })?; + + if let Some(app) = captures.name("app") { + let app = app.as_str(); + + if app.is_empty() { + error!("Failed to parse application name from bind DN, it is empty (capture group is {:#?})", captures.name("app")); + return Err(OperationError::NoMatchingEntries); + } + + return Ok(LdapBindTarget::Application(app.to_string(), usr_uuid)); + } + + return Ok(LdapBindTarget::Account(usr_uuid)); + } + } + + error!( + "Failed to parse bind DN, no captures. Bind DN was {:?})", + dn + ); + Err(OperationError::NoMatchingEntries) + } } fn ldap_domain_to_dc(input: &str) -> String { @@ -800,7 +851,8 @@ mod tests { use ldap3_proto::simple::*; use super::{LdapServer, LdapSession}; - use crate::idm::event::UnixPasswordChangeEvent; + use crate::idm::application::GenerateApplicationPasswordEvent; + use crate::idm::event::{LdapApplicationAuthEvent, UnixPasswordChangeEvent}; use crate::idm::serviceaccount::GenerateApiTokenEvent; const TEST_PASSWORD: &str = "ntaoeuntnaoeuhraohuercahu😍"; @@ -997,6 +1049,578 @@ mod tests { assert!(ldaps.do_bind(idms, "claire", "test").await.is_err()); } + #[idm_test] + async fn test_ldap_application_dnre(idms: &IdmServer, _idms_delayed: &IdmServerDelayed) { + let ldaps = LdapServer::new(idms).await.expect("failed to start ldap"); + + let testdn = format!("app=app1,{0}", ldaps.basedn); + let captures = ldaps.dnre.captures(testdn.as_str()).unwrap(); + assert!(captures.name("app").is_some()); + assert!(captures.name("attr").is_none()); + assert!(captures.name("val").is_none()); + + let testdn = format!("uid=foo,app=app1,{0}", ldaps.basedn); + let captures = ldaps.dnre.captures(testdn.as_str()).unwrap(); + assert!(captures.name("app").is_some()); + assert!(captures.name("attr").is_some()); + assert!(captures.name("val").is_some()); + + let testdn = format!("uid=foo,{0}", ldaps.basedn); + let captures = ldaps.dnre.captures(testdn.as_str()).unwrap(); + assert!(captures.name("app").is_none()); + assert!(captures.name("attr").is_some()); + assert!(captures.name("val").is_some()); + } + + #[idm_test] + async fn test_ldap_application_search(idms: &IdmServer, _idms_delayed: &IdmServerDelayed) { + let ldaps = LdapServer::new(idms).await.expect("failed to start ldap"); + + let usr_uuid = Uuid::new_v4(); + let grp_uuid = Uuid::new_v4(); + let app_uuid = Uuid::new_v4(); + let app_name = "testapp1"; + + // Setup person, group and application + { + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson1")), + (Attribute::Uuid, Value::Uuid(usr_uuid)), + (Attribute::Description, Value::new_utf8s("testperson1")), + (Attribute::DisplayName, Value::new_utf8s("testperson1")) + ); + + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("testgroup1")), + (Attribute::Uuid, Value::Uuid(grp_uuid)) + ); + + let e3 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname(app_name)), + (Attribute::Uuid, Value::Uuid(app_uuid)), + (Attribute::LinkedGroup, Value::Refer(grp_uuid)) + ); + + let ct = duration_from_epoch_now(); + let mut server_txn = idms.proxy_write(ct).await.unwrap(); + assert!(server_txn + .qs_write + .internal_create(vec![e1, e2, e3]) + .and_then(|_| server_txn.commit()) + .is_ok()); + } + + // Setup the anonymous login + let anon_t = ldaps.do_bind(idms, "", "").await.unwrap().unwrap(); + assert!(anon_t.effective_session == LdapSession::UnixBind(UUID_ANONYMOUS)); + + // Searches under application base DN must show same content + let sr = SearchRequest { + msgid: 1, + base: format!("app={app_name},dc=example,dc=com"), + scope: LdapSearchScope::Subtree, + filter: LdapFilter::Present(Attribute::ObjectClass.to_string()), + attrs: vec!["*".to_string()], + }; + + let r1 = ldaps + .do_search(idms, &sr, &anon_t, Source::Internal) + .await + .unwrap(); + + let sr = SearchRequest { + msgid: 1, + base: format!("dc=example,dc=com"), + scope: LdapSearchScope::Subtree, + filter: LdapFilter::Present(Attribute::ObjectClass.to_string()), + attrs: vec!["*".to_string()], + }; + + let r2 = ldaps + .do_search(idms, &sr, &anon_t, Source::Internal) + .await + .unwrap(); + assert!(r1.len() > 0); + assert!(r1.len() == r2.len()); + } + + #[idm_test] + async fn test_ldap_application_bind(idms: &IdmServer, _idms_delayed: &IdmServerDelayed) { + let ldaps = LdapServer::new(idms).await.expect("failed to start ldap"); + + let usr_uuid = Uuid::new_v4(); + let grp_uuid = Uuid::new_v4(); + let app_uuid = Uuid::new_v4(); + + // Setup person, group and application + { + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson1")), + (Attribute::Uuid, Value::Uuid(usr_uuid)), + (Attribute::Description, Value::new_utf8s("testperson1")), + (Attribute::DisplayName, Value::new_utf8s("testperson1")) + ); + + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("testgroup1")), + (Attribute::Uuid, Value::Uuid(grp_uuid)) + ); + + let e3 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname("testapp1")), + (Attribute::Uuid, Value::Uuid(app_uuid)), + (Attribute::LinkedGroup, Value::Refer(grp_uuid)) + ); + + let ct = duration_from_epoch_now(); + let mut server_txn = idms.proxy_write(ct).await.unwrap(); + assert!(server_txn + .qs_write + .internal_create(vec![e1, e2, e3]) + .and_then(|_| server_txn.commit()) + .is_ok()); + } + + // No session, user not member of linked group + let res = ldaps + .do_bind(idms, "spn=testperson1,app=testapp1,dc=example,dc=com", "") + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_none()); + + { + let ml = ModifyList::new_append(Attribute::Member, Value::Refer(usr_uuid)); + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + assert!(idms_prox_write + .qs_write + .internal_modify_uuid(grp_uuid, &ml) + .is_ok()); + assert!(idms_prox_write.commit().is_ok()); + } + + // No session, user does not have app password for testapp1 + let res = ldaps + .do_bind(idms, "spn=testperson1,app=testapp1,dc=example,dc=com", "") + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_none()); + + let pass1: String; + let pass2: String; + let pass3: String; + { + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + + let ev = GenerateApplicationPasswordEvent::new_internal( + usr_uuid, + app_uuid, + "apppwd1".to_string(), + ); + pass1 = idms_prox_write + .generate_application_password(&ev) + .expect("Failed to generate application password"); + + let ev = GenerateApplicationPasswordEvent::new_internal( + usr_uuid, + app_uuid, + "apppwd2".to_string(), + ); + pass2 = idms_prox_write + .generate_application_password(&ev) + .expect("Failed to generate application password"); + + assert!(idms_prox_write.commit().is_ok()); + + // Application password overwritten on duplicated label + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + let ev = GenerateApplicationPasswordEvent::new_internal( + usr_uuid, + app_uuid, + "apppwd2".to_string(), + ); + pass3 = idms_prox_write + .generate_application_password(&ev) + .expect("Failed to generate application password"); + assert!(idms_prox_write.commit().is_ok()); + } + + // Got session, app password valid + let res = ldaps + .do_bind( + idms, + "spn=testperson1,app=testapp1,dc=example,dc=com", + pass1.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_some()); + + // No session, app password overwritten + let res = ldaps + .do_bind( + idms, + "spn=testperson1,app=testapp1,dc=example,dc=com", + pass2.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_none()); + + // Got session, app password overwritten + let res = ldaps + .do_bind( + idms, + "spn=testperson1,app=testapp1,dc=example,dc=com", + pass3.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_some()); + + // No session, invalid app password + let res = ldaps + .do_bind( + idms, + "spn=testperson1,app=testapp1,dc=example,dc=com", + "FOO", + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_none()); + } + + #[idm_test] + async fn test_ldap_application_linked_group( + idms: &IdmServer, + _idms_delayed: &IdmServerDelayed, + ) { + let ldaps = LdapServer::new(idms).await.expect("failed to start ldap"); + + let usr_uuid = Uuid::new_v4(); + let usr_name = "testuser1"; + + let grp1_uuid = Uuid::new_v4(); + let grp1_name = "testgroup1"; + let grp2_uuid = Uuid::new_v4(); + let grp2_name = "testgroup2"; + + let app1_uuid = Uuid::new_v4(); + let app1_name = "testapp1"; + let app2_uuid = Uuid::new_v4(); + let app2_name = "testapp2"; + + // Setup person, groups and applications + { + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname(usr_name)), + (Attribute::Uuid, Value::Uuid(usr_uuid)), + (Attribute::Description, Value::new_utf8s(usr_name)), + (Attribute::DisplayName, Value::new_utf8s(usr_name)) + ); + + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(grp1_name)), + (Attribute::Uuid, Value::Uuid(grp1_uuid)), + (Attribute::Member, Value::Refer(usr_uuid)) + ); + + let e3 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(grp2_name)), + (Attribute::Uuid, Value::Uuid(grp2_uuid)) + ); + + let e4 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname(app1_name)), + (Attribute::Uuid, Value::Uuid(app1_uuid)), + (Attribute::LinkedGroup, Value::Refer(grp1_uuid)) + ); + + let e5 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname(app2_name)), + (Attribute::Uuid, Value::Uuid(app2_uuid)), + (Attribute::LinkedGroup, Value::Refer(grp2_uuid)) + ); + + let ct = duration_from_epoch_now(); + let mut server_txn = idms.proxy_write(ct).await.unwrap(); + assert!(server_txn + .qs_write + .internal_create(vec![e1, e2, e3, e4, e5]) + .and_then(|_| server_txn.commit()) + .is_ok()); + } + + let pass_app1: String; + let pass_app2: String; + { + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + + let ev = GenerateApplicationPasswordEvent::new_internal( + usr_uuid, + app1_uuid, + "label".to_string(), + ); + pass_app1 = idms_prox_write + .generate_application_password(&ev) + .expect("Failed to generate application password"); + + // It is possible to generate an application password even if the + // user is not member of the linked group + let ev = GenerateApplicationPasswordEvent::new_internal( + usr_uuid, + app2_uuid, + "label".to_string(), + ); + pass_app2 = idms_prox_write + .generate_application_password(&ev) + .expect("Failed to generate application password"); + + assert!(idms_prox_write.commit().is_ok()); + } + + // Got session, app password valid + let res = ldaps + .do_bind( + idms, + format!("spn={usr_name},app={app1_name},dc=example,dc=com").as_str(), + pass_app1.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_some()); + + // No session, not member + let res = ldaps + .do_bind( + idms, + format!("spn={usr_name},app={app2_name},dc=example,dc=com").as_str(), + pass_app2.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_none()); + + // Add user to grp2 + { + let ml = ModifyList::new_append(Attribute::Member, Value::Refer(usr_uuid)); + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + assert!(idms_prox_write + .qs_write + .internal_modify_uuid(grp2_uuid, &ml) + .is_ok()); + assert!(idms_prox_write.commit().is_ok()); + } + + // Got session, app password valid + let res = ldaps + .do_bind( + idms, + format!("spn={usr_name},app={app2_name},dc=example,dc=com").as_str(), + pass_app2.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_some()); + + // No session, wrong app + let res = ldaps + .do_bind( + idms, + format!("spn={usr_name},app={app1_name},dc=example,dc=com").as_str(), + pass_app2.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_none()); + + // Bind error, app not exists + { + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + let de = DeleteEvent::new_internal_invalid(filter!(f_eq( + Attribute::Uuid, + PartialValue::Uuid(app2_uuid) + ))); + assert!(idms_prox_write.qs_write.delete(&de).is_ok()); + assert!(idms_prox_write.commit().is_ok()); + } + + let res = ldaps + .do_bind( + idms, + format!("spn={usr_name},app={app2_name},dc=example,dc=com").as_str(), + pass_app2.as_str(), + ) + .await; + assert!(res.is_err()); + } + + // For testing the timeouts + // We need times on this scale + // not yet valid <-> valid from time <-> current_time <-> expire time <-> expired + const TEST_CURRENT_TIME: u64 = 6000; + const TEST_NOT_YET_VALID_TIME: u64 = TEST_CURRENT_TIME - 240; + const TEST_VALID_FROM_TIME: u64 = TEST_CURRENT_TIME - 120; + const TEST_EXPIRE_TIME: u64 = TEST_CURRENT_TIME + 120; + const TEST_AFTER_EXPIRY: u64 = TEST_CURRENT_TIME + 240; + + async fn set_account_valid_time(idms: &IdmServer, acct: Uuid) { + let mut idms_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + + let v_valid_from = Value::new_datetime_epoch(Duration::from_secs(TEST_VALID_FROM_TIME)); + let v_expire = Value::new_datetime_epoch(Duration::from_secs(TEST_EXPIRE_TIME)); + + let me = ModifyEvent::new_internal_invalid( + filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(acct))), + ModifyList::new_list(vec![ + Modify::Present(Attribute::AccountExpire.into(), v_expire), + Modify::Present(Attribute::AccountValidFrom.into(), v_valid_from), + ]), + ); + assert!(idms_write.qs_write.modify(&me).is_ok()); + idms_write.commit().expect("Must not fail"); + } + + #[idm_test] + async fn test_ldap_application_valid_from_expire( + idms: &IdmServer, + _idms_delayed: &IdmServerDelayed, + ) { + let ldaps = LdapServer::new(idms).await.expect("failed to start ldap"); + + let usr_uuid = Uuid::new_v4(); + let usr_name = "testuser1"; + + let grp1_uuid = Uuid::new_v4(); + let grp1_name = "testgroup1"; + + let app1_uuid = Uuid::new_v4(); + let app1_name = "testapp1"; + + let pass_app1: String; + + // Setup person, group, application and app password + { + let e1 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname(usr_name)), + (Attribute::Uuid, Value::Uuid(usr_uuid)), + (Attribute::Description, Value::new_utf8s(usr_name)), + (Attribute::DisplayName, Value::new_utf8s(usr_name)) + ); + + let e2 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname(grp1_name)), + (Attribute::Uuid, Value::Uuid(grp1_uuid)), + (Attribute::Member, Value::Refer(usr_uuid)) + ); + + let e3 = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::ServiceAccount.to_value()), + (Attribute::Class, EntryClass::Application.to_value()), + (Attribute::Name, Value::new_iname(app1_name)), + (Attribute::Uuid, Value::Uuid(app1_uuid)), + (Attribute::LinkedGroup, Value::Refer(grp1_uuid)) + ); + + let ct = duration_from_epoch_now(); + let mut server_txn = idms.proxy_write(ct).await.unwrap(); + assert!(server_txn + .qs_write + .internal_create(vec![e1, e2, e3]) + .and_then(|_| server_txn.commit()) + .is_ok()); + + let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await.unwrap(); + + let ev = GenerateApplicationPasswordEvent::new_internal( + usr_uuid, + app1_uuid, + "label".to_string(), + ); + pass_app1 = idms_prox_write + .generate_application_password(&ev) + .expect("Failed to generate application password"); + + assert!(idms_prox_write.commit().is_ok()); + } + + // Got session, app password valid + let res = ldaps + .do_bind( + idms, + format!("spn={usr_name},app={app1_name},dc=example,dc=com").as_str(), + pass_app1.as_str(), + ) + .await; + assert!(res.is_ok()); + assert!(res.unwrap().is_some()); + + // Any account that is not yet valid / expired can't auth. + // Set the valid bounds high/low + // TEST_VALID_FROM_TIME/TEST_EXPIRE_TIME + set_account_valid_time(idms, usr_uuid).await; + + let time_low = Duration::from_secs(TEST_NOT_YET_VALID_TIME); + let time = Duration::from_secs(TEST_CURRENT_TIME); + let time_high = Duration::from_secs(TEST_AFTER_EXPIRY); + + let mut idms_auth = idms.auth().await.unwrap(); + let lae = LdapApplicationAuthEvent::new(app1_name, usr_uuid, pass_app1) + .expect("Failed to build auth event"); + + let r1 = idms_auth + .application_auth_ldap(&lae, time_low) + .await + .expect_err("Authentication succeeded"); + assert!(r1 == OperationError::SessionExpired); + + let r1 = idms_auth + .application_auth_ldap(&lae, time) + .await + .expect("Failed auth"); + assert!(r1.is_some()); + + let r1 = idms_auth + .application_auth_ldap(&lae, time_high) + .await + .expect_err("Authentication succeeded"); + assert!(r1 == OperationError::SessionExpired); + } + macro_rules! assert_entry_contains { ( $entry:expr, diff --git a/server/lib/src/idm/mod.rs b/server/lib/src/idm/mod.rs index ac67d6570..21c9c89b3 100644 --- a/server/lib/src/idm/mod.rs +++ b/server/lib/src/idm/mod.rs @@ -5,6 +5,7 @@ pub mod account; pub(crate) mod accountpolicy; +pub(crate) mod application; pub(crate) mod applinks; pub mod audit; pub(crate) mod authsession; diff --git a/server/lib/src/idm/server.rs b/server/lib/src/idm/server.rs index 73b516e44..f9c685523 100644 --- a/server/lib/src/idm/server.rs +++ b/server/lib/src/idm/server.rs @@ -25,6 +25,10 @@ use super::event::ReadBackupCodeEvent; use super::ldap::{LdapBoundToken, LdapSession}; use crate::credential::{softlock::CredSoftLock, Credential}; use crate::idm::account::Account; +use crate::idm::application::{ + GenerateApplicationPasswordEvent, LdapApplications, LdapApplicationsReadTransaction, + LdapApplicationsWriteTransaction, +}; use crate::idm::audit::AuditEvent; use crate::idm::authsession::{AuthSession, AuthSessionData}; use crate::idm::credupdatesession::CredentialUpdateSessionMutex; @@ -77,6 +81,7 @@ pub struct IdmServer { /// [Webauthn] verifier/config webauthn: Webauthn, oauth2rs: Arc<Oauth2ResourceServers>, + applications: Arc<LdapApplications>, } /// Contains methods that require writes, but in the context of writing to the idm in memory structures (maybe the query server too). This is things like authentication. @@ -92,6 +97,7 @@ pub struct IdmServerAuthTransaction<'a> { pub(crate) async_tx: Sender<DelayedAction>, pub(crate) audit_tx: Sender<AuditEvent>, pub(crate) webauthn: &'a Webauthn, + pub(crate) applications: LdapApplicationsReadTransaction, } pub struct IdmServerCredUpdateTransaction<'a> { @@ -118,6 +124,7 @@ pub struct IdmServerProxyWriteTransaction<'a> { crypto_policy: &'a CryptoPolicy, webauthn: &'a Webauthn, pub(crate) oauth2rs: Oauth2ResourceServersWriteTransaction<'a>, + pub(crate) applications: LdapApplicationsWriteTransaction<'a>, } pub struct IdmServerDelayed { @@ -145,7 +152,7 @@ impl IdmServer { let (audit_tx, audit_rx) = unbounded(); // Get the domain name, as the relying party id. - let (rp_id, rp_name, domain_level, oauth2rs_set) = { + let (rp_id, rp_name, domain_level, oauth2rs_set, application_set) = { let mut qs_read = qs.read().await?; ( qs_read.get_domain_name().to_string(), @@ -153,6 +160,7 @@ impl IdmServer { qs_read.get_domain_version(), // Add a read/reload of all oauth2 configurations. qs_read.get_oauth2rs_set()?, + qs_read.get_applications_set()?, ) }; @@ -193,6 +201,11 @@ impl IdmServer { e })?; + let applications = LdapApplications::try_from(application_set).map_err(|e| { + admin_error!("Failed to load ldap applications - {:?}", e); + e + })?; + Ok(( IdmServer { session_ticket: Semaphore::new(1), @@ -205,6 +218,7 @@ impl IdmServer { audit_tx, webauthn, oauth2rs: Arc::new(oauth2rs), + applications: Arc::new(applications), }, IdmServerDelayed { async_rx }, IdmServerAudit { audit_rx }, @@ -228,6 +242,7 @@ impl IdmServer { async_tx: self.async_tx.clone(), audit_tx: self.audit_tx.clone(), webauthn: &self.webauthn, + applications: self.applications.read(), }) } @@ -260,6 +275,7 @@ impl IdmServer { crypto_policy: &self.crypto_policy, webauthn: &self.webauthn, oauth2rs: self.oauth2rs.write(), + applications: self.applications.write(), }) } @@ -820,6 +836,67 @@ pub trait IdmServerTransaction<'a> { .ok_or(OperationError::InvalidState) } + fn process_ldap_uuid_to_identity( + &mut self, + uuid: &Uuid, + ct: Duration, + source: Source, + ) -> Result<Identity, OperationError> { + let entry = self + .get_qs_txn() + .internal_search_uuid(*uuid) + .map_err(|err| { + error!(?err, ?uuid, "Failed to search user by uuid"); + err + })?; + + let (account, account_policy) = + Account::try_from_entry_with_policy(entry.as_ref(), self.get_qs_txn())?; + + if !account.is_within_valid_time(ct) { + info!("Account is expired or not yet valid."); + return Err(OperationError::SessionExpired); + } + + // Good to go + let anon_entry = if *uuid == UUID_ANONYMOUS { + // We already have it. + entry + } else { + // Pull the anon entry for mapping the identity. + self.get_qs_txn() + .internal_search_uuid(UUID_ANONYMOUS) + .map_err(|err| { + error!( + ?err, + "Unable to search anonymous user for privilege bounding." + ); + err + })? + }; + + let mut limits = Limits::default(); + let session_id = Uuid::new_v4(); + + // Update limits from account policy + if let Some(max_results) = account_policy.limit_search_max_results() { + limits.search_max_results = max_results as usize; + } + if let Some(max_filter) = account_policy.limit_search_max_filter_test() { + limits.search_max_filter_test = max_filter as usize; + } + + // Users via LDAP are always only granted anonymous rights unless + // they auth with an api-token + Ok(Identity { + origin: IdentType::User(IdentUser { entry: anon_entry }), + source, + session_id, + scope: AccessScope::ReadOnly, + limits, + }) + } + #[instrument(level = "debug", skip_all)] fn validate_ldap_session( &mut self, @@ -828,48 +905,8 @@ pub trait IdmServerTransaction<'a> { ct: Duration, ) -> Result<Identity, OperationError> { match session { - LdapSession::UnixBind(uuid) => { - let anon_entry = self - .get_qs_txn() - .internal_search_uuid(UUID_ANONYMOUS) - .map_err(|e| { - admin_error!("Failed to validate ldap session -> {:?}", e); - e - })?; - - let entry = if *uuid == UUID_ANONYMOUS { - anon_entry.clone() - } else { - self.get_qs_txn().internal_search_uuid(*uuid).map_err(|e| { - admin_error!("Failed to start auth ldap -> {:?}", e); - e - })? - }; - - if Account::check_within_valid_time( - ct, - entry - .get_ava_single_datetime(Attribute::AccountValidFrom) - .as_ref(), - entry - .get_ava_single_datetime(Attribute::AccountExpire) - .as_ref(), - ) { - // Good to go - let limits = Limits::default(); - let session_id = Uuid::new_v4(); - - Ok(Identity { - origin: IdentType::User(IdentUser { entry: anon_entry }), - source, - session_id, - scope: AccessScope::ReadOnly, - limits, - }) - } else { - // Nope, expired - Err(OperationError::SessionExpired) - } + LdapSession::UnixBind(uuid) | LdapSession::ApplicationPasswordBind(_, uuid) => { + self.process_ldap_uuid_to_identity(uuid, ct, source) } LdapSession::UserAuthToken(uat) => self.process_uat_to_identity(uat, ct, source), LdapSession::ApiToken(apit) => { @@ -2059,6 +2096,11 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { #[instrument(level = "debug", skip_all)] pub fn commit(mut self) -> Result<(), OperationError> { + if self.qs_write.get_changed_app() { + self.qs_write + .get_applications_set() + .and_then(|application_set| self.applications.reload(application_set))?; + } if self.qs_write.get_changed_oauth2() { let domain_level = self.qs_write.get_domain_version(); self.qs_write @@ -2069,12 +2111,54 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } // Commit everything. + self.applications.commit(); self.oauth2rs.commit(); self.cred_update_sessions.commit(); trace!("cred_update_session.commit"); self.qs_write.commit() } + + #[instrument(level = "debug", skip_all)] + pub fn generate_application_password( + &mut self, + ev: &GenerateApplicationPasswordEvent, + ) -> Result<String, OperationError> { + let account = self.target_to_account(ev.target)?; + + // This is intended to be read/copied by a human + let cleartext = readable_password_from_random(); + + // Create a modlist from the change + let modlist = account + .generate_application_password_mod( + ev.application, + ev.label.as_str(), + cleartext.as_str(), + self.crypto_policy, + ) + .map_err(|e| { + admin_error!("Unable to generate application password mod {:?}", e); + e + })?; + trace!(?modlist, "processing change"); + // Apply it + self.qs_write + .impersonate_modify( + // Filter as executed + &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(ev.target))), + // Filter as intended (acp) + &filter_all!(f_eq(Attribute::Uuid, PartialValue::Uuid(ev.target))), + &modlist, + // Provide the event to impersonate + &ev.ident, + ) + .map_err(|e| { + error!(error = ?e); + e + }) + .map(|_| cleartext) + } } // Need tests of the sessions and the auth ... diff --git a/server/lib/src/idm/serviceaccount.rs b/server/lib/src/idm/serviceaccount.rs index 7f1612c6a..b915a8aa9 100644 --- a/server/lib/src/idm/serviceaccount.rs +++ b/server/lib/src/idm/serviceaccount.rs @@ -23,10 +23,6 @@ macro_rules! try_from_entry { )); } - let spn = $value.get_ava_single_proto_string(Attribute::Spn).ok_or( - OperationError::InvalidAccountState(format!("Missing attribute: {}", Attribute::Spn)), - )?; - let jws_key = $value .get_ava_single_jws_key_es256(Attribute::JwsEs256PrivateKey) .cloned() @@ -48,7 +44,6 @@ macro_rules! try_from_entry { let uuid = $value.get_uuid().clone(); Ok(ServiceAccount { - spn, uuid, valid_from, expire, @@ -59,7 +54,6 @@ macro_rules! try_from_entry { } pub struct ServiceAccount { - pub spn: String, pub uuid: Uuid, pub valid_from: Option<OffsetDateTime>, diff --git a/server/lib/src/repl/consumer.rs b/server/lib/src/repl/consumer.rs index 63265b431..aa566bc2a 100644 --- a/server/lib/src/repl/consumer.rs +++ b/server/lib/src/repl/consumer.rs @@ -242,6 +242,15 @@ impl<'a> QueryServerWriteTransaction<'a> { self.changed_flags.insert(ChangeFlag::OAUTH2) } + if !self.changed_flags.contains(ChangeFlag::APPLICATION) + && cand + .iter() + .chain(pre_cand.iter().map(|e| e.as_ref())) + .any(|e| e.attribute_equality(Attribute::Class, &EntryClass::Application.into())) + { + self.changed_flags.insert(ChangeFlag::APPLICATION) + } + if !self.changed_flags.contains(ChangeFlag::SYNC_AGREEMENT) && cand .iter() @@ -629,6 +638,7 @@ impl<'a> QueryServerWriteTransaction<'a> { | ChangeFlag::ACP | ChangeFlag::OAUTH2 | ChangeFlag::DOMAIN + | ChangeFlag::APPLICATION | ChangeFlag::SYSTEM_CONFIG | ChangeFlag::SYNC_AGREEMENT | ChangeFlag::KEY_MATERIAL, diff --git a/server/lib/src/repl/proto.rs b/server/lib/src/repl/proto.rs index 681b20671..0987dc747 100644 --- a/server/lib/src/repl/proto.rs +++ b/server/lib/src/repl/proto.rs @@ -1,6 +1,7 @@ use super::cid::Cid; use super::entry::EntryChangeState; use super::entry::State; +use crate::be::dbvalue::DbValueApplicationPassword; use crate::be::dbvalue::DbValueCertificate; use crate::be::dbvalue::DbValueImage; use crate::be::dbvalue::DbValueKeyInternal; @@ -443,6 +444,9 @@ pub enum ReplAttrV1 { Certificate { set: Vec<DbValueCertificate>, }, + ApplicationPassword { + set: Vec<DbValueApplicationPassword>, + }, } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] diff --git a/server/lib/src/schema.rs b/server/lib/src/schema.rs index 4b1fc2885..08e5384d3 100644 --- a/server/lib/src/schema.rs +++ b/server/lib/src/schema.rs @@ -242,6 +242,9 @@ impl SchemaAttribute { } SyntaxType::WebauthnAttestationCaList => false, + SyntaxType::ApplicationPassword => { + matches!(v, PartialValue::Uuid(_)) || matches!(v, PartialValue::Refer(_)) + } }; if r { Ok(()) @@ -305,6 +308,7 @@ impl SchemaAttribute { SyntaxType::KeyInternal => matches!(v, Value::KeyInternal { .. }), SyntaxType::HexString => matches!(v, Value::HexString(_)), SyntaxType::Certificate => matches!(v, Value::Certificate(_)), + SyntaxType::ApplicationPassword => matches!(v, Value::ApplicationPassword(..)), }; if r { Ok(()) @@ -781,7 +785,9 @@ impl<'a> SchemaWriteTransaction<'a> { a.syntax == SyntaxType::OauthScopeMap || a.syntax == SyntaxType::OauthClaimMap || // So that when an rs is removed we trigger removal of the sessions. - a.syntax == SyntaxType::Oauth2Session + a.syntax == SyntaxType::Oauth2Session || + // When an application is removed we trigger removal of passwords + a.syntax == SyntaxType::ApplicationPassword // May not need to be a ref type since it doesn't have external links/impact? // || a.syntax == SyntaxType::Session { diff --git a/server/lib/src/server/batch_modify.rs b/server/lib/src/server/batch_modify.rs index 0e48cb516..e6c04cdbf 100644 --- a/server/lib/src/server/batch_modify.rs +++ b/server/lib/src/server/batch_modify.rs @@ -207,6 +207,15 @@ impl<'a> QueryServerWriteTransaction<'a> { self.changed_flags.insert(ChangeFlag::ACP) } + if !self.changed_flags.contains(ChangeFlag::APPLICATION) + && norm_cand + .iter() + .chain(pre_candidates.iter().map(|e| e.as_ref())) + .any(|e| e.attribute_equality(Attribute::Class, &EntryClass::Application.into())) + { + self.changed_flags.insert(ChangeFlag::APPLICATION) + } + if !self.changed_flags.contains(ChangeFlag::OAUTH2) && norm_cand .iter() diff --git a/server/lib/src/server/create.rs b/server/lib/src/server/create.rs index e462d1775..4511d0970 100644 --- a/server/lib/src/server/create.rs +++ b/server/lib/src/server/create.rs @@ -112,6 +112,15 @@ impl<'a> QueryServerWriteTransaction<'a> { { self.changed_flags.insert(ChangeFlag::ACP) } + + if !self.changed_flags.contains(ChangeFlag::APPLICATION) + && commit_cand + .iter() + .any(|e| e.attribute_equality(Attribute::Class, &EntryClass::Application.into())) + { + self.changed_flags.insert(ChangeFlag::APPLICATION) + } + if !self.changed_flags.contains(ChangeFlag::OAUTH2) && commit_cand.iter().any(|e| { e.attribute_equality(Attribute::Class, &EntryClass::OAuth2ResourceServer.into()) diff --git a/server/lib/src/server/delete.rs b/server/lib/src/server/delete.rs index 785ef697e..0b25d9c69 100644 --- a/server/lib/src/server/delete.rs +++ b/server/lib/src/server/delete.rs @@ -115,6 +115,15 @@ impl<'a> QueryServerWriteTransaction<'a> { { self.changed_flags.insert(ChangeFlag::ACP) } + + if !self.changed_flags.contains(ChangeFlag::APPLICATION) + && del_cand + .iter() + .any(|e| e.attribute_equality(Attribute::Class, &EntryClass::Application.into())) + { + self.changed_flags.insert(ChangeFlag::APPLICATION) + } + if !self.changed_flags.contains(ChangeFlag::OAUTH2) && del_cand.iter().any(|e| { e.attribute_equality(Attribute::Class, &EntryClass::OAuth2ResourceServer.into()) diff --git a/server/lib/src/server/migrations.rs b/server/lib/src/server/migrations.rs index 020cbc944..e7fca902c 100644 --- a/server/lib/src/server/migrations.rs +++ b/server/lib/src/server/migrations.rs @@ -594,6 +594,39 @@ impl<'a> QueryServerWriteTransaction<'a> { // =========== Apply changes ============== + let idm_schema_classes = [ + SCHEMA_ATTR_LINKED_GROUP_DL8.clone().into(), + SCHEMA_ATTR_APPLICATION_PASSWORD_DL8.clone().into(), + SCHEMA_CLASS_APPLICATION_DL8.clone().into(), + SCHEMA_CLASS_PERSON_DL8.clone().into(), + ]; + + idm_schema_classes + .into_iter() + .try_for_each(|entry| self.internal_migrate_or_create(entry)) + .map_err(|err| { + error!(?err, "migrate_domain_6_to_7 -> Error"); + err + })?; + + self.reload()?; + + // Update access controls. + let idm_data = [ + IDM_ACP_SELF_READ_DL8.clone().into(), + IDM_ACP_SELF_WRITE_DL8.clone().into(), + IDM_ACP_APPLICATION_MANAGE_DL8.clone().into(), + IDM_ACP_APPLICATION_ENTRY_MANAGER_DL8.clone().into(), + ]; + + idm_data + .into_iter() + .try_for_each(|entry| self.internal_migrate_or_create(entry)) + .map_err(|err| { + error!(?err, "migrate_domain_6_to_7 -> Error"); + err + })?; + Ok(()) } @@ -888,12 +921,12 @@ impl<'a> QueryServerWriteTransaction<'a> { IDM_ACP_SERVICE_ACCOUNT_MANAGE_V1.clone(), // DL4 // DL5 - IDM_ACP_OAUTH2_MANAGE_DL5.clone().into(), + IDM_ACP_OAUTH2_MANAGE_DL5.clone(), // DL6 - IDM_ACP_GROUP_ACCOUNT_POLICY_MANAGE_DL6.clone().into(), - IDM_ACP_PEOPLE_CREATE_DL6.clone().into(), - IDM_ACP_GROUP_MANAGE_DL6.clone().into(), - IDM_ACP_ACCOUNT_MAIL_READ_DL6.clone().into(), + IDM_ACP_GROUP_ACCOUNT_POLICY_MANAGE_DL6.clone(), + IDM_ACP_PEOPLE_CREATE_DL6.clone(), + IDM_ACP_GROUP_MANAGE_DL6.clone(), + IDM_ACP_ACCOUNT_MAIL_READ_DL6.clone(), IDM_ACP_DOMAIN_ADMIN_DL6.clone(), ]; diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index 423da840e..06d030175 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -131,6 +131,7 @@ bitflags::bitflags! { const SYSTEM_CONFIG = 0b0001_0000; const SYNC_AGREEMENT = 0b0010_0000; const KEY_MATERIAL = 0b0100_0000; + const APPLICATION = 0b1000_0000; } } @@ -664,6 +665,7 @@ pub trait QueryServerTransaction<'a> { .ok_or_else(|| OperationError::InvalidAttribute("Invalid hex string syntax".to_string())), SyntaxType::Certificate => Value::new_certificate_s(value) .ok_or_else(|| OperationError::InvalidAttribute("Invalid x509 certificate syntax".to_string())), + SyntaxType::ApplicationPassword => Err(OperationError::InvalidAttribute("ApplicationPassword values can not be supplied through modification".to_string())), } } None => { @@ -719,7 +721,8 @@ pub trait QueryServerTransaction<'a> { | SyntaxType::OauthScopeMap | SyntaxType::Session | SyntaxType::ApiToken - | SyntaxType::Oauth2Session => { + | SyntaxType::Oauth2Session + | SyntaxType::ApplicationPassword => { let un = self.name_to_uuid(value).unwrap_or(UUID_DOES_NOT_EXIST); Ok(PartialValue::Refer(un)) } @@ -978,6 +981,13 @@ pub trait QueryServerTransaction<'a> { ))) } + fn get_applications_set(&mut self) -> Result<Vec<Arc<EntrySealedCommitted>>, OperationError> { + self.internal_search(filter!(f_eq( + Attribute::Class, + EntryClass::Application.into(), + ))) + } + #[instrument(level = "debug", skip_all)] fn consumer_get_state(&mut self) -> Result<ReplRuvRange, OperationError> { // Get the current state of "where we are up to" @@ -1261,9 +1271,7 @@ impl QueryServer { let s_uuid = wr.get_db_s_uuid()?; let d_uuid = wr.get_db_d_uuid()?; let ts_max = wr.get_db_ts_max(curtime)?; - #[allow(clippy::expect_used)] - wr.commit() - .expect("Critical - unable to commit db_s_uuid or db_d_uuid"); + wr.commit()?; (s_uuid, d_uuid, ts_max) }; @@ -1298,13 +1306,15 @@ impl QueryServer { let phase = Arc::new(CowCell::new(ServerPhase::Bootstrap)); - #[allow(clippy::expect_used)] let resolve_filter_cache = Arc::new( ARCacheBuilder::new() .set_size(RESOLVE_FILTER_CACHE_MAX, RESOLVE_FILTER_CACHE_LOCAL) .set_reader_quiesce(true) .build() - .expect("Failed to build resolve_filter_cache"), + .ok_or_else(|| { + error!("Failed to build filter resolve cache"); + OperationError::DB0003FilterResolveCacheBuild + })?, ); let key_providers = Arc::new(KeyProviders::default()); @@ -1343,10 +1353,12 @@ impl QueryServer { // us from competing with writers on the db tickets. This tilts us to write prioritising // on db operations by always making sure a writer can get a db ticket. let read_ticket = if cfg!(test) { - #[allow(clippy::expect_used)] self.read_tickets .try_acquire() - .expect("unable to acquire db_ticket for qsr") + .inspect_err(|err| { + error!(?err, "Unable to acquire read ticket!"); + }) + .ok()? } else { let fut = tokio::time::timeout( Duration::from_millis(DB_LOCK_ACQUIRE_TIMEOUT_MILLIS), @@ -1371,16 +1383,20 @@ impl QueryServer { // and read ticket holders, OR pool_size == 1, and we are waiting on the writer to now // complete. let db_ticket = if cfg!(test) { - #[allow(clippy::expect_used)] self.db_tickets .try_acquire() - .expect("unable to acquire db_ticket for qsr") + .inspect_err(|err| { + error!(?err, "Unable to acquire database ticket!"); + }) + .ok()? } else { - #[allow(clippy::expect_used)] self.db_tickets .acquire() .await - .expect("unable to acquire db_ticket for qsr") + .inspect_err(|err| { + error!(?err, "Unable to acquire database ticket!"); + }) + .ok()? }; Some((read_ticket, db_ticket)) @@ -1397,16 +1413,12 @@ impl QueryServer { let schema = self.schema.read(); let cid_max = self.cid_max.read(); - #[allow(clippy::expect_used)] - let trim_cid = cid_max - .sub_secs(CHANGELOG_MAX_AGE) - .expect("unable to generate trim cid"); + let trim_cid = cid_max.sub_secs(CHANGELOG_MAX_AGE)?; + + let be_txn = self.be.read()?; Ok(QueryServerReadTransaction { - be_txn: self - .be - .read() - .expect("unable to create backend read transaction"), + be_txn, schema, d_info: self.d_info.read(), system_config: self.system_config.read(), @@ -1422,11 +1434,13 @@ impl QueryServer { #[instrument(level = "debug", skip_all)] async fn write_acquire_ticket(&self) -> Option<(SemaphorePermit<'_>, SemaphorePermit<'_>)> { // Guarantee we are the only writer on the thread pool - #[allow(clippy::expect_used)] let write_ticket = if cfg!(test) { self.write_ticket .try_acquire() - .expect("unable to acquire writer_ticket for qsw") + .inspect_err(|err| { + error!(?err, "Unable to acquire write ticket!"); + }) + .ok()? } else { let fut = tokio::time::timeout( Duration::from_millis(DB_LOCK_ACQUIRE_TIMEOUT_MILLIS), @@ -1450,16 +1464,20 @@ impl QueryServer { // *must* be available because pool_size >= 2 and the only other are readers, or // pool_size == 1 and we are waiting on a single reader to now complete let db_ticket = if cfg!(test) { - #[allow(clippy::expect_used)] self.db_tickets .try_acquire() - .expect("unable to acquire db_ticket for qsw") + .inspect_err(|err| { + error!(?err, "Unable to acquire write db_ticket!"); + }) + .ok()? } else { - #[allow(clippy::expect_used)] self.db_tickets .acquire() .await - .expect("unable to acquire db_ticket for qsw") + .inspect_err(|err| { + error!(?err, "Unable to acquire write db_ticket!"); + }) + .ok()? }; Some((write_ticket, db_ticket)) @@ -1489,10 +1507,7 @@ impl QueryServer { // Update the cid now. *cid = Cid::new_lamport(cid.s_uuid, curtime, &cid.ts); - #[allow(clippy::expect_used)] - let trim_cid = cid - .sub_secs(CHANGELOG_MAX_AGE) - .expect("unable to generate trim cid"); + let trim_cid = cid.sub_secs(CHANGELOG_MAX_AGE)?; Ok(QueryServerWriteTransaction { // I think this is *not* needed, because commit is mut self which should @@ -2060,6 +2075,11 @@ impl<'a> QueryServerWriteTransaction<'a> { self.be_txn.upgrade_reindex(v) } + #[inline] + pub(crate) fn get_changed_app(&self) -> bool { + self.changed_flags.contains(ChangeFlag::APPLICATION) + } + #[inline] pub(crate) fn get_changed_oauth2(&self) -> bool { self.changed_flags.contains(ChangeFlag::OAUTH2) diff --git a/server/lib/src/server/modify.rs b/server/lib/src/server/modify.rs index 68b5093e9..9f370f44b 100644 --- a/server/lib/src/server/modify.rs +++ b/server/lib/src/server/modify.rs @@ -214,6 +214,15 @@ impl<'a> QueryServerWriteTransaction<'a> { self.changed_flags.insert(ChangeFlag::ACP) } + if !self.changed_flags.contains(ChangeFlag::APPLICATION) + && norm_cand + .iter() + .chain(pre_candidates.iter().map(|e| e.as_ref())) + .any(|e| e.attribute_equality(Attribute::Class, &EntryClass::Application.into())) + { + self.changed_flags.insert(ChangeFlag::APPLICATION) + } + if !self.changed_flags.contains(ChangeFlag::OAUTH2) && norm_cand .iter() @@ -415,6 +424,16 @@ impl<'a> QueryServerWriteTransaction<'a> { { self.changed_flags.insert(ChangeFlag::ACP) } + + if !self.changed_flags.contains(ChangeFlag::APPLICATION) + && norm_cand + .iter() + .chain(pre_candidates.iter().map(|e| e.as_ref())) + .any(|e| e.attribute_equality(Attribute::Class, &EntryClass::Application.into())) + { + self.changed_flags.insert(ChangeFlag::APPLICATION) + } + if !self.changed_flags.contains(ChangeFlag::OAUTH2) && norm_cand.iter().any(|e| { e.attribute_equality(Attribute::Class, &EntryClass::OAuth2ResourceServer.into()) diff --git a/server/lib/src/value.rs b/server/lib/src/value.rs index 8201855ad..bb590022e 100644 --- a/server/lib/src/value.rs +++ b/server/lib/src/value.rs @@ -34,7 +34,7 @@ use webauthn_rs::prelude::{ use crate::be::dbentry::DbIdentSpn; use crate::be::dbvalue::DbValueOauthClaimMapJoinV1; -use crate::credential::{totp::Totp, Credential}; +use crate::credential::{apppwd::ApplicationPassword, totp::Totp, Credential}; use crate::prelude::*; use crate::repl::cid::Cid; use crate::server::identity::IdentityId; @@ -276,6 +276,7 @@ pub enum SyntaxType { KeyInternal = 38, HexString = 39, Certificate = 40, + ApplicationPassword = 41, } impl TryFrom<&str> for SyntaxType { @@ -325,6 +326,7 @@ impl TryFrom<&str> for SyntaxType { "KEY_INTERNAL" => Ok(SyntaxType::KeyInternal), "HEX_STRING" => Ok(SyntaxType::HexString), "CERTIFICATE" => Ok(SyntaxType::Certificate), + "APPLICATION_PASSWORD" => Ok(SyntaxType::ApplicationPassword), _ => Err(()), } } @@ -374,6 +376,7 @@ impl fmt::Display for SyntaxType { SyntaxType::KeyInternal => "KEY_INTERNAL", SyntaxType::HexString => "HEX_STRING", SyntaxType::Certificate => "CERTIFICATE", + SyntaxType::ApplicationPassword => "APPLICATION_PASSWORD", }) } } @@ -1227,6 +1230,7 @@ pub enum Value { HexString(String), Certificate(Box<Certificate>), + ApplicationPassword(ApplicationPassword), } impl PartialEq for Value { @@ -2041,6 +2045,10 @@ impl Value { Value::AuditLogString(_, s) => { Value::validate_str_escapes(s) && Value::validate_singleline(s) } + Value::ApplicationPassword(ap) => { + Value::validate_str_escapes(&ap.label) && Value::validate_singleline(&ap.label) + } + // These have stricter validators so not needed. Value::Nsuniqueid(s) => NSUNIQUEID_RE.is_match(s), Value::DateTime(odt) => odt.offset() == time::UtcOffset::UTC, diff --git a/server/lib/src/valueset/apppwd.rs b/server/lib/src/valueset/apppwd.rs new file mode 100644 index 000000000..87a152b8e --- /dev/null +++ b/server/lib/src/valueset/apppwd.rs @@ -0,0 +1,319 @@ +use crate::be::dbvalue::{DbValueApplicationPassword, DbValueSetV2}; +use crate::credential::{apppwd::ApplicationPassword, Password}; +use crate::prelude::*; +use crate::repl::proto::ReplAttrV1; +use crate::schema::SchemaAttribute; +use std::collections::BTreeMap; + +#[derive(Debug, Clone)] +pub struct ValueSetApplicationPassword { + // The map key is application's UUID + // The value is a vector instead of BTreeSet to use + // PartialValue::Refer instead of having to implement + // PartialValue::ApplicationPassword. For example + // btreeset.remove takes a full ApplicationPassword + // struct. + map: BTreeMap<Uuid, Vec<ApplicationPassword>>, +} + +impl ValueSetApplicationPassword { + pub fn new(ap: ApplicationPassword) -> Box<Self> { + let mut map: BTreeMap<Uuid, Vec<ApplicationPassword>> = BTreeMap::new(); + map.entry(ap.application).or_default().push(ap); + Box::new(ValueSetApplicationPassword { map }) + } + + fn from_dbv_iter( + data: impl Iterator<Item = DbValueApplicationPassword>, + ) -> Result<ValueSet, OperationError> { + let mut map: BTreeMap<Uuid, Vec<ApplicationPassword>> = BTreeMap::new(); + for ap in data { + let ap = match ap { + DbValueApplicationPassword::V1 { + refer, + application_refer, + label, + password, + } => { + let password = Password::try_from(password) + .map_err(|()| OperationError::InvalidValueState)?; + ApplicationPassword { + uuid: refer, + application: application_refer, + label, + password, + } + } + }; + map.entry(ap.application).or_default().push(ap); + } + Ok(Box::new(ValueSetApplicationPassword { map })) + } + + pub fn from_dbvs2(data: Vec<DbValueApplicationPassword>) -> Result<ValueSet, OperationError> { + Self::from_dbv_iter(data.into_iter()) + } + + pub fn from_repl_v1(data: &[DbValueApplicationPassword]) -> Result<ValueSet, OperationError> { + Self::from_dbv_iter(data.iter().cloned()) + } + + fn to_vec_dbvs(&self) -> Vec<DbValueApplicationPassword> { + self.map + .iter() + .flat_map(|(_, v)| { + v.iter().map(|ap| DbValueApplicationPassword::V1 { + refer: ap.uuid, + application_refer: ap.application, + label: ap.label.clone(), + password: ap.password.to_dbpasswordv1(), + }) + }) + .collect() + } +} + +impl ValueSetT for ValueSetApplicationPassword { + fn insert_checked(&mut self, value: Value) -> Result<bool, OperationError> { + match value { + Value::ApplicationPassword(ap) => { + let application_entries = self.map.entry(ap.application).or_default(); + + if let Some(application_entry) = application_entries + .iter_mut() + .find(|entry_app_password| *entry_app_password == &ap) + { + // Overwrite on duplicated labels for the same application. + application_entry.password = ap.password; + } else { + // Or just add it. + application_entries.push(ap); + } + Ok(true) + } + _ => Err(OperationError::InvalidValueState), + } + } + + fn clear(&mut self) { + self.map.clear(); + } + + fn remove(&mut self, pv: &PartialValue, _cid: &Cid) -> bool { + match pv { + PartialValue::Refer(u) => { + // Deletes all passwords for the referred application + self.map.remove(u).is_some() + } + PartialValue::Uuid(u) => { + // Delete specific application password + // TODO Migrate to extract_if when available + let mut removed = false; + self.map.retain(|_, v| { + let prev = v.len(); + // Check the innel vec of passwords related to this application. + v.retain(|y| y.uuid != *u); + let post = v.len(); + removed |= post < prev; + // Is the apppwd set for this application id now empty? + !v.is_empty() + }); + removed + } + _ => false, + } + } + + fn contains(&self, pv: &PartialValue) -> bool { + match pv { + PartialValue::Uuid(u) => self.map.values().any(|v| v.iter().any(|ap| ap.uuid == *u)), + PartialValue::Refer(u) => self + .map + .values() + .any(|v| v.iter().any(|ap| ap.application == *u)), + _ => false, + } + } + + fn substring(&self, _pv: &PartialValue) -> bool { + false + } + + fn startswith(&self, _pv: &PartialValue) -> bool { + false + } + + fn endswith(&self, _pv: &PartialValue) -> bool { + false + } + + fn lessthan(&self, _pv: &PartialValue) -> bool { + false + } + + fn len(&self) -> usize { + let mut count = 0; + for v in self.map.values() { + count += v.len(); + } + count + } + + fn generate_idx_eq_keys(&self) -> Vec<String> { + self.map + .keys() + .map(|u| u.as_hyphenated().to_string()) + .collect() + } + + fn syntax(&self) -> SyntaxType { + SyntaxType::ApplicationPassword + } + + fn validate(&self, _schema_attr: &SchemaAttribute) -> bool { + self.map.iter().all(|(_, v)| { + v.iter().all(|ap| { + Value::validate_str_escapes(ap.label.as_str()) + && Value::validate_singleline(ap.label.as_str()) + }) + }) + } + + fn to_proto_string_clone_iter(&self) -> Box<dyn Iterator<Item = String> + '_> { + Box::new(self.map.iter().flat_map(|(_, v)| { + v.iter() + .map(|ap| format!("App: {} Label: {}", ap.application, ap.label)) + })) + } + + fn to_db_valueset_v2(&self) -> DbValueSetV2 { + let data = self.to_vec_dbvs(); + DbValueSetV2::ApplicationPassword(data) + } + + fn to_repl_v1(&self) -> ReplAttrV1 { + let set = self.to_vec_dbvs(); + ReplAttrV1::ApplicationPassword { set } + } + + fn to_partialvalue_iter(&self) -> Box<dyn Iterator<Item = PartialValue> + '_> { + Box::new( + self.map + .iter() + .flat_map(|(_, v)| v.iter().map(|ap| ap.uuid)) + .map(PartialValue::Refer), + ) + } + + fn to_value_iter(&self) -> Box<dyn Iterator<Item = Value> + '_> { + Box::new( + self.map + .iter() + .flat_map(|(_, v)| v.iter().map(|ap| Value::ApplicationPassword(ap.clone()))), + ) + } + + fn equal(&self, other: &ValueSet) -> bool { + if let Some(other) = other.as_application_password_map() { + &self.map == other + } else { + debug_assert!(false); + false + } + } + + fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> { + if let Some(b) = other.as_application_password_map() { + mergemaps!(self.map, b) + } else { + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + } + + fn as_application_password_map(&self) -> Option<&BTreeMap<Uuid, Vec<ApplicationPassword>>> { + Some(&self.map) + } + + fn as_ref_uuid_iter(&self) -> Option<Box<dyn Iterator<Item = Uuid> + '_>> { + // This is what ties us as a type that can be refint checked. + Some(Box::new(self.map.keys().copied())) + } +} + +#[cfg(test)] +mod tests { + use crate::credential::{apppwd::ApplicationPassword, Password}; + use crate::prelude::*; + use crate::valueset::ValueSetApplicationPassword; + use kanidm_lib_crypto::CryptoPolicy; + + // Test the remove operation, removing all application passwords for an + // applicaiton should also remove the KV pair. + #[test] + fn test_valueset_application_password_remove() { + let app1_uuid = Uuid::new_v4(); + let app2_uuid = Uuid::new_v4(); + let ap1_uuid = Uuid::new_v4(); + let ap2_uuid = Uuid::new_v4(); + let ap3_uuid = Uuid::new_v4(); + + let ap1: ApplicationPassword = ApplicationPassword { + uuid: ap1_uuid, + application: app1_uuid, + label: "apppwd1".to_string(), + password: Password::new_pbkdf2(&CryptoPolicy::minimum(), "apppwd1") + .expect("Failed to create password"), + }; + + let ap2: ApplicationPassword = ApplicationPassword { + uuid: ap2_uuid, + application: app1_uuid, + label: "apppwd2".to_string(), + password: Password::new_pbkdf2(&CryptoPolicy::minimum(), "apppwd2") + .expect("Failed to create password"), + }; + + let ap3: ApplicationPassword = ApplicationPassword { + uuid: ap3_uuid, + application: app2_uuid, + label: "apppwd3".to_string(), + password: Password::new_pbkdf2(&CryptoPolicy::minimum(), "apppwd3") + .expect("Failed to create password"), + }; + + let mut vs: ValueSet = ValueSetApplicationPassword::new(ap1); + assert!(vs.len() == 1); + + let res = vs + .insert_checked(Value::ApplicationPassword(ap2)) + .expect("Failed to insert"); + assert!(res); + assert!(vs.len() == 2); + + let res = vs + .insert_checked(Value::ApplicationPassword(ap3)) + .expect("Failed to insert"); + assert!(res); + assert!(vs.len() == 3); + + let res = vs.remove(&PartialValue::Uuid(Uuid::new_v4()), &Cid::new_zero()); + assert!(!res); + assert!(vs.len() == 3); + + let res = vs.remove(&PartialValue::Uuid(ap1_uuid), &Cid::new_zero()); + assert!(res); + assert!(vs.len() == 2); + + let res = vs.remove(&PartialValue::Uuid(ap3_uuid), &Cid::new_zero()); + assert!(res); + assert!(vs.len() == 1); + + let res = vs.remove(&PartialValue::Uuid(ap2_uuid), &Cid::new_zero()); + assert!(res); + assert!(vs.len() == 0); + + let res = vs.as_application_password_map().unwrap(); + assert!(res.keys().len() == 0); + } +} diff --git a/server/lib/src/valueset/mod.rs b/server/lib/src/valueset/mod.rs index c66a125a3..edc7b9ba7 100644 --- a/server/lib/src/valueset/mod.rs +++ b/server/lib/src/valueset/mod.rs @@ -18,7 +18,7 @@ use webauthn_rs::prelude::Passkey as PasskeyV4; use kanidm_proto::internal::{Filter as ProtoFilter, UiHint}; use crate::be::dbvalue::DbValueSetV2; -use crate::credential::{totp::Totp, Credential}; +use crate::credential::{apppwd::ApplicationPassword, totp::Totp, Credential}; use crate::prelude::*; use crate::repl::{cid::Cid, proto::ReplAttrV1}; use crate::schema::SchemaAttribute; @@ -26,6 +26,7 @@ use crate::server::keys::KeyId; use crate::value::{Address, ApiToken, CredentialType, IntentTokenState, Oauth2Session, Session}; pub use self::address::{ValueSetAddress, ValueSetEmailAddress}; +use self::apppwd::ValueSetApplicationPassword; pub use self::auditlogstring::{ValueSetAuditLogString, AUDIT_LOG_STRING_CAPACITY}; pub use self::binary::{ValueSetPrivateBinary, ValueSetPublicBinary}; pub use self::bool::ValueSetBool; @@ -63,6 +64,7 @@ pub use self::utf8::ValueSetUtf8; pub use self::uuid::{ValueSetRefer, ValueSetUuid}; mod address; +mod apppwd; mod auditlogstring; mod binary; mod bool; @@ -392,6 +394,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { None } + fn as_application_password_map(&self) -> Option<&BTreeMap<Uuid, Vec<ApplicationPassword>>> { + debug_assert!(false); + None + } + fn to_value_single(&self) -> Option<Value> { if self.len() != 1 { None @@ -708,6 +715,7 @@ pub fn from_result_value_iter( Value::Certificate(c) => ValueSetCertificate::new(c)?, Value::WebauthnAttestationCaList(_) | Value::PhoneNumber(_, _) + | Value::ApplicationPassword(_) | Value::Passkey(_, _, _) | Value::AttestedPasskey(_, _, _) | Value::TotpSecret(_, _) @@ -802,6 +810,7 @@ pub fn from_value_iter(mut iter: impl Iterator<Item = Value>) -> Result<ValueSet debug_assert!(false); return Err(OperationError::InvalidValueState); } + Value::ApplicationPassword(ap) => ValueSetApplicationPassword::new(ap), }; for v in iter { @@ -862,6 +871,7 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result<ValueSet, OperationErro DbValueSetV2::KeyInternal(set) => ValueSetKeyInternal::from_dbvs2(set), DbValueSetV2::HexString(set) => ValueSetHexString::from_dbvs2(set), DbValueSetV2::Certificate(set) => ValueSetCertificate::from_dbvs2(set), + DbValueSetV2::ApplicationPassword(set) => ValueSetApplicationPassword::from_dbvs2(set), } } @@ -915,5 +925,6 @@ pub fn from_repl_v1(rv1: &ReplAttrV1) -> Result<ValueSet, OperationError> { ReplAttrV1::KeyInternal { set } => ValueSetKeyInternal::from_repl_v1(set), ReplAttrV1::HexString { set } => ValueSetHexString::from_repl_v1(set), ReplAttrV1::Certificate { set } => ValueSetCertificate::from_repl_v1(set), + ReplAttrV1::ApplicationPassword { set } => ValueSetApplicationPassword::from_repl_v1(set), } }