diff --git a/SharepointToolbox/Core/Helpers/PermissionConsolidator.cs b/SharepointToolbox/Core/Helpers/PermissionConsolidator.cs
new file mode 100644
index 0000000..6322b8a
--- /dev/null
+++ b/SharepointToolbox/Core/Helpers/PermissionConsolidator.cs
@@ -0,0 +1,59 @@
+using SharepointToolbox.Core.Models;
+
+namespace SharepointToolbox.Core.Helpers;
+
+///
+/// Merges a flat list of UserAccessEntry rows into consolidated entries
+/// where rows with identical (UserLogin, PermissionLevel, AccessType, GrantedThrough)
+/// are grouped into a single row with multiple locations.
+///
+public static class PermissionConsolidator
+{
+ ///
+ /// Builds a pipe-delimited, case-insensitive composite key from the four key fields.
+ ///
+ internal static string MakeKey(UserAccessEntry entry)
+ {
+ return string.Join("|",
+ entry.UserLogin.ToLowerInvariant(),
+ entry.PermissionLevel.ToLowerInvariant(),
+ entry.AccessType.ToString(),
+ entry.GrantedThrough.ToLowerInvariant());
+ }
+
+ ///
+ /// Groups entries by composite key and returns consolidated rows.
+ /// Each group's first entry provides UserDisplayName, IsHighPrivilege, IsExternalUser.
+ /// All entries in a group contribute a LocationInfo to the Locations list.
+ /// Results are ordered by UserLogin then PermissionLevel.
+ ///
+ public static IReadOnlyList Consolidate(
+ IReadOnlyList entries)
+ {
+ if (entries.Count == 0)
+ return Array.Empty();
+
+ return entries
+ .GroupBy(e => MakeKey(e))
+ .Select(g =>
+ {
+ var first = g.First();
+ var locations = g.Select(e => new LocationInfo(
+ e.SiteUrl, e.SiteTitle, e.ObjectTitle, e.ObjectUrl, e.ObjectType
+ )).ToList();
+
+ return new ConsolidatedPermissionEntry(
+ first.UserDisplayName,
+ first.UserLogin,
+ first.PermissionLevel,
+ first.AccessType,
+ first.GrantedThrough,
+ first.IsHighPrivilege,
+ first.IsExternalUser,
+ locations);
+ })
+ .OrderBy(c => c.UserLogin)
+ .ThenBy(c => c.PermissionLevel)
+ .ToList();
+ }
+}