using Microsoft.Graph; using Microsoft.Graph.Models; using Microsoft.SharePoint.Client; using Serilog; using SharepointToolbox.Web.Core.Helpers; using SharepointToolbox.Web.Core.Models; using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory; using GraphUser = Microsoft.Graph.Models.User; using GraphUserCollectionResponse = Microsoft.Graph.Models.UserCollectionResponse; namespace SharepointToolbox.Web.Services; public class SharePointGroupResolver : ISharePointGroupResolver { private readonly AppGraphClientFactory _graphClientFactory; public SharePointGroupResolver(AppGraphClientFactory graphClientFactory) { _graphClientFactory = graphClientFactory; } public async Task>> ResolveGroupsAsync( ClientContext ctx, TenantProfile profile, IReadOnlyList groupNames, CancellationToken ct) { var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); if (groupNames.Count == 0) return result; GraphServiceClient? graphClient = null; var groupTitles = new HashSet(StringComparer.OrdinalIgnoreCase); try { ctx.Load(ctx.Web.SiteGroups, gs => gs.Include(g => g.Title)); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); foreach (var g in ctx.Web.SiteGroups) groupTitles.Add(g.Title); } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message); } foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase)) { ct.ThrowIfCancellationRequested(); if (!groupTitles.Contains(groupName)) { Log.Debug("SP group '{Group}' not present on {Url}; skipping.", groupName, ctx.Url); result[groupName] = Array.Empty(); continue; } 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)) { graphClient ??= await _graphClientFactory.CreateClientAsync(profile); var aadId = ExtractAadGroupId(user.LoginName); // M365 (group-connected) sites add the group's OWNERS claim ("…_o") to the // site Owners SP group; resolve owners for those, transitive members otherwise. var leafUsers = IsM365GroupOwnersClaim(user.LoginName) ? await ResolveAadGroupOwnersAsync(graphClient, aadId, ct) : await ResolveAadGroupAsync(graphClient, aadId, ct); members.AddRange(leafUsers); } else { members.Add(new ResolvedMember(user.Title ?? user.LoginName, StripClaims(user.LoginName))); } } result[groupName] = members.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase).ToList(); } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message); result[groupName] = Array.Empty(); } } return result; } // Group principals that must be expanded via Graph: // c:0t.c|tenant| → AAD security group // c:0o.c|federateddirectoryclaimprovider| → M365 group members (group-connected/Teams sites) // c:0o.c|federateddirectoryclaimprovider|_o → M365 group owners // The M365 cases are how modern group-connected sites grant access; without expanding them a // user who is "just a member of the site" never appears in a user-centric audit. internal static bool IsAadGroup(string login) => login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase) || login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase); internal static bool IsM365GroupOwnersClaim(string login) => login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase) && login.EndsWith("_o", StringComparison.OrdinalIgnoreCase); // Last claim segment is the group GUID; M365 owners claims append "_o" — strip it. internal static string ExtractAadGroupId(string login) { var id = login[(login.LastIndexOf('|') + 1)..]; return id.EndsWith("_o", StringComparison.OrdinalIgnoreCase) ? id[..^2] : id; } internal static string StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..]; 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 iter = PageIterator.CreatePageIterator( graphClient, response, user => { if (ct.IsCancellationRequested) return false; members.Add(new ResolvedMember( user.DisplayName ?? user.UserPrincipalName ?? "Unknown", user.UserPrincipalName ?? string.Empty)); return true; }); await iter.IterateAsync(ct); return members; } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message); return Enumerable.Empty(); } } // M365 group owners (the "…_o" claim). Owners are a direct, non-nested collection, so no // transitive expansion is needed — owners cannot themselves be groups. private static async Task> ResolveAadGroupOwnersAsync( GraphServiceClient graphClient, string aadGroupId, CancellationToken ct) { try { var response = await graphClient.Groups[aadGroupId].Owners.GraphUser.GetAsync(config => { config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" }; config.QueryParameters.Top = 999; }, ct); if (response?.Value is null) return Enumerable.Empty(); var owners = new List(); var iter = PageIterator.CreatePageIterator( graphClient, response, user => { if (ct.IsCancellationRequested) return false; owners.Add(new ResolvedMember( user.DisplayName ?? user.UserPrincipalName ?? "Unknown", user.UserPrincipalName ?? string.Empty)); return true; }); await iter.IterateAsync(ct); return owners; } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Warning("Could not resolve AAD group '{Id}' owners: {Error}", aadGroupId, ex.Message); return Enumerable.Empty(); } } }