From e3243ce6b0569f81b198863eec806f1f44c772a4 Mon Sep 17 00:00:00 2001
From: Firstyear <william@blackhats.net.au>
Date: Fri, 14 Mar 2025 10:48:05 +1000
Subject: [PATCH] Support rfc2307 memberUid in sync operations. (#3466)

A lot of legacy directory servers will use rfc2307 schema where
members of groups are stored as the uid instead of a dn. Within
kani, we absolutely need this to be a dn, else we risk accidentally
adding kanidm entries into ldap synced groups which isn't what we
want.

If we have an rfc2307 schema, then we pre-resolve the uid to the
member dn so that kanidm gets the correct information.
---
 tools/iam_migrations/ldap/src/config.rs |  10 ++
 tools/iam_migrations/ldap/src/main.rs   | 135 +++++++++++++++++++++---
 2 files changed, 128 insertions(+), 17 deletions(-)

diff --git a/tools/iam_migrations/ldap/src/config.rs b/tools/iam_migrations/ldap/src/config.rs
index c7a83b7ad..dd7c48049 100644
--- a/tools/iam_migrations/ldap/src/config.rs
+++ b/tools/iam_migrations/ldap/src/config.rs
@@ -59,6 +59,14 @@ fn group_attr_gidnumber() -> String {
     Attribute::GidNumber.to_string()
 }
 
+#[derive(Debug, Deserialize, Default)]
+#[serde(rename_all = "lowercase")]
+pub enum GroupAttrSchema {
+    Rfc2307,
+    #[default]
+    Rfc2307Bis,
+}
+
 #[derive(Debug, Deserialize)]
 pub struct Config {
     pub sync_token: String,
@@ -102,6 +110,8 @@ pub struct Config {
     pub group_attr_gidnumber: String,
     #[serde(default = "group_attr_member")]
     pub group_attr_member: String,
+    #[serde(default)]
+    pub group_attr_schema: GroupAttrSchema,
 
     #[serde(flatten)]
     pub entry_map: BTreeMap<Uuid, EntryConfig>,
diff --git a/tools/iam_migrations/ldap/src/main.rs b/tools/iam_migrations/ldap/src/main.rs
index 8e5d52894..33de9fc9a 100644
--- a/tools/iam_migrations/ldap/src/main.rs
+++ b/tools/iam_migrations/ldap/src/main.rs
@@ -11,16 +11,26 @@
 // We allow expect since it forces good error messages at the least.
 #![allow(clippy::expect_used)]
 
-mod config;
-mod error;
-
-use crate::config::{Config, EntryConfig};
+use crate::config::{Config, EntryConfig, GroupAttrSchema};
 use crate::error::SyncError;
 use chrono::Utc;
 use clap::Parser;
 use cron::Schedule;
+use kanidm_client::KanidmClientBuilder;
+use kanidm_lib_file_permissions::readonly as file_permissions_readonly;
 use kanidm_proto::constants::ATTR_OBJECTCLASS;
+use kanidm_proto::scim_v1::{
+    MultiValueAttr, ScimEntry, ScimSshPubKey, ScimSyncGroup, ScimSyncPerson, ScimSyncRequest,
+    ScimSyncRetentionMode, ScimSyncState,
+};
+#[cfg(target_family = "unix")]
+use kanidm_utils_users::{get_current_gid, get_current_uid, get_effective_gid, get_effective_uid};
 use kanidmd_lib::prelude::Attribute;
+use ldap3_client::{
+    proto::{self, LdapFilter},
+    LdapClient, LdapClientBuilder, LdapSyncRepl, LdapSyncReplEntry, LdapSyncStateValue,
+};
+use std::collections::{BTreeMap, BTreeSet};
 use std::fs::metadata;
 use std::fs::File;
 use std::io::Read;
@@ -38,22 +48,12 @@ use tokio::net::TcpListener;
 use tokio::runtime;
 use tokio::sync::broadcast;
 use tokio::time::sleep;
-
 use tracing::{debug, error, info, warn};
 use tracing_subscriber::prelude::*;
 use tracing_subscriber::{fmt, EnvFilter};
 
-use kanidm_client::KanidmClientBuilder;
-use kanidm_lib_file_permissions::readonly as file_permissions_readonly;
-use kanidm_proto::scim_v1::{
-    MultiValueAttr, ScimEntry, ScimSshPubKey, ScimSyncGroup, ScimSyncPerson, ScimSyncRequest,
-    ScimSyncRetentionMode, ScimSyncState,
-};
-
-#[cfg(target_family = "unix")]
-use kanidm_utils_users::{get_current_gid, get_current_uid, get_effective_gid, get_effective_uid};
-
-use ldap3_client::{proto, LdapClientBuilder, LdapSyncRepl, LdapSyncReplEntry, LdapSyncStateValue};
+mod config;
+mod error;
 
 include!("./opt.rs");
 
@@ -343,7 +343,7 @@ async fn run_sync(
         LdapSyncRepl::Success {
             cookie,
             refresh_deletes: _,
-            entries,
+            mut entries,
             delete_uuids,
             present_uuids,
         } => {
@@ -393,6 +393,14 @@ async fn run_sync(
                 }
             };
 
+            if matches!(sync_config.group_attr_schema, GroupAttrSchema::Rfc2307) {
+                // Since the schema is rfc 2307, this means that the names of members
+                // in any group are uids, not dn's, so we need to resolve these now.
+                resolve_member_uid_to_dn(&mut ldap_client, &mut entries, sync_config)
+                    .await
+                    .map_err(|_| SyncError::Preprocess)?;
+            };
+
             let entries = match process_ldap_sync_result(entries, sync_config).await {
                 Ok(ssr) => ssr,
                 Err(()) => {
@@ -444,6 +452,99 @@ async fn run_sync(
     // done!
 }
 
+async fn resolve_member_uid_to_dn(
+    ldap_client: &mut LdapClient,
+    ldap_entries: &mut [LdapSyncReplEntry],
+    sync_config: &Config,
+) -> Result<(), ()> {
+    let mut lookup_cache: BTreeMap<String, String> = Default::default();
+
+    for sync_entry in ldap_entries.iter_mut() {
+        let oc = sync_entry
+            .entry
+            .attrs
+            .get(ATTR_OBJECTCLASS)
+            .ok_or_else(|| {
+                error!("Invalid entry - no object class {}", sync_entry.entry.dn);
+            })?;
+
+        if !oc.contains(&sync_config.group_objectclass) {
+            // Not a group, skip.
+            continue;
+        }
+
+        // It's a group, does it have memberUid? We pop this out here
+        // because we plan to replace it.
+        let members = sync_entry
+            .entry
+            .remove_ava(&sync_config.group_attr_member)
+            .unwrap_or_default();
+
+        // Now, search all the members to dns.
+        let mut resolved_members: BTreeSet<String> = Default::default();
+
+        for member_uid in members {
+            if let Some(member_dn) = lookup_cache.get(&member_uid) {
+                resolved_members.insert(member_dn.to_string());
+            } else {
+                // Not in cache, search it. We use a syncrepl request here as this
+                // can bypass some query limits. Note we set the sync cookie to None.
+                let filter = LdapFilter::And(vec![
+                    // Always put uid first as openldap can't query optimise.
+                    LdapFilter::Equality(
+                        sync_config.person_attr_user_name.clone(),
+                        member_uid.clone(),
+                    ),
+                    LdapFilter::Equality(
+                        ATTR_OBJECTCLASS.into(),
+                        sync_config.person_objectclass.clone(),
+                    ),
+                ]);
+
+                let mode = proto::SyncRequestMode::RefreshOnly;
+                let sync_result = ldap_client
+                    .syncrepl(sync_config.ldap_sync_base_dn.clone(), filter, None, mode)
+                    .await
+                    .map_err(|err| {
+                        debug!(?member_uid, ?sync_entry.entry_uuid);
+                        error!(
+                            ?err,
+                            "Failed to perform syncrepl to resolve members from ldap"
+                        );
+                    })?;
+
+                // Get the memberDN out now.
+                let member_dn = match sync_result {
+                    LdapSyncRepl::Success { mut entries, .. } => {
+                        let Some(resolved_entry) = entries.pop() else {
+                            warn!(?member_uid, "Unable to resolve member, no matching entries");
+                            continue;
+                        };
+
+                        resolved_entry.entry.dn.clone()
+                    }
+                    _ => {
+                        error!("Invalid sync repl result state");
+                        return Err(());
+                    }
+                };
+
+                // cache it.
+                lookup_cache.insert(member_uid, member_dn.clone());
+                resolved_members.insert(member_dn);
+            }
+        }
+
+        // Put the members back in resolved as DN's now.
+        sync_entry
+            .entry
+            .attrs
+            .insert(sync_config.group_attr_member.clone(), resolved_members);
+    }
+
+    Ok(())
+}
+
 async fn process_ldap_sync_result(
     ldap_entries: Vec<LdapSyncReplEntry>,
     sync_config: &Config,