From f8cd7ad023bafb9ca8743c7416d39836f566de92 Mon Sep 17 00:00:00 2001 From: Toby <80796996+T0byV@users.noreply.github.com> Date: Sun, 6 Jul 2025 23:42:53 +0200 Subject: [PATCH] server, ldap: add support for Subschema requests (#1071) Add a subschema entry to the rootDSE, which shows all attributes and objectclasses present on the LLDAP server, which is needed for some applications that need to index the LDAP server. The current implementation's goal is to have a bare minimum working subschema which follows the LDAP RFC. It also updates the GraphQL interface to follow the changes that have been made in actually separating out objectclasses, instead of having them as an attribute. Co-authored-by: nitnelave --- Cargo.lock | 3 + crates/access-control/src/lib.rs | 7 + crates/domain/Cargo.toml | 1 + crates/domain/src/schema.rs | 8 + crates/graphql-server/src/query.rs | 50 +++++- crates/ldap/Cargo.toml | 10 ++ crates/ldap/src/core/group.rs | 25 ++- crates/ldap/src/core/user.rs | 41 +++-- crates/ldap/src/core/utils.rs | 144 ++++++++++++++++- crates/ldap/src/handler.rs | 28 +++- crates/ldap/src/lib.rs | 3 + crates/ldap/src/search.rs | 247 ++++++++++++++++++++++++++++- schema.graphql | 6 + 13 files changed, 544 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1895198..79c895f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2659,6 +2659,7 @@ dependencies = [ "chrono", "derive_more 1.0.0", "image", + "itertools", "juniper", "lldap_auth", "pretty_assertions", @@ -2745,6 +2746,8 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "derive_more 1.0.0", + "itertools", "ldap3_proto", "lldap_access_control", "lldap_auth", diff --git a/crates/access-control/src/lib.rs b/crates/access-control/src/lib.rs index e420938..51d13cd 100644 --- a/crates/access-control/src/lib.rs +++ b/crates/access-control/src/lib.rs @@ -178,6 +178,13 @@ impl AccessControlledBackendHandler { Self { handler } } + pub fn get_schema_only_handler( + &self, + _validation_result: &ValidationResults, + ) -> Option<&impl ReadSchemaBackendHandler> { + Some(&self.handler) + } + pub fn get_admin_handler( &self, validation_result: &ValidationResults, diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 96a81ce..8000899 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -17,6 +17,7 @@ test = [] anyhow = "*" base64 = "0.21" bincode = "1.3" +itertools = "0.10" juniper = "0.15" serde_bytes = "0.11" diff --git a/crates/domain/src/schema.rs b/crates/domain/src/schema.rs index 62c2110..15e7d06 100644 --- a/crates/domain/src/schema.rs +++ b/crates/domain/src/schema.rs @@ -36,4 +36,12 @@ impl AttributeList { self.get_attribute_schema(name) .map(|a| (a.attribute_type, a.is_list)) } + + pub fn format_for_ldap_schema_description(&self) -> String { + self.attributes + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(" $ ") + } } diff --git a/crates/graphql-server/src/query.rs b/crates/graphql-server/src/query.rs index 55002ed..e16c301 100644 --- a/crates/graphql-server/src/query.rs +++ b/crates/graphql-server/src/query.rs @@ -10,7 +10,10 @@ use lldap_domain::{ }; use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler}; use lldap_domain_model::model::UserColumn; -use lldap_ldap::{UserFieldType, map_user_field}; +use lldap_ldap::{ + UserFieldType, get_default_group_object_classes, get_default_user_object_classes, + map_user_field, +}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{Instrument, Span, debug, debug_span}; @@ -522,10 +525,28 @@ impl From for AttributeSchema { attributes: DomainAttributeList, + default_classes: Vec, extra_classes: Vec, _phantom: std::marker::PhantomData>, } +#[derive(Clone)] +pub struct ObjectClassInfo { + object_class: String, + is_hardcoded: bool, +} + +#[graphql_object] +impl ObjectClassInfo { + fn object_class(&self) -> &str { + &self.object_class + } + + fn is_hardcoded(&self) -> bool { + self.is_hardcoded + } +} + #[graphql_object(context = Context)] impl AttributeList { fn attributes(&self) -> Vec> { @@ -540,12 +561,35 @@ impl AttributeList { fn extra_ldap_object_classes(&self) -> Vec { self.extra_classes.iter().map(|c| c.to_string()).collect() } + + fn ldap_object_classes(&self) -> Vec { + let mut all_object_classes: Vec = self + .default_classes + .iter() + .map(|c| ObjectClassInfo { + object_class: c.to_string(), + is_hardcoded: true, + }) + .collect(); + + all_object_classes.extend(self.extra_classes.iter().map(|c| ObjectClassInfo { + object_class: c.to_string(), + is_hardcoded: false, + })); + + all_object_classes + } } impl AttributeList { - fn new(attributes: DomainAttributeList, extra_classes: Vec) -> Self { + fn new( + attributes: DomainAttributeList, + default_classes: Vec, + extra_classes: Vec, + ) -> Self { Self { attributes, + default_classes, extra_classes, _phantom: std::marker::PhantomData, } @@ -563,12 +607,14 @@ impl Schema { fn user_schema(&self) -> AttributeList { AttributeList::::new( self.schema.get_schema().user_attributes.clone(), + get_default_user_object_classes(), self.schema.get_schema().extra_user_object_classes.clone(), ) } fn group_schema(&self) -> AttributeList { AttributeList::::new( self.schema.get_schema().group_attributes.clone(), + get_default_group_object_classes(), self.schema.get_schema().extra_group_object_classes.clone(), ) } diff --git a/crates/ldap/Cargo.toml b/crates/ldap/Cargo.toml index d309837..2c5d94e 100644 --- a/crates/ldap/Cargo.toml +++ b/crates/ldap/Cargo.toml @@ -12,6 +12,12 @@ repository.workspace = true anyhow = "*" ldap3_proto = "0.6.0" tracing = "*" +itertools = "0.10" + +[dependencies.derive_more] +features = ["from"] +default-features = false +version = "1" [dependencies.chrono] features = ["serde"] @@ -54,3 +60,7 @@ pretty_assertions = "1" [dev-dependencies.tokio] features = ["full"] version = "1.25" + +[dev-dependencies.lldap_domain] +path = "../domain" +features = ["test"] \ No newline at end of file diff --git a/crates/ldap/src/core/group.rs b/crates/ldap/src/core/group.rs index b51af7c..089a807 100644 --- a/crates/ldap/src/core/group.rs +++ b/crates/ldap/src/core/group.rs @@ -18,6 +18,24 @@ use lldap_domain::{ use lldap_domain_handlers::handler::{GroupListerBackendHandler, GroupRequestFilter}; use tracing::{debug, instrument, warn}; +pub const REQUIRED_GROUP_ATTRIBUTES: &[&str] = &["display_name"]; + +const DEFAULT_GROUP_OBJECT_CLASSES: &[&str] = &["groupOfUniqueNames", "groupOfNames"]; + +fn get_default_group_object_classes_as_bytes() -> Vec> { + DEFAULT_GROUP_OBJECT_CLASSES + .iter() + .map(|c| c.as_bytes().to_vec()) + .collect() +} + +pub fn get_default_group_object_classes() -> Vec { + DEFAULT_GROUP_OBJECT_CLASSES + .iter() + .map(|&c| LdapObjectClass::from(c)) + .collect() +} + pub fn get_group_attribute( group: &Group, base_dn_str: &str, @@ -28,7 +46,8 @@ pub fn get_group_attribute( ) -> Option>> { let attribute_values = match map_group_field(attribute, schema) { GroupFieldType::ObjectClass => { - let mut classes = vec![b"groupOfUniqueNames".to_vec()]; + let mut classes: Vec> = get_default_group_object_classes_as_bytes(); + classes.extend( schema .get_schema() @@ -205,7 +224,9 @@ fn convert_group_filter( GroupRequestFilter::from(false) })), GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from( - matches!(value_lc.as_str(), "groupofuniquenames" | "groupofnames") + get_default_group_object_classes() + .iter() + .any(|class| class.as_str().eq_ignore_ascii_case(value_lc.as_str())) || schema .get_schema() .extra_group_object_classes diff --git a/crates/ldap/src/core/user.rs b/crates/ldap/src/core/user.rs index a4f5f3b..e93fc05 100644 --- a/crates/ldap/src/core/user.rs +++ b/crates/ldap/src/core/user.rs @@ -21,6 +21,25 @@ use lldap_domain_handlers::handler::{UserListerBackendHandler, UserRequestFilter use lldap_domain_model::model::UserColumn; use tracing::{debug, instrument, warn}; +pub const REQUIRED_USER_ATTRIBUTES: &[&str] = &["user_id", "mail"]; + +const DEFAULT_USER_OBJECT_CLASSES: &[&str] = + &["inetOrgPerson", "posixAccount", "mailAccount", "person"]; + +fn get_default_user_object_classes_vec_u8() -> Vec> { + DEFAULT_USER_OBJECT_CLASSES + .iter() + .map(|c| c.as_bytes().to_vec()) + .collect() +} + +pub fn get_default_user_object_classes() -> Vec { + DEFAULT_USER_OBJECT_CLASSES + .iter() + .map(|&c| LdapObjectClass::from(c)) + .collect() +} + pub fn get_user_attribute( user: &User, attribute: &AttributeName, @@ -31,12 +50,8 @@ pub fn get_user_attribute( ) -> Option>> { let attribute_values = match map_user_field(attribute, schema) { UserFieldType::ObjectClass => { - let mut classes = vec![ - b"inetOrgPerson".to_vec(), - b"posixAccount".to_vec(), - b"mailAccount".to_vec(), - b"person".to_vec(), - ]; + let mut classes: Vec> = get_default_user_object_classes_vec_u8(); + classes.extend( schema .get_schema() @@ -227,13 +242,13 @@ fn convert_user_filter( Ok(UserRequestFilter::from(false)) } UserFieldType::ObjectClass => Ok(UserRequestFilter::from( - matches!( - value_lc.as_str(), - "person" | "inetorgperson" | "posixaccount" | "mailaccount" - ) || schema - .get_schema() - .extra_user_object_classes - .contains(&LdapObjectClass::from(value_lc)), + get_default_user_object_classes() + .iter() + .any(|class| class.as_str().eq_ignore_ascii_case(value_lc.as_str())) + || schema + .get_schema() + .extra_user_object_classes + .contains(&LdapObjectClass::from(value_lc)), )), UserFieldType::MemberOf => Ok(get_group_id_from_distinguished_name_or_plain_name( &value_lc, diff --git a/crates/ldap/src/core/utils.rs b/crates/ldap/src/core/utils.rs index e6cd212..8744ee1 100644 --- a/crates/ldap/src/core/utils.rs +++ b/crates/ldap/src/core/utils.rs @@ -1,10 +1,17 @@ -use crate::core::error::{LdapError, LdapResult}; +use crate::core::{ + error::{LdapError, LdapResult}, + group::{REQUIRED_GROUP_ATTRIBUTES, get_default_group_object_classes}, + user::{REQUIRED_USER_ATTRIBUTES, get_default_user_object_classes}, +}; use chrono::TimeZone; +use itertools::join; use ldap3_proto::LdapResultCode; use lldap_domain::{ public_schema::PublicSchema, + schema::{AttributeList, Schema}, types::{ - Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, GroupName, UserId, + Attribute, AttributeName, AttributeType, AttributeValue, Cardinality, GroupName, + LdapObjectClass, UserId, }, }; use lldap_domain_model::model::UserColumn; @@ -330,6 +337,139 @@ pub fn get_custom_attribute( }) } +#[derive(derive_more::From)] +pub struct ObjectClassList(Vec); + +// See RFC4512 section 4.2.1 "objectClasses" +impl ObjectClassList { + pub fn format_for_ldap_schema_description(&self) -> String { + join(self.0.iter().map(|c| format!("'{}'", c)), " ") + } +} + +// See RFC4512 section 4.2 "Subschema Subentries" +// This struct holds all information on what attributes and objectclasses are present on the server. +// It can be used to 'index' a server using a LDAP subschema call. +pub struct LdapSchemaDescription { + base: PublicSchema, + user_object_classes: ObjectClassList, + group_object_classes: ObjectClassList, +} + +impl LdapSchemaDescription { + pub fn from(schema: PublicSchema) -> Self { + let mut user_object_classes = get_default_user_object_classes(); + user_object_classes.extend(schema.get_schema().extra_user_object_classes.clone()); + let mut group_object_classes = get_default_group_object_classes(); + group_object_classes.extend(schema.get_schema().extra_group_object_classes.clone()); + + Self { + base: schema, + user_object_classes: ObjectClassList(user_object_classes), + group_object_classes: ObjectClassList(group_object_classes), + } + } + + fn schema(&self) -> &Schema { + self.base.get_schema() + } + + pub fn user_object_classes(&self) -> &ObjectClassList { + &self.user_object_classes + } + + pub fn group_object_classes(&self) -> &ObjectClassList { + &self.group_object_classes + } + + pub fn required_user_attributes(&self) -> AttributeList { + let attributes = self + .schema() + .user_attributes + .attributes + .iter() + .filter(|a| REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str())) + .cloned() + .collect(); + + AttributeList { attributes } + } + + pub fn optional_user_attributes(&self) -> AttributeList { + let attributes = self + .schema() + .user_attributes + .attributes + .iter() + .filter(|a| !REQUIRED_USER_ATTRIBUTES.contains(&a.name.as_str())) + .cloned() + .collect(); + + AttributeList { attributes } + } + + pub fn required_group_attributes(&self) -> AttributeList { + let attributes = self + .schema() + .group_attributes + .attributes + .iter() + .filter(|a| REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str())) + .cloned() + .collect(); + + AttributeList { attributes } + } + + pub fn optional_group_attributes(&self) -> AttributeList { + let attributes = self + .schema() + .group_attributes + .attributes + .iter() + .filter(|a| !REQUIRED_GROUP_ATTRIBUTES.contains(&a.name.as_str())) + .cloned() + .collect(); + + AttributeList { attributes } + } + + // See RFC4512 section 4.2.2 "attributeTypes" + // Parameter 'index_offset' is an offset for the enumeration of this list of attributes, + // it has been preceeded by the list of hardcoded attributes. + pub fn formatted_attribute_list(&self, index_offset: usize) -> Vec> { + let mut formatted_list: Vec> = Vec::new(); + + for (index, attribute) in self.all_attributes().attributes.into_iter().enumerate() { + formatted_list.push( + format!( + "( 2.{} NAME '{}' DESC 'LLDAP: {}' SUP {:?} )", + (index + index_offset), + attribute.name, + if attribute.is_hardcoded { + "builtin attribute" + } else { + "custom attribute" + }, + attribute.attribute_type + ) + .into_bytes() + .to_vec(), + ) + } + + formatted_list + } + + pub fn all_attributes(&self) -> AttributeList { + let mut combined_attributes = self.schema().user_attributes.attributes.clone(); + combined_attributes.extend_from_slice(&self.schema().group_attributes.attributes); + AttributeList { + attributes: combined_attributes, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ldap/src/handler.rs b/crates/ldap/src/handler.rs index bb425d6..bde29ef 100644 --- a/crates/ldap/src/handler.rs +++ b/crates/ldap/src/handler.rs @@ -7,8 +7,8 @@ use crate::{ create, delete, modify, password::{self, do_password_modification}, search::{ - self, is_root_dse_request, make_search_error, make_search_request, make_search_success, - root_dse_response, + self, is_root_dse_request, is_subschema_entry_request, make_ldap_subschema_entry, + make_search_error, make_search_request, make_search_success, root_dse_response, }, }; use ldap3_proto::proto::{ @@ -18,8 +18,8 @@ use ldap3_proto::proto::{ }; use lldap_access_control::AccessControlledBackendHandler; use lldap_auth::access_control::ValidationResults; -use lldap_domain::types::AttributeName; -use lldap_domain_handlers::handler::{BackendHandler, LoginHandler}; +use lldap_domain::{public_schema::PublicSchema, types::AttributeName}; +use lldap_domain_handlers::handler::{BackendHandler, LoginHandler, ReadSchemaBackendHandler}; use lldap_opaque_handler::OpaqueHandler; use tracing::{debug, instrument}; @@ -141,6 +141,26 @@ impl LdapHandler LdapOp { atype: "isGlobalCatalogReady".to_string(), vals: vec![b"false".to_vec()], }, + LdapPartialAttribute { + atype: "subschemaSubentry".to_string(), + vals: vec![b"cn=Subschema".to_vec()], + }, + ], + }) +} + +pub fn make_ldap_subschema_entry(schema: PublicSchema) -> LdapOp { + let ldap_schema_description: LdapSchemaDescription = LdapSchemaDescription::from(schema); + let current_time_utc = Utc::now().format("%Y%m%d%H%M%SZ").to_string().into_bytes(); + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=Subschema".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "structuralObjectClass".to_string(), + vals: vec![b"subentry".to_vec()], + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![b"top".to_vec(), b"subentry".to_vec(), b"subschema".to_vec(), b"extensibleObject".to_vec()], + }, + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"Subschema".to_vec()], + }, + LdapPartialAttribute { + atype: "createTimestamp".to_string(), + vals: vec![current_time_utc.to_vec()], + }, + LdapPartialAttribute { + atype: "modifyTimestamp".to_string(), + vals: vec![current_time_utc.to_vec()], + }, + LdapPartialAttribute { + atype: "ldapSyntaxes".to_string(), + vals: vec![ + b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )".to_vec(), + ], + }, + LdapPartialAttribute { + atype: "attributeTypes".to_string(), + vals: { + let hardcoded_attributes = [ + b"( 2.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), + b"( 2.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), + b"( 2.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), + b"( 2.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + ]; + let num_hardcoded_attributes = hardcoded_attributes.len(); + hardcoded_attributes.into_iter().chain( + ldap_schema_description + .formatted_attribute_list(num_hardcoded_attributes) + ).collect() + } + }, + LdapPartialAttribute { + atype: "objectClasses".to_string(), + vals: vec![ + format!( + "( 3.0 NAME ( {} ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( {} ) MAY ( {} ) )", + ldap_schema_description.user_object_classes().format_for_ldap_schema_description(), + ldap_schema_description.required_user_attributes().format_for_ldap_schema_description(), + ldap_schema_description.optional_user_attributes().format_for_ldap_schema_description(), + ).into_bytes(), + format!( + "( 3.1 NAME ( {} ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( {} ) MAY ( {} ) )", + ldap_schema_description.group_object_classes().format_for_ldap_schema_description(), + ldap_schema_description.required_group_attributes().format_for_ldap_schema_description(), + ldap_schema_description.optional_group_attributes().format_for_ldap_schema_description(), + ).into_bytes(), + ], + }, + LdapPartialAttribute { + atype: "subschemaSubentry".to_string(), + vals: vec![b"cn=Subschema".to_vec()], + }, ], }) } @@ -179,6 +260,10 @@ pub(crate) fn is_root_dse_request(request: &LdapSearchRequest) -> bool { false } +pub(crate) fn is_subschema_entry_request(request: &LdapSearchRequest) -> bool { + request.base == "cn=Subschema" && request.scope == LdapSearchScope::Base +} + async fn do_search_internal( ldap_info: &LdapInfo, backend_handler: &impl UserAndGroupListerBackendHandler, @@ -318,7 +403,7 @@ mod tests { setup_bound_handler_with_group, setup_bound_readonly_handler, }, }; - use chrono::TimeZone; + use chrono::{DateTime, Duration, NaiveDateTime, TimeZone}; use ldap3_proto::proto::{LdapDerefAliases, LdapSearchScope, LdapSubstringFilter}; use lldap_domain::{ schema::{AttributeList, AttributeSchema, Schema}, @@ -356,6 +441,156 @@ mod tests { ); } + fn assert_timestamp_within_margin( + timestamp_bytes: &[u8], + base_timestamp_dt: DateTime, + time_margin: Duration, + ) { + let timestamp_str = + std::str::from_utf8(timestamp_bytes).expect("Invalid conversion from UTF-8 to string"); + let timestamp_naive = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%SZ") + .expect("Invalid timestamp format"); + let timestamp_dt: DateTime = Utc.from_utc_datetime(×tamp_naive); + + let within_range = (base_timestamp_dt - timestamp_dt).abs() <= time_margin; + + assert!( + within_range, + "Timestamp not within range: expected within [{} - {}], got [{}]", + base_timestamp_dt - time_margin, + base_timestamp_dt + time_margin, + timestamp_dt + ); + } + + #[tokio::test] + async fn test_subschema_response() { + let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + + let request = LdapSearchRequest { + base: "cn=Subschema".to_string(), + scope: LdapSearchScope::Base, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter: LdapFilter::Present("objectClass".to_string()), + attrs: vec!["supportedExtension".to_string()], + }; + + let actual_reponse: Vec = ldap_handler.do_search_or_dse(&request).await.unwrap(); + + let LdapOp::SearchResultEntry(search_result_entry) = &actual_reponse[0] else { + panic!("Expected SearchResultEntry"); + }; + + let attrs = &search_result_entry.attributes; + assert_eq!(attrs.len(), 9); + assert_eq!(search_result_entry.dn, "cn=Subschema".to_owned()); + + assert_eq!( + attrs[0], + LdapPartialAttribute { + atype: "structuralObjectClass".to_owned(), + vals: vec![b"subentry".to_vec()] + } + ); + + assert_eq!( + attrs[1], + LdapPartialAttribute { + atype: "objectClass".to_owned(), + vals: vec![ + b"top".to_vec(), + b"subentry".to_vec(), + b"subschema".to_vec(), + b"extensibleObject".to_vec() + ] + } + ); + + assert_eq!( + attrs[2], + LdapPartialAttribute { + atype: "cn".to_owned(), + vals: vec![b"Subschema".to_vec()] + } + ); + + let check_timestamp_attribute = |attr: &LdapPartialAttribute, expected_type: &str| { + assert_eq!(attr.atype, expected_type); + assert_eq!(attr.vals.len(), 1); + assert_timestamp_within_margin(&attr.vals[0], Utc::now(), Duration::seconds(300)); + }; + check_timestamp_attribute(&attrs[3], "createTimestamp"); + check_timestamp_attribute(&attrs[4], "modifyTimestamp"); + + assert_eq!( + attrs[5], + LdapPartialAttribute { + atype: "ldapSyntaxes".to_owned(), + vals: vec![ + b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )" + .to_vec() + ] + } + ); + + assert_eq!( + attrs[6], + LdapPartialAttribute { + atype: "attributeTypes".to_owned(), + vals: vec![ + b"( 2.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), + b"( 2.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), + b"( 2.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), + b"( 2.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + b"( 2.4 NAME 'avatar' DESC 'LLDAP: builtin attribute' SUP JpegPhoto )".to_vec(), + b"( 2.5 NAME 'creation_date' DESC 'LLDAP: builtin attribute' SUP DateTime )" + .to_vec(), + b"( 2.6 NAME 'display_name' DESC 'LLDAP: builtin attribute' SUP String )" + .to_vec(), + b"( 2.7 NAME 'first_name' DESC 'LLDAP: builtin attribute' SUP String )" + .to_vec(), + b"( 2.8 NAME 'last_name' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(), + b"( 2.9 NAME 'mail' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(), + b"( 2.10 NAME 'user_id' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(), + b"( 2.11 NAME 'uuid' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(), + b"( 2.12 NAME 'creation_date' DESC 'LLDAP: builtin attribute' SUP DateTime )" + .to_vec(), + b"( 2.13 NAME 'display_name' DESC 'LLDAP: builtin attribute' SUP String )" + .to_vec(), + b"( 2.14 NAME 'group_id' DESC 'LLDAP: builtin attribute' SUP Integer )" + .to_vec(), + b"( 2.15 NAME 'uuid' DESC 'LLDAP: builtin attribute' SUP String )".to_vec() + ] + } + ); + + assert_eq!(attrs[7], + LdapPartialAttribute { + atype: "objectClasses".to_owned(), + vals: vec![ + b"( 3.0 NAME ( 'inetOrgPerson' 'posixAccount' 'mailAccount' 'person' 'customUserClass' ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( mail $ user_id ) MAY ( avatar $ creation_date $ display_name $ first_name $ last_name $ uuid ) )".to_vec(), + b"( 3.1 NAME ( 'groupOfUniqueNames' 'groupOfNames' ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( display_name ) MAY ( creation_date $ group_id $ uuid ) )".to_vec(), + ] + } + ); + + assert_eq!( + attrs[8], + LdapPartialAttribute { + atype: "subschemaSubentry".to_owned(), + vals: vec![b"cn=Subschema".to_vec()] + } + ); + + assert_eq!(actual_reponse[1], make_search_success()); + } + #[tokio::test] async fn test_search_regular_user() { let mut mock = MockTestBackendHandler::new(); @@ -710,7 +945,7 @@ mod tests { }, LdapPartialAttribute { atype: "objectClass".to_string(), - vals: vec![b"groupOfUniqueNames".to_vec(),] + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec(),], }, LdapPartialAttribute { atype: "uniqueMember".to_string(), @@ -738,7 +973,7 @@ mod tests { }, LdapPartialAttribute { atype: "objectClass".to_string(), - vals: vec![b"groupOfUniqueNames".to_vec(),] + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec(),], }, LdapPartialAttribute { atype: "uniqueMember".to_string(), @@ -1433,7 +1668,7 @@ mod tests { }, LdapPartialAttribute { atype: "objectClass".to_string(), - vals: vec![b"groupOfUniqueNames".to_vec(),] + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec(),], }, ], }), @@ -1569,7 +1804,7 @@ mod tests { }, LdapPartialAttribute { atype: "objectclass".to_string(), - vals: vec![b"groupOfUniqueNames".to_vec()], + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec()], }, // UID LdapPartialAttribute { diff --git a/schema.graphql b/schema.graphql index caf6a9c..c9bfaba 100644 --- a/schema.graphql +++ b/schema.graphql @@ -80,6 +80,11 @@ input CreateUserInput { "Attributes." attributes: [AttributeValueInput!] } +type ObjectClassInfo { + objectClass: String! + isHardcoded: Boolean! +} + type AttributeSchema { name: String! attributeType: AttributeType! @@ -186,6 +191,7 @@ enum AttributeType { type AttributeList { attributes: [AttributeSchema!]! extraLdapObjectClasses: [String!]! + ldapObjectClasses: [ObjectClassInfo!]! } type Success {