feat(17-01): ResolvedMember model, ISharePointGroupResolver interface, SharePointGroupResolver CSOM+Graph implementation

- ResolvedMember record in Core/Models with DisplayName and Login
- ISharePointGroupResolver interface with ResolveGroupsAsync contract
- SharePointGroupResolver: CSOM group user loading + Graph transitive AAD resolution
- Internal static helpers IsAadGroup, ExtractAadGroupId, StripClaims (all green unit tests)
- Graceful error handling: exceptions return empty list per group, never throw
- OrdinalIgnoreCase result dict; lazy Graph client creation on first AAD group
This commit is contained in:
Dev
2026-04-09 13:04:56 +02:00
parent 0f8b1953e1
commit 543b863283
4 changed files with 217 additions and 7 deletions

View File

@@ -0,0 +1,37 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// 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).
/// </summary>
public interface ISharePointGroupResolver
{
/// <summary>
/// Resolves the given SharePoint group names to their transitive leaf-user members.
///
/// For each group name:
/// - CSOM loads the direct members from <see cref="Microsoft.SharePoint.Client.GroupCollection"/>.
/// - Any members that are nested AAD groups (login prefix <c>c:0t.c|tenant|</c>) are expanded
/// transitively via the Microsoft Graph <c>groups/{id}/transitiveMembers/microsoft.graph.user</c> 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.
/// </summary>
/// <param name="ctx">SharePoint ClientContext for CSOM group member loading.</param>
/// <param name="clientId">App registration client ID used by <see cref="SharepointToolbox.Infrastructure.Auth.GraphClientFactory"/>.</param>
/// <param name="groupNames">List of SharePoint group display names to resolve.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 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.
/// </returns>
Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
ClientContext ctx,
string clientId,
IReadOnlyList<string> groupNames,
CancellationToken ct);
}

View File

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