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(); } } }