Skip to content

Commit 9135fee

Browse files
authored
TfsExportUsersForMappingProcessor: allow matching users by email (#2526)
Our scenario is: - Source server is on-premise TFS 2018 connected to on-premise Active Directory. - Target server is Azure DevOps connected to Azure Entra ID (formerly Azure Active Directory). Even when the users are synchronized between the Active Directiories, the users' display names are often different between the two. So default matchind by display name does not work for us. I implemented the possibility to match users by email (at least for us it works for most cases). This matching is turned off by default. When turned on, it has higher precedence than matching by display name. So first, match by email is looked for and if no match is found, default matching by display name is used. Because of this I expanded properties of the users loaded from server. I still do not use all of them, but I want to keep them for future use (I have another PR prepared). Because of this, I also added mapper ([Riok.Mapperly](https://github.com/riok/mapperly)), so I do not need to copy properties one by one when creating `IdentityItemData` object from `Identity` object.
2 parents 0eb315d + 9498cd8 commit 9135fee

File tree

6 files changed

+68
-24
lines changed

6 files changed

+68
-24
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
4141
<PackageVersion Include="OxyPlot.Core" Version="2.2.0" />
4242
<PackageVersion Include="OxyPlot.ImageSharp" Version="2.2.0" />
43+
<PackageVersion Include="Riok.Mapperly" Version="4.1.0" />
4344
<PackageVersion Include="Serilog" Version="4.0.1" />
4445
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
4546
<PackageVersion Include="Serilog.Enrichers.Process" Version="3.0.0" />

src/MigrationTools.Clients.TfsObjectModel/MigrationTools.Clients.TfsObjectModel.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<PackageReference Include="OpenTelemetry.Exporter.Console" />
4141
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
4242
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
43+
<PackageReference Include="Riok.Mapperly" />
4344
<PackageReference Include="TfsUrlParser" />
4445
</ItemGroup>
4546

src/MigrationTools.Clients.TfsObjectModel/Processors/TfsExportUsersForMappingProcessor.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@ protected override void InternalExecute()
6565
Log.LogInformation("Found {usersToMap} total mapped", usersToMap.Count);
6666
}
6767

68-
usersToMap = usersToMap.Where(x => x.Source.FriendlyName != x.Target?.FriendlyName).ToList();
68+
usersToMap = usersToMap.Where(x => x.Source.DisplayName != x.Target?.DisplayName).ToList();
6969
Log.LogInformation("Filtered to {usersToMap} total viable mappings", usersToMap.Count);
7070
Dictionary<string, string> usermappings = [];
7171
foreach (IdentityMapData userMapping in usersToMap)
7272
{
73-
// We cannot use ToDictionary(), because there can be multiple users with the same friendly name and so
73+
// We cannot use ToDictionary(), because there can be multiple users with the same display name and so
7474
// it would throw with duplicate key. This way we just overwrite the value – last item in source wins.
75-
usermappings[userMapping.Source.FriendlyName] = userMapping.Target?.FriendlyName;
75+
usermappings[userMapping.Source.DisplayName] = userMapping.Target?.DisplayName;
7676
}
7777
System.IO.File.WriteAllText(CommonTools.UserMapping.Options.UserMappingFile, JsonConvert.SerializeObject(usermappings, Formatting.Indented));
7878
Log.LogInformation("Writen to: {LocalExportJsonFile}", CommonTools.UserMapping.Options.UserMappingFile);

src/MigrationTools.Clients.TfsObjectModel/Tools/TfsUserMappingTool.cs

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using MigrationTools.DataContracts;
99
using MigrationTools.Processors.Infrastructure;
1010
using MigrationTools.Tools.Infrastructure;
11+
using Riok.Mapperly.Abstractions;
1112

1213
namespace MigrationTools.Tools
1314
{
@@ -22,16 +23,19 @@ public TfsUserMappingTool(IOptions<TfsUserMappingToolOptions> options, IServiceP
2223
{
2324
}
2425

25-
private List<string> GetUsersFromWorkItems(List<WorkItemData> workitems, List<string> identityFieldsToCheck)
26+
private readonly CaseInsensitiveStringComparer _workItemNameComparer = new();
27+
private readonly TfsUserMappingToolMapper _mapper = new();
28+
29+
private HashSet<string> GetUsersFromWorkItems(List<WorkItemData> workitems, List<string> identityFieldsToCheck)
2630
{
27-
List<string> foundUsers = new List<string>();
31+
HashSet<string> foundUsers = new(StringComparer.CurrentCultureIgnoreCase);
2832
foreach (var wItem in workitems)
2933
{
3034
foreach (var rItem in wItem.Revisions.Values)
3135
{
3236
foreach (var fItem in rItem.Fields.Values)
3337
{
34-
if (identityFieldsToCheck.Contains(fItem.ReferenceName, new CaseInsensativeStringComparer()))
38+
if (identityFieldsToCheck.Contains(fItem.ReferenceName, _workItemNameComparer))
3539
{
3640
if (!foundUsers.Contains(fItem.Value) && !string.IsNullOrEmpty((string)fItem.Value))
3741
{
@@ -74,7 +78,7 @@ private Dictionary<string, string> GetMappingFileData()
7478
try
7579
{
7680
var fileMaps = Newtonsoft.Json.JsonConvert.DeserializeObject<List<IdentityMapData>>(fileData);
77-
_UserMappings = fileMaps.ToDictionary(x => x.Source.FriendlyName, x => x.Target?.FriendlyName);
81+
_UserMappings = fileMaps.ToDictionary(x => x.Source.DisplayName, x => x.Target?.DisplayName);
7882
}
7983
catch (Exception)
8084
{
@@ -104,11 +108,7 @@ private List<IdentityItemData> GetUsersListFromServer(IGroupSecurityService gss)
104108
else if ((identity.Type == IdentityType.WindowsUser) || (identity.Type == IdentityType.UnknownIdentityType))
105109
{
106110
// UnknownIdentityType is set for users in Azure Entra ID.
107-
foundUsers.Add(new IdentityItemData()
108-
{
109-
FriendlyName = identity.DisplayName,
110-
AccountName = identity.AccountName
111-
});
111+
foundUsers.Add(_mapper.IdentityToIdentityItemData(identity));
112112
}
113113
else
114114
{
@@ -134,12 +134,40 @@ public List<IdentityMapData> GetUsersInSourceMappedToTarget(TfsProcessor process
134134
var sourceUsers = GetUsersListFromServer(processor.Source.GetService<IGroupSecurityService>());
135135
Log.LogInformation($"TfsUserMappingTool::GetUsersInSourceMappedToTarget Loading identities from target server");
136136
var targetUsers = GetUsersListFromServer(processor.Target.GetService<IGroupSecurityService>());
137-
return sourceUsers.Select(sUser => new IdentityMapData { Source = sUser, Target = targetUsers.SingleOrDefault(tUser => tUser.FriendlyName == sUser.FriendlyName) }).ToList();
137+
138+
if (Options.MatchUsersByEmail)
139+
{
140+
Log.LogInformation("TfsUserMappingTool::GetUsersInSourceMappedToTarget "
141+
+ "Matching users between source and target by email is enabled. In no match by email is found, "
142+
+ "matching by display name will be used.");
143+
}
144+
145+
List<IdentityMapData> identityMap = [];
146+
foreach (var sourceUser in sourceUsers)
147+
{
148+
IdentityItemData targetUser = null;
149+
if (Options.MatchUsersByEmail && !string.IsNullOrEmpty(sourceUser.MailAddress))
150+
{
151+
var candidates = targetUsers
152+
.Where(tu => tu.MailAddress.Equals(sourceUser.MailAddress, StringComparison.OrdinalIgnoreCase))
153+
.ToList();
154+
if (candidates.Count == 1)
155+
{
156+
// If there are more than one user with the same email address, we can't be sure which one is
157+
// the correct one, so mapping will match either by display name, or will be skipped and
158+
// exported for manual mapping.
159+
targetUser = candidates[0];
160+
}
161+
}
162+
targetUser ??= targetUsers.SingleOrDefault(x => x.DisplayName == sourceUser.DisplayName);
163+
identityMap.Add(new IdentityMapData { Source = sourceUser, Target = targetUser });
164+
}
165+
return identityMap;
138166
}
139167
else
140168
{
141169
Log.LogWarning("TfsUserMappingTool is disabled in settings. You may have users in the source that are not mapped to the target. ");
142-
return null;
170+
return [];
143171
}
144172
}
145173

@@ -148,11 +176,11 @@ public List<IdentityMapData> GetUsersInSourceMappedToTargetForWorkItems(TfsProce
148176
if (Options.Enabled)
149177
{
150178
Dictionary<string, string> result = new Dictionary<string, string>();
151-
List<string> workItemUsers = GetUsersFromWorkItems(sourceWorkItems, Options.IdentityFieldsToCheck);
179+
HashSet<string> workItemUsers = GetUsersFromWorkItems(sourceWorkItems, Options.IdentityFieldsToCheck);
152180
Log.LogDebug($"TfsUserMappingTool::GetUsersInSourceMappedToTargetForWorkItems [workItemUsers|{workItemUsers.Count}]");
153181
List<IdentityMapData> mappedUsers = GetUsersInSourceMappedToTarget(processor);
154182
Log.LogDebug($"TfsUserMappingTool::GetUsersInSourceMappedToTargetForWorkItems [mappedUsers|{mappedUsers.Count}]");
155-
return mappedUsers.Where(x => workItemUsers.Contains(x.Source.FriendlyName)).ToList();
183+
return mappedUsers.Where(x => workItemUsers.Contains(x.Source.DisplayName)).ToList();
156184
}
157185
else
158186
{
@@ -162,7 +190,7 @@ public List<IdentityMapData> GetUsersInSourceMappedToTargetForWorkItems(TfsProce
162190
}
163191
}
164192

165-
public class CaseInsensativeStringComparer : IEqualityComparer<string>
193+
internal class CaseInsensitiveStringComparer : IEqualityComparer<string>
166194
{
167195
public bool Equals(string x, string y)
168196
{
@@ -174,4 +202,12 @@ public int GetHashCode(string obj)
174202
return obj.GetHashCode();
175203
}
176204
}
205+
206+
[Mapper]
207+
internal partial class TfsUserMappingToolMapper
208+
{
209+
#pragma warning disable RMG020 // Source member is not mapped to any target member
210+
public partial IdentityItemData IdentityToIdentityItemData(Identity identity);
211+
#pragma warning restore RMG020 // Source member is not mapped to any target member
212+
}
177213
}
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
using System;
2-
using System.Collections.Generic;
3-
using Microsoft.TeamFoundation.Build.Client;
4-
using MigrationTools.Enrichers;
1+
using System.Collections.Generic;
52
using MigrationTools.Tools.Infrastructure;
63

74
namespace MigrationTools.Tools
@@ -10,7 +7,7 @@ public class TfsUserMappingToolOptions : ToolOptions, ITfsUserMappingToolOptions
107
{
118

129
/// <summary>
13-
/// This is a list of the Identiy fields in the Source to check for user mapping purposes. You should list all identiy fields that you wan to map.
10+
/// This is a list of the Identiy fields in the Source to check for user mapping purposes. You should list all identiy fields that you want to map.
1411
/// </summary>
1512
public List<string> IdentityFieldsToCheck { get; set; }
1613

@@ -19,11 +16,17 @@ public class TfsUserMappingToolOptions : ToolOptions, ITfsUserMappingToolOptions
1916
/// </summary>
2017
public string UserMappingFile { get; set; }
2118

19+
/// <summary>
20+
/// By default, users in source are mapped to target users by their display name. If this is set to true, then the
21+
/// users will be mapped by their email address first. If no match is found, then the display name will be used.
22+
/// </summary>
23+
public bool MatchUsersByEmail { get; set; }
2224
}
2325

2426
public interface ITfsUserMappingToolOptions
2527
{
2628
List<string> IdentityFieldsToCheck { get; set; }
2729
string UserMappingFile { get; set; }
30+
bool MatchUsersByEmail { get; set; }
2831
}
29-
}
32+
}

src/MigrationTools/DataContracts/IdentityItemData.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
{
33
public class IdentityItemData
44
{
5-
public string FriendlyName { get; set; }
5+
public string Sid { get; set; }
6+
public string DisplayName { get; set; }
7+
public string Domain { get; set; }
68
public string AccountName { get; set; }
9+
public string MailAddress { get; set; }
710
}
811

912
public class IdentityMapData

0 commit comments

Comments
 (0)