35 KiB
Phase 17: Group Expansion in HTML Reports - Research
Researched: 2026-04-09 Domain: SharePoint CSOM group member resolution / HTML export rendering / async pre-resolution service Confidence: HIGH
Summary
Phase 17 adds expandable SharePoint group rows to the site-centric permissions HTML report (HtmlExportService). In the existing report, a SharePoint group appears as an opaque user-pill in the "Users/Groups" column (rendered from PermissionEntry.Users, PrincipalType = "SharePointGroup"). Phase 17 makes that pill clickable — clicking expands a hidden sub-row listing the group's members.
Member resolution is a pre-render step: the ViewModel resolves all group members via CSOM before calling BuildHtml, then passes a resolution dictionary into the service. The HTML service itself remains pure (no async I/O). Transitive membership is achieved by recursively resolving any AAD group members found in a SharePoint group via Graph groups/{id}/transitiveMembers. When CSOM or Graph cannot resolve members (throttling, insufficient scope), the group pill renders with a "members unavailable" label rather than failing the export.
The user access audit report (UserAccessHtmlExportService) is NOT in scope: that report is user-centric, and groups appear only as GrantedThrough text — there are no expandable group rows to add there.
Primary recommendation: Create a new ISharePointGroupResolver service that uses CSOM (ClientContext.Web.SiteGroups.GetByName(name).Users) plus optional Graph transitive lookup for nested AAD groups. The ViewModel calls this service before export and passes IReadOnlyDictionary<string, IReadOnlyList<string>> groupMembers into HtmlExportService.BuildHtml. The HTML uses the existing toggleGroup() JS pattern with a new grpmem{idx} ID namespace for member sub-rows.
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| RPT-01 | User can expand SharePoint groups in HTML reports to see group members | Group pill in site-centric HtmlExportService becomes clickable; hidden sub-rows per member revealed via toggleGroup(); member data passed as pre-resolved dictionary from ViewModel |
| RPT-02 | Group member resolution uses transitive membership to include nested group members | CSOM resolves direct SharePoint group users; for user entries that are AAD groups (login matches `c:0t.c |
| </phase_requirements> |
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Microsoft.SharePoint.Client (CSOM) | Already referenced | ctx.Web.SiteGroups.GetByName(name).Users — only supported API for classic SharePoint group members |
Graph v1.0 and beta DO NOT support classic SharePoint group membership enumeration; CSOM is the only supported path |
| Microsoft.Graph (Graph SDK) | Already referenced | graphClient.Groups[aadGroupId].TransitiveMembers.GraphUser.GetAsync() — resolves nested AAD groups transitively |
Existing Graph SDK usage throughout codebase (GraphUserDirectoryService, GraphUserSearchService, BulkMemberService) |
GraphClientFactory |
Project-internal | Creates GraphServiceClient with MSAL tokens |
Already used in all Graph-calling services; same pattern |
ExecuteQueryRetryHelper |
Project-internal | CSOM retry with throttle handling | Already used in PermissionsService, BulkMemberService; handles 429 and 503 |
No New NuGet Packages
Phase 17 requires zero new NuGet packages. All required APIs are already referenced.
Critical API Finding: SharePoint Classic Groups vs Graph
SharePoint classic groups (e.g. "Site Members", "HR Team") are managed by SharePoint, not Azure AD. Microsoft Graph v1.0 has no endpoint for their membership. The Graph beta sharePointGroup resource is for SharePoint Embedded containers only — not for classic on-site groups. The only supported enumeration path is CSOM:
// Source: Microsoft CSOM documentation (learn.microsoft.com/sharepoint/dev)
var group = ctx.Web.SiteGroups.GetByName("Site Members");
ctx.Load(group.Users);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var user in group.Users) { ... }
A SharePoint group member can itself be an AAD/M365 group (login contains c:0t.c|tenant|<guid> pattern). For these nested AAD groups, Graph transitiveMembers resolves all leaf users in a single call — satisfying RPT-02.
Architecture Patterns
Recommended Project Structure Changes
SharepointToolbox/
├── Services/
│ ├── ISharePointGroupResolver.cs ← NEW interface
│ └── SharePointGroupResolver.cs ← NEW service
├── Services/Export/
│ └── HtmlExportService.cs ← BuildHtml overload + group pill rendering
└── ViewModels/Tabs/
└── PermissionsViewModel.cs ← call resolver before export, pass dict
SharepointToolbox.Tests/
└── Services/
├── SharePointGroupResolverTests.cs ← NEW (mostly skip-marked, requires live tenant)
└── Export/
└── HtmlExportServiceTests.cs ← extend: group pill expansion tests
Pattern 1: Pre-Resolution Architecture (ViewModel orchestrates, service stays pure)
What: ViewModel calls ISharePointGroupResolver.ResolveGroupsAsync() to build a Dictionary<string, IReadOnlyList<string>> groupMembers keyed by group name (exact match with PermissionEntry.Users value for SharePoint group rows). This dict is passed to BuildHtml. HTML export service remains synchronous and pure — no async I/O inside string building.
Why: Consistent with how BrandingService provides ReportBranding to the HTML service — the ViewModel fetches context, export service renders it. Avoids making export services async or injecting CSOM/Graph into what are currently pure string-building classes.
Data flow:
PermissionsViewModel.ExportHtmlAsync()
→ collect group names from Results where PrincipalType == "SharePointGroup"
→ ISharePointGroupResolver.ResolveGroupsAsync(ctx, clientId, groupNames, ct)
→ per group: CSOM SiteGroups.GetByName(name).Users → List<MemberInfo>
→ per nested AAD group: Graph transitiveMembers → leaf users
→ on throttle/error: return empty list (graceful fallback)
→ HtmlExportService.BuildHtml(entries, groupMembers, branding)
→ for group pills: if groupMembers contains name → render expandable pill + hidden sub-rows
→ else if name not in dict → render plain pill (no expand)
→ if dict has empty list → render pill + "members unavailable" sub-row
Pattern 2: ISharePointGroupResolver Interface
What: A focused single-method async service that accepts a ClientContext (for CSOM) and clientId string (for Graph factory), resolves a set of group names to their transitive leaf-user members.
// NEW: Services/ISharePointGroupResolver.cs
public interface ISharePointGroupResolver
{
/// <summary>
/// Resolves SharePoint group names to their transitive member display names.
/// Returns empty list for any group that cannot be resolved (throttled, not found, etc.).
/// Never throws — failures are surfaced as empty member lists.
/// </summary>
Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
ClientContext ctx,
string clientId,
IReadOnlyList<string> groupNames,
CancellationToken ct);
}
// Simple value record for a resolved leaf member
public record ResolvedMember(string DisplayName, string Login);
Why a dict keyed by group name: The PermissionEntry.Users field for a SharePoint group row IS the group display name (set to member.Title in PermissionsService.ExtractPermissionsAsync). The dict lookup is O(1) at render time.
Pattern 3: SharePointGroupResolver — CSOM + Graph Resolution
What: CSOM loads group users. Each user login is inspected for the AAD group prefix pattern (c:0t.c|tenant|<guid>). For matching entries, the GUID is extracted and used to call graphClient.Groups[guid].TransitiveMembers.GraphUser.GetAsync(). All leaf users merged and de-duplicated.
// Source: CSOM pattern from Microsoft docs + existing ExecuteQueryRetryHelper usage
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);
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
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))
{
// Expand nested AAD group transitively via Graph
var aadId = ExtractAadGroupId(user.LoginName);
var leafUsers = 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 (Exception ex)
{
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
result[groupName] = Array.Empty<ResolvedMember>(); // graceful fallback
}
}
return result;
}
// AAD group login pattern: "c:0t.c|tenant|<guid>"
private static bool IsAadGroup(string login) =>
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase);
private static string ExtractAadGroupId(string login) =>
login[(login.LastIndexOf('|') + 1)..];
Pattern 4: Graph Transitive Members for Nested AAD Groups
What: Call graphClient.Groups[aadGroupId].TransitiveMembers.GraphUser.GetAsync() to get all leaf users recursively in one call. The /microsoft.graph.user cast filters to only user objects (excludes sub-groups, devices, etc.).
// Source: Graph SDK pattern — consistent with GraphUserDirectoryService.cs PageIterator usage
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<User, UserCollectionResponse>.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 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>(); // graceful fallback
}
}
Pattern 5: HTML Rendering — Expandable Group Pill
What: When a PermissionEntry has PrincipalType = "SharePointGroup" and the group name is in groupMembers:
- If members list is non-empty: render clickable span with toggle + hidden member sub-rows
- If members list is empty (resolution failed): render span + "members unavailable" sub-row
- If group name NOT in dict (resolver wasn't called, or group was skipped): render plain pill (existing behavior)
Uses the SAME toggleGroup() JS function already in both BuildHtml (standard) and BuildConsolidatedHtml. New ID namespace: grpmem{idx} (distinct from ugrp, sgrp, loc).
<!-- Expandable group pill — members resolved -->
<span class="user-pill group-expandable"
onclick="toggleGroup('grpmem0')"
style="cursor:pointer">HR Site Members ▼</span>
<!-- Hidden member sub-rows follow immediately after the main <tr> -->
<tr data-group="grpmem0" style="display:none">
<td colspan="7" style="padding-left:2em;font-size:.8rem;color:#555">
Alice Smith <alice@contoso.com> •
Bob Jones <bob@contoso.com>
</td>
</tr>
<!-- Expandable group pill — resolution failed -->
<span class="user-pill group-expandable"
onclick="toggleGroup('grpmem1')"
style="cursor:pointer">Visitors ▼</span>
<tr data-group="grpmem1" style="display:none">
<td colspan="7" style="padding-left:2em;font-size:.8rem;color:#888;font-style:italic">
members unavailable
</td>
</tr>
CSS addition (inline in BuildHtml):
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
toggleGroup() JS: Reuse the EXISTING function from HtmlExportService.BuildHtml — it is currently absent from HtmlExportService (unlike UserAccessHtmlExportService). The standard HtmlExportService only has a filterTable() function. Phase 17 must add toggleGroup() to HtmlExportService's inline JS.
Pattern 6: PermissionsViewModel — Identify Groups and Call Resolver
What: In ExportHtmlAsync(), before calling _htmlExportService.WriteAsync():
- Collect distinct group names from
ResultswherePrincipalType == "SharePointGroup" - If no groups → pass empty dict (existing behavior, no CSOM calls)
- If groups exist → call
_sharePointGroupResolver.ResolveGroupsAsync(ctx, clientId, groupNames, ct) - Pass resolved dict to
BuildHtml
The ViewModel already has _currentProfile (for ClientId) and _sessionManager (for ClientContext). A ClientContext for the current site is available via sessionManager.GetOrCreateContextAsync(profile, ct).
Complication: The permissions scan may span multiple sites. Groups are site-scoped — a group named "Site Members" may exist on every site. Phase 17 resolves group names using the FIRST site's context (or the selected site if single-site). This is acceptable because group names in the HTML report are also site-scoped in the display — the group name badge appears inline per row.
Alternative (simpler): Resolve using the primary context from _currentProfile (the site collection admin context used for scanning). SharePoint group names are per-site, so for multi-site scans, group names may collide between sites. The simpler approach: resolve using the first available context. This is flagged as an open question.
Pattern 7: BuildHtml Signature Extension
What: Add an optional IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null parameter to both BuildHtml overloads and WriteAsync. Passing null (or omitting) preserves 100% existing behavior — existing callers need zero changes.
// HtmlExportService.cs — add parameter to both BuildHtml overloads and WriteAsync
public string BuildHtml(
IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
public async Task WriteAsync(
IReadOnlyList<PermissionEntry> entries,
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
Anti-Patterns to Avoid
- Making
HtmlExportService.BuildHtmlasync: Breaks the pure/testable design. Pre-resolve in ViewModel, pass dict. - Injecting
ISharePointGroupResolverintoHtmlExportService: Export service should remain infrastructure-free. Resolver belongs in the ViewModel layer. - Using Graph v1.0
groups/{id}/membersto get SharePoint classic group members: Graph does NOT support classic SharePoint group membership. Only CSOM works. - Calling resolver for every export regardless of whether groups exist: Always check
Results.Any(r => r.PrincipalType == "SharePointGroup")first — skip resolver entirely if no group rows present (zero CSOM calls overhead). - Re-using
ugrp/sgrp/locID prefixes for group member sub-rows: Collisions with existing toggleGroup IDs inUserAccessHtmlExportService. Usegrpmem{idx}. - Blocking the UI thread during group resolution: Resolution must be
await-ed in an async command, same pattern as existingExportHtmlAsyncinPermissionsViewModel.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| CSOM retry / throttle handling | Custom retry loop | ExecuteQueryRetryHelper.ExecuteQueryRetryAsync |
Already handles 429/503 with exponential backoff; identical to how PermissionsService calls CSOM |
| Graph auth token acquisition | Custom HTTP client | GraphClientFactory.CreateClientAsync(clientId, ct) |
MSAL + Graph SDK already wired; same factory used in GraphUserDirectoryService and BulkMemberService |
| Transitive AAD group expansion | Recursive Graph calls | groups/{id}/transitiveMembers/microsoft.graph.user + PageIterator |
Single Graph call returns all leaf users regardless of nesting depth; PageIterator handles pagination |
| JavaScript expand/collapse for member rows | New JS function | Existing toggleGroup(id) function — add it to HtmlExportService |
Identical implementation already in UserAccessHtmlExportService; just needs to be added to HtmlExportService inline JS block |
| HTML encoding | Custom escaping | HtmlExportService.HtmlEncode() private method (already exists) |
Already handles &, <, >, ", ' |
Common Pitfalls
Pitfall 1: Confusing SharePoint Classic Groups with AAD/M365 Groups
What goes wrong: Developer calls graphClient.Groups[id] for a SharePoint classic group (login c:0(.s|true prefix or similar) — this throws a 404 because SharePoint groups are not AAD groups.
Why it happens: SharePoint groups have login names like c:0(.s|true or c:0t.c|tenant|<spGroupId> — not the same as AAD group objects.
How to avoid: Only call Graph for logins that match the AAD group pattern c:0t.c|tenant|<guid> (a valid GUID in the final segment). All other members are treated as leaf users directly.
Warning signs: ServiceException: Resource not found from Graph SDK during member resolution.
Pitfall 2: toggleGroup() Missing from HtmlExportService Inline JS
What goes wrong: The group pill onclick calls toggleGroup('grpmem0') but HtmlExportService currently only has filterTable() in its inline JS — not toggleGroup().
Why it happens: toggleGroup() was added to UserAccessHtmlExportService (Phase 7) but never added to HtmlExportService (which renders the site-centric permissions report).
How to avoid: Add toggleGroup() to HtmlExportService's <script> block as part of Phase 17. Do NOT import from UserAccessHtmlExportService — both services are self-contained.
Warning signs: Clicking a group pill does nothing in the rendered HTML.
Pitfall 3: Group Name Case Sensitivity in Dictionary Lookup
What goes wrong: Group name in PermissionEntry.Users is "HR Team" but resolver stores "hr team" — dict lookup returns null, group renders as non-expandable.
Why it happens: SharePoint group names are case-preserving but comparison varies.
How to avoid: Use StringComparer.OrdinalIgnoreCase for the result dictionary. Collect group names using .ToHashSet(StringComparer.OrdinalIgnoreCase) before calling resolver.
Pitfall 4: CSOM progress Parameter in ExecuteQueryRetryHelper
What goes wrong: SharePointGroupResolver calls ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct) but has no IProgress<OperationProgress> — passing null works but requires verifying the helper accepts null.
Why it happens: The helper signature requires an IProgress<OperationProgress> parameter.
How to avoid: Inspect ExecuteQueryRetryHelper signature — if it nullable-accepts null, pass null; otherwise create a no-op Progress<OperationProgress>(_ => { }). Checking the existing usage in PermissionsService shows progress is non-nullable in the interface but null can be passed as IProgress<T>?.
Warning signs: NullReferenceException inside retry helper during member resolution.
Pitfall 5: Multi-Site Group Name Collision
What goes wrong: Site A and Site B both have a group named "Members". Resolver resolves using Site A's context — members list for "Members" is from Site A, but the HTML report row may be from Site B.
Why it happens: Group names are per-site; the permissions scan spans multiple sites.
How to avoid: Accept this limitation as Phase 17 scope. Document that group resolution uses the first available site context. For a full solution, the dict key would need to be (siteUrl, groupName) — defer to a future phase. Add a note in the HTML "members may reflect a specific site's group configuration."
Warning signs: Users report incorrect member lists for groups with the same name across sites.
Pitfall 6: No ExpandGroup Option in Simplified HTML Export
What goes wrong: HtmlExportService has TWO BuildHtml overloads — one for IReadOnlyList<PermissionEntry> (standard) and one for IReadOnlyList<SimplifiedPermissionEntry> (simplified). Both need the groupMembers parameter.
Why it happens: The simplified export also renders SharePoint group pills via the same pattern (see HtmlExportService.cs lines 268-297).
How to avoid: Add groupMembers parameter to both BuildHtml overloads. The PermissionsViewModel calls both paths (IsSimplified flag). Both must receive the resolved dict.
Code Examples
Collecting Group Names from Results (ViewModel)
// In PermissionsViewModel.ExportHtmlAsync() — before calling WriteAsync
var groupNames = Results
.Where(r => r.PrincipalType == "SharePointGroup")
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(n => n.Trim())
.Where(n => n.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
if (groupNames.Count > 0 && _groupResolver != null)
{
var ctx = await _sessionManager.GetOrCreateContextAsync(
new TenantProfile { TenantUrl = _currentProfile!.TenantUrl, ClientId = _currentProfile.ClientId },
ct);
groupMembers = await _groupResolver.ResolveGroupsAsync(ctx, _currentProfile.ClientId, groupNames, ct);
}
await _htmlExportService.WriteAsync(Results, dialog.FileName, ct, branding, groupMembers);
HTML Pill Rendering (HtmlExportService)
// In BuildHtml — replace existing pill rendering for SharePointGroup entries
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isGroup = entry.PrincipalType == "SharePointGroup";
if (isGroup && groupMembers != null && groupMembers.TryGetValue(name, out var members))
{
var memId = $"grpmem{grpMemIdx++}";
var arrow = " ▼"; // ▼
pillsBuilder.Append(
$"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{memId}')\"" +
$">{HtmlEncode(name)}{arrow}</span>");
// Emit hidden member sub-rows after this <tr> closes
memberSubRows.AppendLine($"<tr data-group=\"{memId}\" style=\"display:none\">");
memberSubRows.AppendLine($" <td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">");
if (members.Count == 0)
{
memberSubRows.Append("<em style=\"color:#888\">members unavailable</em>");
}
else
{
memberSubRows.Append(string.Join(" • ", members.Select(m =>
$"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>")));
}
memberSubRows.AppendLine(" </td>");
memberSubRows.AppendLine("</tr>");
}
else
{
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
sb.AppendLine($" <td>{pillsBuilder}</td>");
// ... rest of row ...
sb.AppendLine("</tr>");
sb.Append(memberSubRows); // append member sub-rows immediately after main row
memberSubRows.Clear();
CSOM Group User Loading
// Source: CSOM pattern — identical to pattern used in BulkMemberService.AddToClassicGroupAsync
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);
Graph Transitive Members (AAD nested groups)
// Source: Graph SDK pattern — consistent with GraphUserDirectoryService.cs PageIterator usage
// GET /groups/{aadGroupId}/transitiveMembers/microsoft.graph.user
var response = await graphClient
.Groups[aadGroupId]
.TransitiveMembers
.GraphUser // cast to user objects only — excludes sub-groups, devices
.GetAsync(config =>
{
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
config.QueryParameters.Top = 999;
}, ct);
// Use PageIterator for pagination (same pattern as GraphUserDirectoryService)
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
BuildHtml(entries, branding) — groups opaque |
BuildHtml(entries, branding, groupMembers?) — groups expandable |
Phase 17 | Existing callers pass nothing; behavior unchanged |
| No member resolution service | ISharePointGroupResolver via CSOM + Graph transitive |
Phase 17 | New DI service registration in App.xaml.cs |
HtmlExportService has no toggleGroup() JS |
toggleGroup() added to inline JS block |
Phase 17 | Required for interactive group expansion |
Deprecated / not applicable:
- Graph
sharePointGroupbeta endpoint: Not applicable — targets SharePoint Embedded containers, not classic SharePoint site groups - Graph
groups/{id}/members: Not applicable for SharePoint classic groups (only for AAD groups)
Open Questions
-
Multi-site group name collision
- What we know: Groups are site-scoped; same name may exist on multiple sites in a multi-site scan
- What's unclear: Should resolution use the first site's context, or resolve per-site?
- Recommendation: Use the first site's context for Phase 17. This is a known limitation. If the plan decides per-site resolution is needed, the dict key changes to
(siteUrl, groupName)— a bounded change.
-
ExecuteQueryRetryHelpernull progress parameter- What we know:
PermissionsServicepassesprogress(non-null) always - What's unclear: Whether
ExecuteQueryRetryHelperacceptsnullfor the progress parameter - Recommendation: Read
ExecuteQueryRetryHelperbefore implementing; if it does not accept null, createProgress<OperationProgress>(_ => { })no-op.
- What we know:
-
Which simplified export is affected
- What we know:
HtmlExportServicehas twoBuildHtmloverloads — one forPermissionEntry(standard) and one forSimplifiedPermissionEntry(simplified with risk labels) - What's unclear: Should group expansion apply to BOTH, or only the standard report?
- Recommendation: Apply to both —
SimplifiedPermissionEntrywrapsPermissionEntryand the same group pills are rendered; addinggroupMembersto the simplified overload is minimal extra work.
- What we know:
-
ResolvedMembermodel placement- What we know: The model is used by both the service and the HTML export
- What's unclear: Should it live in
Core.Models(alongsideUserAccessEntry, etc.) or inServicesnamespace? - Recommendation: Place in
Core.Models— consistent withLocationInfo,GraphDirectoryUser,GraphUserResultwhich are all small value records in that namespace.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | xUnit (project: SharepointToolbox.Tests) |
| Config file | SharepointToolbox.Tests/SharepointToolbox.Tests.csproj |
| Quick run command | dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" --no-build |
| Full suite command | dotnet test |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| RPT-01-a | BuildHtml with no groupMembers → existing output byte-identical |
unit | dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" |
✅ (extend existing) |
| RPT-01-b | BuildHtml with groupMembers for a group → renders clickable pill with toggle id |
unit | dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" |
✅ (extend existing) |
| RPT-01-c | BuildHtml with resolved members → hidden sub-rows with member names |
unit | dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" |
✅ (extend existing) |
| RPT-01-d | BuildHtml with empty member list (failed resolution) → "members unavailable" label |
unit | dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" |
✅ (extend existing) |
| RPT-01-e | HtmlExportService inline JS contains toggleGroup function |
unit | dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" |
✅ (extend existing) |
| RPT-02-a | SharePointGroupResolver returns empty list (not throw) when CSOM group not found |
unit (mock) | dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests" |
❌ Wave 0 |
| RPT-02-b | SharePointGroupResolver.ResolveGroupsAsync with live tenant → actual members |
skip | [Fact(Skip="Requires live SP tenant")] |
❌ Wave 0 |
| RPT-02-c | AAD group login detection (IsAadGroup) — unit test on pattern |
unit | dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests" |
❌ Wave 0 |
Sampling Rate
- Per task commit:
dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" --no-build - Per wave merge:
dotnet test - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs— covers RPT-02-a, RPT-02-b (skip), RPT-02-cCore/Models/ResolvedMember.cs— new value record needed before tests can compileServices/ISharePointGroupResolver.cs— interface stub needed before resolver tests compile- No new framework install needed — xUnit already configured
Sources
Primary (HIGH confidence)
- Direct inspection of
SharepointToolbox/Services/Export/HtmlExportService.cs— twoBuildHtmloverloads, user pill rendering, inline JS (notoggleGroup),WriteAsyncsignature - Direct inspection of
SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs—toggleGroup()JS implementation (lines 285-289 in C# string, confirmed via reading) - Direct inspection of
SharepointToolbox/Services/PermissionsService.cslines 312-335 — howPrincipalType = "SharePointGroup"andGrantedThrough = "SharePoint Group: {name}"are set;member.Title= group display name stored inUsers - Direct inspection of
SharepointToolbox/Services/BulkMemberService.cs— CSOMSiteGroups.GetByNamepattern andgraphClient.Groups[id]pattern - Direct inspection of
SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs—CreateClientAsync(clientId, ct)returnsGraphServiceClient - Direct inspection of
SharepointToolbox/Services/GraphUserDirectoryService.cs—PageIteratorpattern for Graph pagination - Direct inspection of
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs—_currentProfile,_sessionManager,ExportHtmlAsynccall sites
Secondary (MEDIUM confidence)
- Microsoft Learn: List group transitive members —
GET /groups/{id}/transitiveMembers/microsoft.graph.userv1.0 endpoint confirmed - Microsoft Q&A: Graph API cannot fetch classic SharePoint site group members — confirms CSOM is the only path for classic SharePoint groups
- Microsoft Q&A: Retrieve SharePoint Site Group Members via API — confirms SharePoint REST
/_api/web/sitegroups/getbyname/usersas alternative (not used here; CSOM is preferred given existing CSOM infrastructure)
Tertiary (LOW confidence)
- AAD group login format
c:0t.c|tenant|<guid>— derived from codebase pattern inspection and community examples; not found in official Microsoft docs but consistent with observed SharePoint Online claims encoding
Metadata
Confidence breakdown:
- Standard stack: HIGH — all dependencies already in project; no new NuGet packages
- Architecture: HIGH — all patterns directly observed in existing code; pre-resolution approach mirrors branding service pattern
- Pitfalls: HIGH — derived from direct reading of all affected files and confirmed API limitations
- Graph API for transitive members: HIGH — official Microsoft Learn docs confirmed
- CSOM-only for classic groups: HIGH — multiple official Microsoft Q&A sources confirm Graph limitation
- AAD group login prefix pattern: MEDIUM — inferred from codebase + community; not in official reference docs
Research date: 2026-04-09 Valid until: 2026-05-09 (stable CSOM/Graph APIs; toggleGroup pattern is internal to project)