using System.Collections.Concurrent; using System.DirectoryServices; using SGU.AuthBroker.Core.Directory; using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Profiles; using SGU.AuthBroker.Options; namespace SGU.AuthBroker.Services; public sealed class ActiveDirectorySynchronizer( BrokerOptions options, ILogger logger) : IActiveDirectorySynchronizer { private const string GenderMetadataPrefix = "SGU-Gender:"; private const int InfoAttributeMaximumLength = 1024; private const int AccountDisabled = 0x0002; private const int NormalAccount = 0x0200; private static readonly AuthenticationTypes BindFlags = AuthenticationTypes.Secure | AuthenticationTypes.Signing | AuthenticationTypes.Sealing; private readonly ActiveDirectoryOptions options = options.Directory; private readonly ConcurrentDictionary userLocks = new(StringComparer.OrdinalIgnoreCase); public async Task SynchronizeAsync( UserIdentity identity, InstitutionalProfile? profile, string password, CancellationToken cancellationToken) { SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { try { return await Task.Run( () => Synchronize(identity, profile, password), cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { logger.LogError( BrokerEventIds.DirectorySynchronizationFailure, exception, "Active Directory synchronization failed for {InstitutionalUser} with role {Role}.", identity.UserName, identity.Role); throw; } } finally { gate.Release(); if (gate.CurrentCount == 1) { userLocks.TryRemove(new KeyValuePair(identity.UserName, gate)); } } } private DirectorySyncResult Synchronize( UserIdentity identity, InstitutionalProfile? profile, string password) { string targetOuDn = options.GetOuDn(identity.Role); using DirectoryEntry root = Bind(options.BaseDn); using DirectoryEntry targetOu = BindOrCreateOu(targetOuDn, root); using DirectorySearcher searcher = new(root) { Filter = $"(&(objectCategory=person)(objectClass=user)(sAMAccountName={EscapeLdapFilter(identity.UserName)}))", SearchScope = SearchScope.Subtree, PageSize = 1, SizeLimit = 1 }; searcher.PropertiesToLoad.Add("distinguishedName"); SearchResult? result = searcher.FindOne(); bool created = result is null; bool moved = false; DirectoryEntry? user = null; try { if (created) { user = targetOu.Children.Add($"CN={EscapeRdn(identity.UserName)}", "user"); user.Properties["sAMAccountName"].Value = identity.UserName; user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}"; user.Properties["displayName"].Value = identity.UserName; user.CommitChanges(); } else { user = result!.GetDirectoryEntry(); string distinguishedName = Convert.ToString(user.Properties["distinguishedName"].Value) ?? string.Empty; string parentDn = ParentDn(distinguishedName); if (!string.Equals(parentDn, targetOuDn, StringComparison.OrdinalIgnoreCase)) { user.MoveTo(targetOu); moved = true; } user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}"; user.CommitChanges(); } // Role membership is part of account provisioning, not optional // enrichment. Do it before changing the password so a missing or // inaccessible authorization group cannot leave a newly usable // account without its required classification. EnsureRoleGroupMembership(user, identity); // The exact institutional password received by the broker is passed to AD. // It is not derived, transformed, written to disk, or included in logs. user.Invoke("SetPassword", [password]); int flags = user.Properties["userAccountControl"].Value is int currentFlags ? currentFlags : NormalAccount; user.Properties["userAccountControl"].Value = (flags | NormalAccount) & ~AccountDisabled; user.Properties["pwdLastSet"].Value = -1; user.CommitChanges(); TryApplyProfile(user, identity, profile, options.DefaultCompany, logger); TryEnsureRemoteDesktopGroupMembership(user, identity.UserName); return new DirectorySyncResult( options.DomainNetbios, identity.UserName, $"{identity.UserName}@{options.UpnSuffix}", created, moved); } finally { user?.Dispose(); } } private static void TryApplyProfile( DirectoryEntry user, UserIdentity identity, InstitutionalProfile? profile, string defaultCompany, ILogger logger) { try { SetOptionalProperty(user, "company", defaultCompany); if (profile is not null) { SetOptionalProperty(user, "displayName", profile.DisplayName); SetOptionalProperty(user, "givenName", profile.GivenName); SetOptionalProperty(user, "sn", profile.Surname); SetOptionalProperty(user, "mail", profile.Email); SetOptionalProperty(user, "title", profile.JobTitle); SetOptionalProperty(user, "department", profile.Department); SetOptionalProperty(user, "employeeType", profile.EmployeeType); SetOptionalProperty(user, "streetAddress", profile.StreetAddress); SetOptionalProperty(user, "l", profile.City); SetOptionalProperty(user, "st", profile.State); SetOptionalProperty(user, "postalCode", profile.PostalCode); SetGenderMetadata(user, profile.Gender, identity.UserName, logger); if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) { SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); } } user.CommitChanges(); } catch (Exception exception) { // Metadata is intentionally best-effort. User creation, password sync, // and account enablement have already committed successfully. logger.LogWarning( BrokerEventIds.DirectoryOptionalMetadataFailure, exception, "Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.", identity.UserName); try { user.RefreshCache(); } catch { // Discarding the optional property cache must not alter the // already committed password synchronization result. } } } private static void SetOptionalProperty(DirectoryEntry entry, string propertyName, string? value) { if (!string.IsNullOrWhiteSpace(value)) { entry.Properties[propertyName].Value = value; } } private static void SetGenderMetadata( DirectoryEntry entry, InstitutionalGender? gender, string institutionalUser, ILogger logger) { if (gender is null) { return; } string existing = Convert.ToString(entry.Properties["info"].Value) ?? string.Empty; string? updated = MergeGenderMetadata(existing, gender); if (updated is null) { logger.LogWarning( BrokerEventIds.DirectoryOptionalMetadataFailure, "Gender metadata was not written for {InstitutionalUser} because the Active Directory info attribute has no remaining capacity.", institutionalUser); return; } entry.Properties["info"].Value = updated; } internal static string? MergeGenderMetadata( string? existing, InstitutionalGender? gender) { if (gender is null) { return null; } string managedLine = $"{GenderMetadataPrefix} {gender}"; string normalizedExisting = (existing ?? string.Empty) .Replace("\r\n", "\n", StringComparison.Ordinal) .Replace('\r', '\n'); string[] preservedLines = string.IsNullOrEmpty(normalizedExisting) ? [] : normalizedExisting .Split('\n') .Where(line => !line.TrimStart().StartsWith( GenderMetadataPrefix, StringComparison.OrdinalIgnoreCase)) .ToArray(); string updated = string.Join("\r\n", preservedLines.Append(managedLine)); return updated.Length <= InfoAttributeMaximumLength ? updated : null; } private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity) { user.RefreshCache(["distinguishedName"]); string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value); if (string.IsNullOrWhiteSpace(userDn)) { throw new InvalidOperationException($"Active Directory did not return a distinguished name for {identity.UserName}."); } string groupDn = options.GetGroupDn(identity.Role); using DirectoryEntry group = Bind(groupDn); _ = group.NativeObject; if (group.Properties["member"].Contains(userDn)) { return; } group.Properties["member"].Add(userDn); group.CommitChanges(); logger.LogInformation( BrokerEventIds.DirectoryRoleGroupMembershipAdded, "Added {InstitutionalUser} with role {Role} to Active Directory security group {GroupDn}.", identity.UserName, identity.Role, groupDn); } private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser) { if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn)) { return; } try { user.RefreshCache(["distinguishedName"]); string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value); if (string.IsNullOrWhiteSpace(userDn)) { return; } using DirectoryEntry group = Bind(options.RemoteDesktopGroupDn); _ = group.NativeObject; if (!group.Properties["member"].Contains(userDn)) { group.Properties["member"].Add(userDn); group.CommitChanges(); } } catch (Exception exception) { // Remote access is lab policy and must not invalidate a completed // password synchronization if the optional group is unavailable. logger.LogWarning( BrokerEventIds.DirectoryGroupMembershipFailure, exception, "Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.", institutionalUser); } } private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root) { try { DirectoryEntry existing = Bind(ouDn); _ = existing.NativeObject; return existing; } catch (DirectoryServicesCOMException) when (options.CreateMissingOus) { string parent = ParentDn(ouDn); if (!ouDn.EndsWith($",{options.BaseDn}", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Automatic OU creation is limited to descendants of BaseDn."); } string rdn = ouDn[..FirstUnescapedComma(ouDn)]; DirectoryEntry? parentEntry = null; try { DirectoryEntry container = root; if (!string.Equals(parent, options.BaseDn, StringComparison.OrdinalIgnoreCase)) { parentEntry = BindOrCreateOu(parent, root); container = parentEntry; } DirectoryEntry created = container.Children.Add(rdn, "organizationalUnit"); created.CommitChanges(); return created; } finally { parentEntry?.Dispose(); } } } private DirectoryEntry Bind(string distinguishedName) => new($"LDAP://{options.LdapHost}/{distinguishedName}", null, null, BindFlags); private static string ParentDn(string distinguishedName) { int comma = FirstUnescapedComma(distinguishedName); return comma < 0 ? string.Empty : distinguishedName[(comma + 1)..]; } private static int FirstUnescapedComma(string value) { bool escaped = false; for (int i = 0; i < value.Length; i++) { if (escaped) { escaped = false; continue; } if (value[i] == '\\') { escaped = true; } else if (value[i] == ',') { return i; } } return -1; } private static string EscapeLdapFilter(string value) => value .Replace("\\", "\\5c", StringComparison.Ordinal) .Replace("*", "\\2a", StringComparison.Ordinal) .Replace("(", "\\28", StringComparison.Ordinal) .Replace(")", "\\29", StringComparison.Ordinal) .Replace("\0", "\\00", StringComparison.Ordinal); private static string EscapeRdn(string value) => value .Replace("\\", "\\\\", StringComparison.Ordinal) .Replace(",", "\\,", StringComparison.Ordinal) .Replace("+", "\\+", StringComparison.Ordinal) .Replace("\"", "\\\"", StringComparison.Ordinal) .Replace("<", "\\<", StringComparison.Ordinal) .Replace(">", "\\>", StringComparison.Ordinal) .Replace(";", "\\;", StringComparison.Ordinal) .Replace("=", "\\=", StringComparison.Ordinal); }