f4cc81bb71
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
7.5 KiB
C#
192 lines
7.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// CSOM + Microsoft Graph implementation of <see cref="ISharePointGroupResolver"/>.
|
|
///
|
|
/// Resolution strategy (Phase 17):
|
|
/// 1. Iterate distinct group names (OrdinalIgnoreCase).
|
|
/// 2. Per group: load users via CSOM <c>ctx.Web.SiteGroups.GetByName(name).Users</c>.
|
|
/// 3. Per user: if login matches AAD group pattern (<see cref="IsAadGroup"/>), resolve
|
|
/// transitively via Graph <c>groups/{id}/transitiveMembers/microsoft.graph.user</c>.
|
|
/// 4. De-duplicate leaf members by Login (OrdinalIgnoreCase).
|
|
/// 5. On any error: log warning and return empty list for that group (never throw).
|
|
/// </summary>
|
|
public class SharePointGroupResolver : ISharePointGroupResolver
|
|
{
|
|
private readonly AppGraphClientFactory? _graphClientFactory;
|
|
|
|
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
|
|
{
|
|
_graphClientFactory = graphClientFactory;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
|
|
ClientContext ctx,
|
|
string clientId,
|
|
IReadOnlyList<string> groupNames,
|
|
CancellationToken ct)
|
|
{
|
|
var result = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
if (groupNames.Count == 0)
|
|
return result;
|
|
|
|
GraphServiceClient? graphClient = null;
|
|
|
|
// Preload the web's SiteGroups catalog once, so we can skip missing
|
|
// groups without triggering a server round-trip per name (which fills
|
|
// logs with "Could not resolve SP group" warnings for groups that
|
|
// live on other sites or were renamed/deleted).
|
|
var groupTitles = new HashSet<string>(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 (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))
|
|
{
|
|
// Group not on this web — likely scoped to another site in a
|
|
// multi-site scan. Keep quiet: log at Debug, return empty.
|
|
Log.Debug("SP group '{Group}' not present on {Url}; skipping.",
|
|
groupName, ctx.Url);
|
|
result[groupName] = Array.Empty<ResolvedMember>();
|
|
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<ResolvedMember>();
|
|
|
|
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<ResolvedMember>();
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── Static helpers (internal for testability via InternalsVisibleTo) ───────
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> if the login name represents a nested AAD/M365 group
|
|
/// (login prefix pattern <c>c:0t.c|tenant|</c>).
|
|
/// </summary>
|
|
internal static bool IsAadGroup(string login) =>
|
|
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// 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"
|
|
/// </summary>
|
|
internal static string ExtractAadGroupId(string login) =>
|
|
login[(login.LastIndexOf('|') + 1)..];
|
|
|
|
/// <summary>
|
|
/// 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"
|
|
/// </summary>
|
|
internal static string StripClaims(string login) =>
|
|
login[(login.LastIndexOf('|') + 1)..];
|
|
|
|
// ── Private: Graph transitive member resolution ────────────────────────────
|
|
|
|
private static async Task<IEnumerable<ResolvedMember>> 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<ResolvedMember>();
|
|
|
|
var members = new List<ResolvedMember>();
|
|
|
|
var pageIterator = PageIterator<GraphUser, GraphUserCollectionResponse>.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<ResolvedMember>();
|
|
}
|
|
}
|
|
}
|