289 lines
8.7 KiB
Rust
289 lines
8.7 KiB
Rust
use ldap3_proto::control::LdapControl;
|
|
use ldap3_proto::proto::{
|
|
LdapFilter, LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry,
|
|
};
|
|
use ldap3_proto::{LdapMsg, LdapResultCode};
|
|
|
|
use std::str::FromStr;
|
|
|
|
use crate::db::error::BoxedError;
|
|
use crate::db::{Database, DatabaseInterface, User};
|
|
use crate::ldap::{Dn, LdapAttribute, LdapReturnError, LdapStream, LdapStreamError, MalformedDn};
|
|
|
|
#[derive(Debug)]
|
|
pub struct InvalidSearchDn {
|
|
dn: String,
|
|
kind: InvalidSearchDnKind,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum InvalidSearchDnKind {
|
|
Malformed(MalformedDn),
|
|
NoOu,
|
|
MultipleOu,
|
|
}
|
|
|
|
impl LdapReturnError for InvalidSearchDn {
|
|
fn code(&self) -> LdapResultCode {
|
|
LdapResultCode::InvalidDNSyntax
|
|
}
|
|
|
|
fn message(&self) -> String {
|
|
match &self.kind {
|
|
InvalidSearchDnKind::Malformed(_e) => format!("Malformed dn: {}", self.dn),
|
|
InvalidSearchDnKind::NoOu => format!("Missing `ou` in search dn: {}", self.dn),
|
|
InvalidSearchDnKind::MultipleOu => {
|
|
format!("Multiple `ou` tables provided in search dn: {}", self.dn)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SearchDn(#[expect(unused)] Dn);
|
|
|
|
impl SearchDn {
|
|
pub fn from_dn_str(input: &str) -> Result<Self, InvalidSearchDn> {
|
|
let dn = Dn::from_dn_str(input).map_err(|e| InvalidSearchDn {
|
|
dn: input.to_string(),
|
|
kind: InvalidSearchDnKind::Malformed(e),
|
|
})?;
|
|
|
|
let Some(ou) = dn.keys.get("ou") else {
|
|
return Err(InvalidSearchDn {
|
|
dn: input.to_string(),
|
|
kind: InvalidSearchDnKind::NoOu,
|
|
});
|
|
};
|
|
|
|
if ou.len() != 1 {
|
|
return Err(InvalidSearchDn {
|
|
dn: input.to_string(),
|
|
kind: InvalidSearchDnKind::MultipleOu,
|
|
});
|
|
}
|
|
|
|
Ok(Self(dn))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum SearchError {
|
|
Db(BoxedError),
|
|
InvalidDn(InvalidSearchDn),
|
|
}
|
|
|
|
impl SearchError {
|
|
pub async fn error_message(
|
|
&self,
|
|
stream: &mut LdapStream,
|
|
msgid: i32,
|
|
) -> Result<(), LdapStreamError> {
|
|
let resp_msg = LdapMsg {
|
|
msgid,
|
|
op: LdapOp::SearchResultDone(LdapResult {
|
|
code: self.code(),
|
|
matcheddn: String::new(),
|
|
message: self.message(),
|
|
referral: vec![],
|
|
}),
|
|
ctrl: vec![],
|
|
};
|
|
|
|
stream.send(resp_msg).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl LdapReturnError for SearchError {
|
|
fn code(&self) -> LdapResultCode {
|
|
match self {
|
|
Self::Db(_e) => LdapResultCode::Unavailable,
|
|
Self::InvalidDn(e) => e.code(),
|
|
}
|
|
}
|
|
|
|
fn message(&self) -> String {
|
|
match self {
|
|
Self::Db(e) => format!("Database error: {e}"),
|
|
Self::InvalidDn(e) => e.message(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn search_success(
|
|
stream: &mut LdapStream,
|
|
msgid: i32,
|
|
entries: Vec<(LdapSearchResultEntry, Vec<LdapControl>)>,
|
|
) -> Result<(), LdapStreamError> {
|
|
let count = entries.len();
|
|
|
|
for (entry, ctrl) in entries {
|
|
tracing::debug!("Search result: {entry:?}");
|
|
stream
|
|
.send(LdapMsg {
|
|
msgid,
|
|
op: LdapOp::SearchResultEntry(entry),
|
|
ctrl,
|
|
})
|
|
.await?;
|
|
}
|
|
|
|
stream
|
|
.send(LdapMsg {
|
|
msgid,
|
|
op: LdapOp::SearchResultDone(LdapResult {
|
|
code: LdapResultCode::Success,
|
|
matcheddn: String::new(),
|
|
message: format!("Found {count} result(s)"),
|
|
referral: vec![],
|
|
}),
|
|
// TODO: implement LdapControl for pagination
|
|
ctrl: vec![],
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry {
|
|
let mut res: Vec<LdapPartialAttribute> = vec![];
|
|
for attr in req_attrs {
|
|
if let Some(attr_values) = match attr.as_str() {
|
|
"uid" => Some(vec![user.username.clone()]),
|
|
"cn" | "mail" => Some(vec![user.mail.clone()]),
|
|
// TODO: group membership
|
|
"memberof" => Some(vec![]),
|
|
// Copied from lldap output, not sure if we want to add/remove some classes depending on context
|
|
"objectclass" => Some(
|
|
vec!["inetOrgPerson", "posixAccount", "mailAccount", "person"]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect(),
|
|
),
|
|
_ => {
|
|
tracing::warn!("Ignoring unknown attr in search query: {attr}");
|
|
None
|
|
}
|
|
} {
|
|
res.push(LdapPartialAttribute {
|
|
atype: attr.clone(),
|
|
// LDAP response expects raw byte vec for each value
|
|
vals: attr_values.into_iter().map(Vec::from).collect(),
|
|
});
|
|
}
|
|
}
|
|
|
|
LdapSearchResultEntry {
|
|
dn: Dn::from_user(user).to_dn_string(),
|
|
attributes: res,
|
|
}
|
|
}
|
|
|
|
pub async fn search_by_everything(
|
|
stream: &mut LdapStream,
|
|
db: &Database,
|
|
sr: LdapSearchRequest,
|
|
msgid: i32,
|
|
) -> Result<(), LdapStreamError> {
|
|
// TODO: We should probably reuse the search DN somehow
|
|
if let Err(e) = SearchDn::from_dn_str(&sr.base) {
|
|
SearchError::InvalidDn(e)
|
|
.error_message(stream, msgid)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let users = match search_everything(db, &sr.filter).await {
|
|
Ok(users) => users,
|
|
Err(e) => {
|
|
SearchError::Db(e).error_message(stream, msgid).await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
// TODO: pagination ctrl
|
|
let entries = users
|
|
.into_iter()
|
|
.map(|user| (search_entry_from_user(&user, &sr.attrs), vec![]))
|
|
.collect();
|
|
search_success(stream, msgid, entries).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// TODO: pagination
|
|
// TODO: support groups (objectclass=groupOfNames) and membership
|
|
/// A very unoptimized search algorithm for the LDAP database.
|
|
///
|
|
/// Key information:
|
|
/// - ignores all filters that are not AND/OR/NOT/EQ (others may be added
|
|
/// if we find a valid usecase)
|
|
/// - iterates over every single entry in the database!!!!!
|
|
pub async fn search_everything(db: &Database, sr: &LdapFilter) -> Result<Vec<User>, BoxedError> {
|
|
Ok(db
|
|
.list_all_users()
|
|
.await?
|
|
.into_iter()
|
|
.filter(|user| user_matches_filter(user, sr))
|
|
.collect())
|
|
}
|
|
|
|
/// Checks if a user matches a single `LdapFilter`.
|
|
///
|
|
/// Used recursively to see if a user matches a filter overall.
|
|
pub fn user_matches_filter(user: &User, filter: &LdapFilter) -> bool {
|
|
// TODO: we should also receive the group memberships as argument here
|
|
// so we can match that in the future
|
|
// For now, we only match mail/mailalias/uid attributes
|
|
// TODO: implement mailalias, for now it's simply mapped to the mail attribute
|
|
match filter {
|
|
LdapFilter::And(filters) => {
|
|
for sub_filter in filters {
|
|
if !user_matches_filter(user, sub_filter) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
LdapFilter::Or(filters) => {
|
|
for sub_filter in filters {
|
|
if user_matches_filter(user, sub_filter) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
LdapFilter::Not(sub_filter) => !user_matches_filter(user, sub_filter),
|
|
LdapFilter::Equality(attr, value) => LdapAttribute::from_str(attr).map_or_else(
|
|
|e| {
|
|
tracing::warn!("Unrecognized attribute, considering no match: {e}");
|
|
false
|
|
},
|
|
|attr| user_matches_attribute(user, &attr, value),
|
|
),
|
|
_ => {
|
|
tracing::warn!("Unimplemented search filter, considering no match: {filter:?}");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: we want to support group relations here as argument soon
|
|
pub fn user_matches_attribute(user: &User, attribute: &LdapAttribute, value: &str) -> bool {
|
|
match attribute {
|
|
// TODO: should we lowercase the value here?
|
|
LdapAttribute::Uid => user.username == *value,
|
|
// TODO: should CN be different than the mail?
|
|
// TODO: should we lowercase the value here?
|
|
LdapAttribute::CommonName | LdapAttribute::Mail | LdapAttribute::MailAlias => {
|
|
user.mail == *value
|
|
}
|
|
// TODO: group membership
|
|
LdapAttribute::MemberOf => false,
|
|
LdapAttribute::ObjectClass => matches!(
|
|
// We lowercase the value here because there's no ambiguity
|
|
value.to_lowercase().as_ref(),
|
|
"inetorgperson" | "posixaccount" | "mailaccount" | "person"
|
|
),
|
|
}
|
|
}
|