diff --git a/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs b/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs index 498c68d..f535802 100644 --- a/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs +++ b/SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs @@ -137,13 +137,12 @@ public class SharePointGroupResolverTests groupNames: Array.Empty(), ct: CancellationToken.None); - // Assert: verify the returned dict is OrdinalIgnoreCase by inserting a value - // and looking it up with different casing — this validates the comparer used - // at construction time even on an empty dict - var mutable = new Dictionary(result.Comparer) - { - ["Site Members"] = 1 - }; + // Assert: verify the returned dict is OrdinalIgnoreCase by casting to Dictionary + // and checking its comparer, or by testing that the underlying type supports it. + // Since ResolveGroupsAsync returns a Dictionary wrapped as IReadOnlyDictionary, + // we cast back and insert a test entry with mixed casing. + var mutable = (Dictionary>)result; + mutable["Site Members"] = Array.Empty(); Assert.True(mutable.ContainsKey("site members"), "Result dictionary comparer must be OrdinalIgnoreCase"); } diff --git a/SharepointToolbox/Core/Models/ResolvedMember.cs b/SharepointToolbox/Core/Models/ResolvedMember.cs new file mode 100644 index 0000000..b28eda2 --- /dev/null +++ b/SharepointToolbox/Core/Models/ResolvedMember.cs @@ -0,0 +1,10 @@ +namespace SharepointToolbox.Core.Models; + +/// +/// Represents a resolved leaf member of a SharePoint group or nested AAD group. +/// Used by to return +/// transitive member lists for HTML report group expansion (Phase 17). +/// +/// The display name of the member (e.g. "Alice Smith"). +/// The login / UPN of the member (e.g. "alice@contoso.com"), with claims prefix stripped. +public record ResolvedMember(string DisplayName, string Login); diff --git a/SharepointToolbox/Services/ISharePointGroupResolver.cs b/SharepointToolbox/Services/ISharePointGroupResolver.cs new file mode 100644 index 0000000..489b7f4 --- /dev/null +++ b/SharepointToolbox/Services/ISharePointGroupResolver.cs @@ -0,0 +1,37 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +/// +/// Resolves SharePoint group names to their transitive member lists via CSOM + Microsoft Graph. +/// Used as a pre-render step in HTML report export (Phase 17). +/// +public interface ISharePointGroupResolver +{ + /// + /// Resolves the given SharePoint group names to their transitive leaf-user members. + /// + /// For each group name: + /// - CSOM loads the direct members from . + /// - Any members that are nested AAD groups (login prefix c:0t.c|tenant|) are expanded + /// transitively via the Microsoft Graph groups/{id}/transitiveMembers/microsoft.graph.user endpoint. + /// - Members are de-duplicated by login (OrdinalIgnoreCase). + /// + /// Failures (throttling, group not found, Graph errors) are handled gracefully: + /// the group entry is included in the result with an empty member list rather than throwing. + /// + /// SharePoint ClientContext for CSOM group member loading. + /// App registration client ID used by . + /// List of SharePoint group display names to resolve. + /// Cancellation token. + /// + /// A read-only dictionary keyed by group name (OrdinalIgnoreCase) mapping to the list of resolved members. + /// Never throws — a group with a resolution failure maps to an empty list. + /// + Task>> ResolveGroupsAsync( + ClientContext ctx, + string clientId, + IReadOnlyList groupNames, + CancellationToken ct); +} diff --git a/SharepointToolbox/Services/SharePointGroupResolver.cs b/SharepointToolbox/Services/SharePointGroupResolver.cs new file mode 100644 index 0000000..644b369 --- /dev/null +++ b/SharepointToolbox/Services/SharePointGroupResolver.cs @@ -0,0 +1,164 @@ +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.SharePoint.Client; +using Serilog; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; +using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; +using GraphUser = Microsoft.Graph.Models.User; +using GraphUserCollectionResponse = Microsoft.Graph.Models.UserCollectionResponse; + +namespace SharepointToolbox.Services; + +/// +/// CSOM + Microsoft Graph implementation of . +/// +/// Resolution strategy (Phase 17): +/// 1. Iterate distinct group names (OrdinalIgnoreCase). +/// 2. Per group: load users via CSOM ctx.Web.SiteGroups.GetByName(name).Users. +/// 3. Per user: if login matches AAD group pattern (), resolve +/// transitively via Graph groups/{id}/transitiveMembers/microsoft.graph.user. +/// 4. De-duplicate leaf members by Login (OrdinalIgnoreCase). +/// 5. On any error: log warning and return empty list for that group (never throw). +/// +public class SharePointGroupResolver : ISharePointGroupResolver +{ + private readonly AppGraphClientFactory? _graphClientFactory; + + public SharePointGroupResolver(AppGraphClientFactory graphClientFactory) + { + _graphClientFactory = graphClientFactory; + } + + /// + public async Task>> ResolveGroupsAsync( + ClientContext ctx, + string clientId, + IReadOnlyList groupNames, + CancellationToken ct) + { + var result = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + + if (groupNames.Count == 0) + return result; + + GraphServiceClient? graphClient = null; + + foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase)) + { + ct.ThrowIfCancellationRequested(); + + try + { + var group = ctx.Web.SiteGroups.GetByName(groupName); + ctx.Load(group.Users, users => users.Include( + u => u.Title, + u => u.LoginName, + u => u.PrincipalType)); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); + + var members = new List(); + + foreach (var user in group.Users) + { + if (IsAadGroup(user.LoginName)) + { + // Lazy-create graph client on first AAD group encountered + graphClient ??= await _graphClientFactory!.CreateClientAsync(clientId, ct); + + var aadId = ExtractAadGroupId(user.LoginName); + var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct); + members.AddRange(leafUsers); + } + else + { + members.Add(new ResolvedMember( + DisplayName: user.Title ?? user.LoginName, + Login: StripClaims(user.LoginName))); + } + } + + result[groupName] = members + .DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message); + result[groupName] = Array.Empty(); + } + } + + return result; + } + + // ── Static helpers (internal for testability via InternalsVisibleTo) ─────── + + /// + /// Returns true if the login name represents a nested AAD/M365 group + /// (login prefix pattern c:0t.c|tenant|). + /// + internal static bool IsAadGroup(string login) => + login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase); + + /// + /// Extracts the AAD object GUID from an AAD group login name. + /// e.g. "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" → "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" + /// + internal static string ExtractAadGroupId(string login) => + login[(login.LastIndexOf('|') + 1)..]; + + /// + /// Strips the SharePoint claims prefix from a login name, returning the UPN or identifier after the last pipe. + /// e.g. "i:0#.f|membership|user@contoso.com" → "user@contoso.com" + /// + internal static string StripClaims(string login) => + login[(login.LastIndexOf('|') + 1)..]; + + // ── Private: Graph transitive member resolution ──────────────────────────── + + private static async Task> ResolveAadGroupAsync( + GraphServiceClient graphClient, + string aadGroupId, + CancellationToken ct) + { + try + { + var response = await graphClient + .Groups[aadGroupId] + .TransitiveMembers + .GraphUser + .GetAsync(config => + { + config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" }; + config.QueryParameters.Top = 999; + }, ct); + + if (response?.Value is null) + return Enumerable.Empty(); + + var members = new List(); + + var pageIterator = PageIterator.CreatePageIterator( + graphClient, + response, + user => + { + if (ct.IsCancellationRequested) return false; + members.Add(new ResolvedMember( + DisplayName: user.DisplayName ?? user.UserPrincipalName ?? "Unknown", + Login: user.UserPrincipalName ?? string.Empty)); + return true; + }); + + await pageIterator.IterateAsync(ct); + return members; + } + catch (Exception ex) + { + Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message); + return Enumerable.Empty(); + } + } +}