Files
LLDAP/crates/ldap/src/modify.rs
Kumpelinus 89cb59919b server: Add modifyTimestamp and pwdChangedTime attributes (#1265)
Add a modifyTimestamp attribute to LDAP entries for users and groups, and expose pwdChangedTime for users.
These attributes let clients track when an entry (or its password) was last changed.

 -  modifyTimestamp is a server-maintained attribute that updates on any write to user or group entries, including membership changes (on the group side).

 -  pwdChangedTime is set when a user’s password is created or changed.
2025-08-31 14:56:07 +02:00

307 lines
11 KiB
Rust

use crate::{
core::{
error::{LdapError, LdapResult},
utils::{LdapInfo, get_user_id_from_distinguished_name},
},
handler::make_modify_response,
password::{self},
};
use ldap3_proto::proto::{LdapModify, LdapModifyRequest, LdapModifyType, LdapOp, LdapResultCode};
use lldap_access_control::UserReadableBackendHandler;
use lldap_auth::access_control::ValidationResults;
use lldap_domain::types::UserId;
use lldap_opaque_handler::OpaqueHandler;
async fn handle_modify_change(
opaque_handler: &impl OpaqueHandler,
user_id: UserId,
credentials: &ValidationResults,
user_is_admin: bool,
change: &LdapModify,
) -> LdapResult<()> {
if !change
.modification
.atype
.eq_ignore_ascii_case("userpassword")
|| change.operation != LdapModifyType::Replace
{
return Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
r#"Unsupported operation: `{:?}` for `{}`"#,
change.operation, change.modification.atype
),
});
}
if !credentials.can_change_password(&user_id, user_is_admin) {
return Err(LdapError {
code: LdapResultCode::InsufficentAccessRights,
message: format!(
r#"User `{}` cannot modify the password of user `{}`"#,
&credentials.user, &user_id
),
});
}
if let [value] = &change.modification.vals.as_slice() {
password::change_password(opaque_handler, user_id, value)
.await
.map_err(|e| LdapError {
code: LdapResultCode::Other,
message: format!("Error while changing the password: {e:#?}"),
})?;
} else {
return Err(LdapError {
code: LdapResultCode::InvalidAttributeSyntax,
message: format!(
r#"Wrong number of values for password attribute: {}"#,
change.modification.vals.len()
),
});
}
Ok(())
}
pub(crate) async fn handle_modify_request<'cred, UserBackendHandler>(
opaque_handler: &impl OpaqueHandler,
get_readable_handler: impl FnOnce(
&'cred ValidationResults,
UserId,
) -> Option<&'cred UserBackendHandler>,
ldap_info: &LdapInfo,
credentials: &'cred ValidationResults,
request: &LdapModifyRequest,
) -> LdapResult<Vec<LdapOp>>
where
// Note: ideally, get_readable_handler would take UserId by reference, but I couldn't make the lifetimes work.
UserBackendHandler: UserReadableBackendHandler + 'cred,
{
match get_user_id_from_distinguished_name(
&request.dn,
&ldap_info.base_dn,
&ldap_info.base_dn_str,
) {
Ok(uid) => {
let user_is_admin = get_readable_handler(credentials, uid.clone())
.ok_or_else(|| LdapError {
code: LdapResultCode::InsufficentAccessRights,
message: format!(
"User `{}` cannot modify user `{}`",
credentials.user.as_str(),
uid.as_str()
),
})?
.get_user_groups(&uid)
.await
.map_err(|e| LdapError {
code: LdapResultCode::OperationsError,
message: format!("Internal error while requesting user's groups: {e:#?}"),
})?
.iter()
.any(|g| g.display_name == "lldap_admin".into());
for change in &request.changes {
handle_modify_change(
opaque_handler,
uid.clone(),
credentials,
user_is_admin,
change,
)
.await?
}
Ok(vec![make_modify_response(
LdapResultCode::Success,
String::new(),
)])
}
Err(e) => Err(LdapError {
code: LdapResultCode::InvalidDNSyntax,
message: format!("Invalid username: {e}"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
handler::tests::{
setup_bound_admin_handler, setup_bound_handler_with_group,
setup_bound_password_manager_handler,
},
password::tests::expect_password_change,
};
use chrono::TimeZone;
use ldap3_proto::proto::LdapResult as LdapResultOp;
use lldap_domain::{
types::{GroupDetails, GroupId, GroupName, UserId},
uuid,
};
use lldap_test_utils::MockTestBackendHandler;
use mockall::predicate::eq;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
fn setup_target_user_groups(
mock: &mut MockTestBackendHandler,
target_user: &str,
groups: Vec<&'static str>,
) {
mock.expect_get_user_groups()
.times(1)
.with(eq(UserId::from(target_user)))
.return_once(move |_| {
let mut g = HashSet::<GroupDetails>::new();
for group in groups {
g.insert(GroupDetails {
group_id: GroupId(42),
display_name: GroupName::from(group),
creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
attributes: Vec::new(),
modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(),
});
}
Ok(g)
});
}
fn make_password_modify_request(target_user: &str) -> LdapModifyRequest {
LdapModifyRequest {
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
changes: vec![LdapModify {
operation: LdapModifyType::Replace,
modification: ldap3_proto::LdapPartialAttribute {
atype: "userPassword".to_string(),
vals: vec![b"tommy".to_vec()],
},
}],
}
}
fn make_modify_success_response() -> Vec<LdapOp> {
vec![LdapOp::ModifyResponse(LdapResultOp {
code: LdapResultCode::Success,
matcheddn: "".to_string(),
message: "".to_string(),
referral: vec![],
})]
}
fn make_modify_failure_response(code: LdapResultCode, message: &str) -> Vec<LdapOp> {
vec![LdapOp::ModifyResponse(LdapResultOp {
code,
matcheddn: "".to_string(),
message: message.to_string(),
referral: vec![],
})]
}
#[tokio::test]
async fn test_modify_password_of_regular_as_admin() {
let mut mock = MockTestBackendHandler::new();
setup_target_user_groups(&mut mock, "bob", Vec::new());
expect_password_change(&mut mock, "bob");
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_password_modify_request("bob");
assert_eq!(
ldap_handler.do_modify_request(&request).await,
make_modify_success_response()
);
}
#[tokio::test]
async fn test_modify_password_of_regular_as_regular() {
let mut mock = MockTestBackendHandler::new();
setup_target_user_groups(&mut mock, "test", Vec::new());
expect_password_change(&mut mock, "test");
let ldap_handler = setup_bound_handler_with_group(mock, "regular").await;
let request = make_password_modify_request("test");
assert_eq!(
ldap_handler.do_modify_request(&request).await,
make_modify_success_response()
);
}
#[tokio::test]
async fn test_modify_password_of_regular_as_password_manager() {
let mut mock = MockTestBackendHandler::new();
setup_target_user_groups(&mut mock, "bob", Vec::new());
expect_password_change(&mut mock, "bob");
let ldap_handler = setup_bound_password_manager_handler(mock).await;
let request = make_password_modify_request("bob");
assert_eq!(
ldap_handler.do_modify_request(&request).await,
make_modify_success_response()
);
}
#[tokio::test]
async fn test_modify_password_of_admin_as_password_manager() {
let mut mock = MockTestBackendHandler::new();
setup_target_user_groups(&mut mock, "bob", vec!["lldap_admin"]);
let ldap_handler = setup_bound_password_manager_handler(mock).await;
let request = make_password_modify_request("bob");
assert_eq!(
ldap_handler.do_modify_request(&request).await,
make_modify_failure_response(
LdapResultCode::InsufficentAccessRights,
"User `test` cannot modify the password of user `bob`"
)
);
}
#[tokio::test]
async fn test_modify_password_of_other_regular_as_regular() {
let ldap_handler =
setup_bound_handler_with_group(MockTestBackendHandler::new(), "regular").await;
let request = make_password_modify_request("bob");
assert_eq!(
ldap_handler.do_modify_request(&request).await,
make_modify_failure_response(
LdapResultCode::InsufficentAccessRights,
"User `test` cannot modify user `bob`"
)
);
}
#[tokio::test]
async fn test_modify_password_of_admin_as_admin() {
let mut mock = MockTestBackendHandler::new();
setup_target_user_groups(&mut mock, "test", vec!["lldap_admin"]);
expect_password_change(&mut mock, "test");
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = make_password_modify_request("test");
assert_eq!(
ldap_handler.do_modify_request(&request).await,
make_modify_success_response()
);
}
#[tokio::test]
async fn test_modify_password_invalid_number_of_values() {
let mut mock = MockTestBackendHandler::new();
setup_target_user_groups(&mut mock, "bob", Vec::new());
let ldap_handler = setup_bound_admin_handler(mock).await;
let request = {
let target_user = "bob";
LdapModifyRequest {
dn: format!("uid={target_user},ou=people,dc=example,dc=com"),
changes: vec![LdapModify {
operation: LdapModifyType::Replace,
modification: ldap3_proto::LdapPartialAttribute {
atype: "userPassword".to_string(),
vals: vec![b"tommy".to_vec(), b"other_value".to_vec()],
},
}],
}
};
assert_eq!(
ldap_handler.do_modify_request(&request).await,
make_modify_failure_response(
LdapResultCode::InvalidAttributeSyntax,
"Wrong number of values for password attribute: 2"
)
);
}
}