213 lines
9.8 KiB
Markdown
213 lines
9.8 KiB
Markdown
---
|
|
phase: 17-group-expansion-html-reports
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- SharepointToolbox/Core/Models/ResolvedMember.cs
|
|
- SharepointToolbox/Services/ISharePointGroupResolver.cs
|
|
- SharepointToolbox/Services/SharePointGroupResolver.cs
|
|
- SharepointToolbox/App.xaml.cs
|
|
- SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
|
|
autonomous: true
|
|
requirements: [RPT-02]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "SharePointGroupResolver returns a dictionary keyed by group name with OrdinalIgnoreCase comparison"
|
|
- "SharePointGroupResolver returns empty list (never throws) when a group cannot be resolved"
|
|
- "IsAadGroup correctly identifies AAD group login patterns (c:0t.c|tenant|<guid>)"
|
|
- "AAD groups detected in SharePoint group members are resolved transitively via Graph"
|
|
- "ResolvedMember record exists in Core/Models with DisplayName and Login properties"
|
|
artifacts:
|
|
- path: "SharepointToolbox/Core/Models/ResolvedMember.cs"
|
|
provides: "Value record for resolved group member"
|
|
contains: "record ResolvedMember"
|
|
- path: "SharepointToolbox/Services/ISharePointGroupResolver.cs"
|
|
provides: "Interface contract for group resolution"
|
|
exports: ["ISharePointGroupResolver"]
|
|
- path: "SharepointToolbox/Services/SharePointGroupResolver.cs"
|
|
provides: "CSOM + Graph implementation of group resolution"
|
|
contains: "ResolveGroupsAsync"
|
|
- path: "SharepointToolbox/App.xaml.cs"
|
|
provides: "DI registration for ISharePointGroupResolver"
|
|
contains: "ISharePointGroupResolver"
|
|
- path: "SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs"
|
|
provides: "Unit tests for resolver logic"
|
|
contains: "IsAadGroup"
|
|
key_links:
|
|
- from: "SharepointToolbox/Services/SharePointGroupResolver.cs"
|
|
to: "GraphClientFactory"
|
|
via: "constructor injection"
|
|
pattern: "GraphClientFactory"
|
|
- from: "SharepointToolbox/Services/SharePointGroupResolver.cs"
|
|
to: "ExecuteQueryRetryHelper"
|
|
via: "CSOM retry"
|
|
pattern: "ExecuteQueryRetryAsync"
|
|
---
|
|
|
|
<objective>
|
|
Create the SharePoint group member resolution service that resolves group names to their transitive members via CSOM + Graph API.
|
|
|
|
Purpose: This service is the data provider for Phase 17 — it pre-resolves group members before HTML export so the export service remains pure and synchronous.
|
|
Output: `ResolvedMember` model, `ISharePointGroupResolver` interface, `SharePointGroupResolver` implementation, DI registration, unit tests.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/17-group-expansion-html-reports/17-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs -->
|
|
|
|
From SharepointToolbox/Core/Models/PermissionEntry.cs:
|
|
```csharp
|
|
public record PermissionEntry(
|
|
string ObjectType,
|
|
string Title,
|
|
string Url,
|
|
bool HasUniquePermissions,
|
|
string Users,
|
|
string UserLogins,
|
|
string PermissionLevels,
|
|
string GrantedThrough,
|
|
string PrincipalType // "SharePointGroup" | "User" | "External User"
|
|
);
|
|
```
|
|
|
|
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
|
|
```csharp
|
|
public class GraphClientFactory
|
|
{
|
|
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
|
|
}
|
|
```
|
|
|
|
From SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs:
|
|
```csharp
|
|
public static class ExecuteQueryRetryHelper
|
|
{
|
|
public static async Task ExecuteQueryRetryAsync(
|
|
ClientContext ctx,
|
|
IProgress<OperationProgress>? progress = null,
|
|
CancellationToken ct = default)
|
|
}
|
|
```
|
|
|
|
From SharepointToolbox/App.xaml.cs (DI registration pattern):
|
|
```csharp
|
|
// Phase 4: Bulk Members
|
|
services.AddTransient<IBulkMemberService, BulkMemberService>();
|
|
// Phase 7: User Access Audit
|
|
services.AddTransient<IGraphUserSearchService, GraphUserSearchService>();
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: ResolvedMember model + ISharePointGroupResolver interface + SharePointGroupResolver implementation + tests</name>
|
|
<files>
|
|
SharepointToolbox/Core/Models/ResolvedMember.cs,
|
|
SharepointToolbox/Services/ISharePointGroupResolver.cs,
|
|
SharepointToolbox/Services/SharePointGroupResolver.cs,
|
|
SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
|
|
</files>
|
|
<behavior>
|
|
- Test: IsAadGroup returns true for "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" and false for "i:0#.f|membership|user@contoso.com" and false for "c:0(.s|true"
|
|
- Test: ExtractAadGroupId extracts GUID from "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" -> "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh"
|
|
- Test: StripClaims strips "i:0#.f|membership|user@contoso.com" -> "user@contoso.com"
|
|
- Test: ResolveGroupsAsync returns empty dict when groupNames list is empty
|
|
- Test: Result dictionary uses OrdinalIgnoreCase comparer (lookup "site members" matches key "Site Members")
|
|
</behavior>
|
|
<action>
|
|
1. Create `Core/Models/ResolvedMember.cs`:
|
|
```csharp
|
|
namespace SharepointToolbox.Core.Models;
|
|
public record ResolvedMember(string DisplayName, string Login);
|
|
```
|
|
|
|
2. Create `Services/ISharePointGroupResolver.cs`:
|
|
```csharp
|
|
public interface ISharePointGroupResolver
|
|
{
|
|
Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
|
|
ClientContext ctx, string clientId,
|
|
IReadOnlyList<string> groupNames, CancellationToken ct);
|
|
}
|
|
```
|
|
|
|
3. Create `Services/SharePointGroupResolver.cs` implementing `ISharePointGroupResolver`:
|
|
- Constructor takes `GraphClientFactory` (same pattern as `BulkMemberService`)
|
|
- `ResolveGroupsAsync` iterates group names (`.Distinct(StringComparer.OrdinalIgnoreCase)`)
|
|
- Per group: CSOM `ctx.Web.SiteGroups.GetByName(name).Users` with `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct)`
|
|
- Per user: check `IsAadGroup(loginName)` — if true, extract GUID via `ExtractAadGroupId` and call `ResolveAadGroupAsync` via Graph
|
|
- `ResolveAadGroupAsync`: `graphClient.Groups[aadGroupId].TransitiveMembers.GraphUser.GetAsync()` with `PageIterator` for pagination — same pattern as `GraphUserDirectoryService`
|
|
- De-duplicate members by Login (OrdinalIgnoreCase) using `.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)`
|
|
- Entire per-group block wrapped in try/catch — on any exception, log warning via `Serilog.Log.Warning` and set `result[groupName] = Array.Empty<ResolvedMember>()`
|
|
- Result dictionary created with `StringComparer.OrdinalIgnoreCase`
|
|
- Make `IsAadGroup`, `ExtractAadGroupId`, and `StripClaims` internal static (with `InternalsVisibleTo` already set for test project) so they are testable
|
|
- `IsAadGroup` pattern: `login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase)`
|
|
- `ExtractAadGroupId`: `login[(login.LastIndexOf('|') + 1)..]`
|
|
- `StripClaims`: `login[(login.LastIndexOf('|') + 1)..]` (same substring after last pipe)
|
|
|
|
4. Create `SharePointGroupResolverTests.cs`:
|
|
- Test `IsAadGroup` with true/false cases (see behavior above)
|
|
- Test `ExtractAadGroupId` extraction
|
|
- Test `StripClaims` extraction
|
|
- Test `ResolveGroupsAsync` with empty list returns empty dict (needs mock ClientContext — use `[Fact(Skip="Requires CSOM ClientContext mock")]` if CSOM types cannot be easily mocked; alternatively test the static helpers only and add a skip-marked integration test)
|
|
- Test case-insensitive dict: create resolver, call with known group, verify lookup with different casing works. If full resolution cannot be unit tested without live CSOM, mark as `[Fact(Skip="Requires live SP tenant")]` and focus unit tests on the three static helpers
|
|
</action>
|
|
<verify>
|
|
<automated>dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests" --no-build</automated>
|
|
</verify>
|
|
<done>ResolvedMember record exists, ISharePointGroupResolver interface defined, SharePointGroupResolver compiles with CSOM + Graph resolution, static helpers (IsAadGroup, ExtractAadGroupId, StripClaims) have green unit tests</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: DI registration in App.xaml.cs</name>
|
|
<files>SharepointToolbox/App.xaml.cs</files>
|
|
<action>
|
|
Add DI registration for `ISharePointGroupResolver` in `App.xaml.cs` after the Phase 4 Bulk Members block (or near other service registrations):
|
|
```csharp
|
|
// Phase 17: Group Expansion
|
|
services.AddTransient<ISharePointGroupResolver, SharePointGroupResolver>();
|
|
```
|
|
Add the `using SharepointToolbox.Services;` if not already present (it should be since `IPermissionsService` is already registered from the same namespace).
|
|
</action>
|
|
<verify>
|
|
<automated>dotnet build --no-restore 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<done>ISharePointGroupResolver registered in DI container, solution builds with 0 errors</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `dotnet build` — 0 errors, 0 warnings
|
|
- `dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests"` — all tests pass
|
|
- `dotnet test` — full suite green (no regressions)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- ResolvedMember record exists at Core/Models/ResolvedMember.cs
|
|
- ISharePointGroupResolver interface defines ResolveGroupsAsync contract
|
|
- SharePointGroupResolver implements CSOM group user loading + Graph transitive resolution
|
|
- Static helpers (IsAadGroup, ExtractAadGroupId, StripClaims) have passing unit tests
|
|
- DI registration wired in App.xaml.cs
|
|
- Full test suite green
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/17-group-expansion-html-reports/17-01-SUMMARY.md`
|
|
</output>
|