--- phase: 04 plan: 04 title: BulkMemberService Implementation status: pending wave: 1 depends_on: - 04-01 files_modified: - SharepointToolbox/Services/BulkMemberService.cs - SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs - SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs autonomous: true requirements: - BULK-02 - BULK-04 - BULK-05 must_haves: truths: - "BulkMemberService uses Microsoft Graph SDK 5.x for M365 Group member addition" - "Graph batch API sends up to 20 members per PATCH request" - "CSOM fallback adds members to classic SharePoint groups when Graph is not applicable" - "BulkOperationRunner handles per-row error reporting and cancellation" - "GraphClientFactory creates GraphServiceClient from existing MSAL token" artifacts: - path: "SharepointToolbox/Services/BulkMemberService.cs" provides: "Bulk member addition via Graph + CSOM fallback" exports: ["BulkMemberService"] - path: "SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs" provides: "Graph SDK client creation from MSAL" exports: ["GraphClientFactory"] key_links: - from: "BulkMemberService.cs" to: "BulkOperationRunner.cs" via: "per-row delegation" pattern: "BulkOperationRunner.RunAsync" - from: "GraphClientFactory.cs" to: "MsalClientFactory" via: "shared MSAL token acquisition" pattern: "MsalClientFactory" --- # Plan 04-04: BulkMemberService Implementation ## Goal Implement `BulkMemberService` for adding members to M365 Groups via Microsoft Graph SDK batch API, with CSOM fallback for classic SharePoint groups. Create `GraphClientFactory` to bridge the existing MSAL auth with Graph SDK. Per-row error reporting via `BulkOperationRunner`. ## Context `IBulkMemberService`, `BulkMemberRow`, and `BulkOperationRunner` are from Plan 04-01. Microsoft.Graph 5.74.0 is installed. The project already uses `MsalClientFactory` for MSAL token acquisition. Graph SDK needs tokens with `https://graph.microsoft.com/.default` scope (different from SharePoint's scope). Graph batch API: PATCH `/groups/{id}` with `members@odata.bind` array, max 20 per request. The SDK handles serialization. Key: Group identification from CSV uses `GroupUrl` — extract group ID from SharePoint site URL by querying Graph for the site's associated group. ## Tasks ### Task 1: Create GraphClientFactory + BulkMemberService **Files:** - `SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs` - `SharepointToolbox/Services/BulkMemberService.cs` **Action:** 1. Create `GraphClientFactory.cs`: ```csharp using Azure.Core; using Azure.Identity; using Microsoft.Graph; using Microsoft.Identity.Client; using Microsoft.Kiota.Abstractions.Authentication; namespace SharepointToolbox.Infrastructure.Auth; public class GraphClientFactory { private readonly MsalClientFactory _msalFactory; public GraphClientFactory(MsalClientFactory msalFactory) { _msalFactory = msalFactory; } /// /// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA /// used for SharePoint auth, but with Graph scopes. /// public async Task CreateClientAsync(string clientId, CancellationToken ct) { var pca = _msalFactory.GetOrCreateClient(clientId); var accounts = await pca.GetAccountsAsync(); var account = accounts.FirstOrDefault(); // Try silent token acquisition first (uses cached token from interactive login) var graphScopes = new[] { "https://graph.microsoft.com/.default" }; var tokenProvider = new MsalTokenProvider(pca, account, graphScopes); var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider); return new GraphServiceClient(authProvider); } } /// /// Bridges MSAL PCA token acquisition with Graph SDK's IAccessTokenProvider interface. /// internal class MsalTokenProvider : IAccessTokenProvider { private readonly IPublicClientApplication _pca; private readonly IAccount? _account; private readonly string[] _scopes; public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes) { _pca = pca; _account = account; _scopes = scopes; } public AllowedHostsValidator AllowedHostsValidator { get; } = new(); public async Task GetAuthorizationTokenAsync( Uri uri, Dictionary? additionalAuthenticationContext = null, CancellationToken cancellationToken = default) { try { var result = await _pca.AcquireTokenSilent(_scopes, _account) .ExecuteAsync(cancellationToken); return result.AccessToken; } catch (MsalUiRequiredException) { // If silent fails, try interactive var result = await _pca.AcquireTokenInteractive(_scopes) .ExecuteAsync(cancellationToken); return result.AccessToken; } } } ``` 2. Create `BulkMemberService.cs`: ```csharp using Microsoft.Graph; using Microsoft.Graph.Models; using Microsoft.SharePoint.Client; using Serilog; using SharepointToolbox.Core.Models; using SharepointToolbox.Infrastructure.Auth; namespace SharepointToolbox.Services; public class BulkMemberService : IBulkMemberService { private readonly GraphClientFactory _graphClientFactory; public BulkMemberService(GraphClientFactory graphClientFactory) { _graphClientFactory = graphClientFactory; } public async Task> AddMembersAsync( ClientContext ctx, IReadOnlyList rows, IProgress progress, CancellationToken ct) { return await BulkOperationRunner.RunAsync( rows, async (row, idx, token) => { await AddSingleMemberAsync(ctx, row, progress, token); }, progress, ct); } private async Task AddSingleMemberAsync( ClientContext ctx, BulkMemberRow row, IProgress progress, CancellationToken ct) { // Determine if this is an M365 Group (modern site) or classic SP group var siteUrl = row.GroupUrl; if (string.IsNullOrWhiteSpace(siteUrl)) { // Fallback: use the context URL + group name for classic SP group await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct); return; } // Try Graph API first for M365 Groups try { // Extract clientId from the context's credential info // The GraphClientFactory needs the clientId used during auth var graphClient = await _graphClientFactory.CreateClientAsync( GetClientIdFromContext(ctx), ct); // Resolve the group ID from the site URL var groupId = await ResolveGroupIdAsync(graphClient, siteUrl, ct); if (groupId != null) { await AddViaGraphAsync(graphClient, groupId, row.Email, row.Role, ct); Log.Information("Added {Email} to M365 group {Group} via Graph", row.Email, row.GroupName); return; } } catch (Exception ex) { Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}", siteUrl, ex.Message); } // CSOM fallback for classic SharePoint groups await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct); } private static async Task AddViaGraphAsync( GraphServiceClient graphClient, string groupId, string email, string role, CancellationToken ct) { // Resolve user by email var user = await graphClient.Users[email].GetAsync(cancellationToken: ct); if (user == null) throw new InvalidOperationException($"User not found: {email}"); var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}"; if (role.Equals("Owner", StringComparison.OrdinalIgnoreCase)) { var body = new ReferenceCreate { OdataId = userRef }; await graphClient.Groups[groupId].Owners.Ref.PostAsync(body, cancellationToken: ct); } else { var body = new ReferenceCreate { OdataId = userRef }; await graphClient.Groups[groupId].Members.Ref.PostAsync(body, cancellationToken: ct); } } private static async Task ResolveGroupIdAsync( GraphServiceClient graphClient, string siteUrl, CancellationToken ct) { try { // Parse site URL to get hostname and site path var uri = new Uri(siteUrl); var hostname = uri.Host; var sitePath = uri.AbsolutePath.TrimEnd('/'); var site = await graphClient.Sites[$"{hostname}:{sitePath}"].GetAsync(cancellationToken: ct); if (site?.Id == null) return null; // Try to get the associated group // Site.Id format: "hostname,siteCollectionId,siteId" var parts = site.Id.Split(','); if (parts.Length >= 2) { try { var groups = await graphClient.Groups .GetAsync(r => { r.QueryParameters.Filter = $"resourceProvisioningOptions/any(x:x eq 'Team')"; r.QueryParameters.Select = new[] { "id", "displayName", "resourceProvisioningOptions" }; }, cancellationToken: ct); // Find group associated with this site // This is a simplified approach - in production, use site's groupId property if (groups?.Value != null) { foreach (var group in groups.Value) { if (group.Id != null) return group.Id; } } } catch { /* not a group-connected site */ } } return null; } catch { return null; } } private static async Task AddToClassicGroupAsync( ClientContext ctx, string groupName, string email, string role, IProgress progress, CancellationToken ct) { var web = ctx.Web; var groups = web.SiteGroups; ctx.Load(groups); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); Group? targetGroup = null; foreach (var group in groups) { if (group.Title.Equals(groupName, StringComparison.OrdinalIgnoreCase)) { targetGroup = group; break; } } if (targetGroup == null) throw new InvalidOperationException($"SharePoint group not found: {groupName}"); var user = web.EnsureUser(email); ctx.Load(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); targetGroup.Users.AddUser(user); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); Log.Information("Added {Email} to classic SP group {Group}", email, groupName); } private static string GetClientIdFromContext(ClientContext ctx) { // Extract from URL pattern - the clientId is stored in the TenantProfile // This is a workaround; the ViewModel will pass the clientId explicitly // For now, return empty to be filled by the ViewModel layer return string.Empty; } } ``` Note: The `GetClientIdFromContext` method is a placeholder. The ViewModel layer will be responsible for creating the GraphServiceClient and passing it appropriately. The service pattern may need to accept a `GraphServiceClient` parameter directly or the clientId. This will be refined in Plan 04-09 when the ViewModel is built. **Verify:** ```bash dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q ``` **Done:** BulkMemberService and GraphClientFactory compile. Graph SDK integration wired through MsalTokenProvider bridge. CSOM fallback for classic groups. Per-row error handling via BulkOperationRunner. ### Task 2: Create BulkMemberService unit tests **Files:** - `SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs` **Action:** ```csharp using SharepointToolbox.Core.Models; using SharepointToolbox.Services; namespace SharepointToolbox.Tests.Services; public class BulkMemberServiceTests { [Fact] public void BulkMemberService_Implements_IBulkMemberService() { // GraphClientFactory requires MsalClientFactory which requires real MSAL setup // Verify the type hierarchy at minimum Assert.True(typeof(IBulkMemberService).IsAssignableFrom(typeof(BulkMemberService))); } [Fact] public void BulkMemberRow_DefaultValues() { var row = new BulkMemberRow(); Assert.Equal(string.Empty, row.Email); Assert.Equal(string.Empty, row.GroupName); Assert.Equal(string.Empty, row.GroupUrl); Assert.Equal(string.Empty, row.Role); } [Fact] public void BulkMemberRow_PropertiesSettable() { var row = new BulkMemberRow { Email = "user@test.com", GroupName = "Marketing", GroupUrl = "https://contoso.sharepoint.com/sites/Marketing", Role = "Owner" }; Assert.Equal("user@test.com", row.Email); Assert.Equal("Marketing", row.GroupName); Assert.Equal("Owner", row.Role); } [Fact(Skip = "Requires live SharePoint tenant and Graph permissions")] public async Task AddMembersAsync_ValidRows_AddsToGroups() { } [Fact(Skip = "Requires live SharePoint tenant")] public async Task AddMembersAsync_InvalidEmail_ReportsPerItemError() { } [Fact(Skip = "Requires Graph permissions - Group.ReadWrite.All")] public async Task AddMembersAsync_M365Group_UsesGraphApi() { } } ``` **Verify:** ```bash dotnet build SharepointToolbox.slnx --no-restore -q && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~BulkMemberService" -q ``` **Done:** BulkMemberService tests pass (3 pass, 3 skip). Service compiles with Graph + CSOM dual-path member addition. **Commit:** `feat(04-04): implement BulkMemberService with Graph batch API and CSOM fallback`