Research covers all five v2.3 features: automated app registration, app removal, auto-take ownership, group expansion in HTML reports, and report consolidation toggle. No new NuGet packages required. Build order and phase implications documented. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
25 KiB
Technology Stack
Project: SharePoint Toolbox v2 Researched: 2026-04-08 (updated for v2.2 milestone)
v2.2 Stack Additions
This section covers only the NEW capability needs for v2.2 (Report Branding + User Directory). The full existing stack is documented in the section below. The short answer: no new NuGet packages are needed for either feature.
Feature 1: HTML Report Branding (Logo Embedding)
Requirement: Embed MSP logo (global) and client logo (per-tenant) into the self-contained HTML reports that already exist.
Approach: Base64 data URI — BCL only
The existing HTML export services (HtmlExportService, UserAccessHtmlExportService, etc.) produce fully self-contained HTML files using StringBuilder with all CSS and JS inlined. Logo images follow the same pattern: convert image bytes to a Base64 string and embed as an HTML <img> data URI.
// In a LogoEmbedHelper or directly in each export service:
byte[] bytes = await File.ReadAllBytesAsync(logoFilePath, ct);
string mime = Path.GetExtension(logoFilePath).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" => "image/jpeg",
".jpeg" => "image/jpeg",
".gif" => "image/gif",
".svg" => "image/svg+xml",
".webp" => "image/webp",
_ => "image/png"
};
string dataUri = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
// In HTML: <img src="{dataUri}" alt="Logo" style="height:48px;" />
Why this approach:
- Zero new dependencies.
File.ReadAllBytesAsync,Convert.ToBase64String, andPath.GetExtensionare all BCL. - The existing "no external dependencies" constraint on HTML reports is preserved.
- Self-contained EXE constraint is preserved — no logo file paths can break because the bytes are embedded in the HTML at export time.
- Base64 increases image size by ~33% but logos are small (< 50 KB typical); the impact on HTML file size is negligible.
Logo storage strategy — store file path, embed at export time:
Store the logo file path (not the base64) in AppSettings (global MSP logo) and TenantProfile (per-client logo). At export time, the export service reads the file and embeds it. This keeps JSON settings files small and lets the user swap logos without re-entering settings.
AppSettings.MspLogoPath: string?— path to MSP logo fileTenantProfile.ClientLogoPath: string?— path to client logo file for this tenant
The settings UI uses WPF OpenFileDialog (already used in multiple ViewModels) to browse for image files — filter to *.png;*.jpg;*.jpeg;*.gif;*.svg.
Logo preview in WPF UI: Use BitmapImage (built into System.Windows.Media.Imaging, already in scope for any WPF project). Bind a WPF Image control's Source to a BitmapImage loaded from the file path.
// In ViewModel — logo preview
[ObservableProperty]
private BitmapImage? _mspLogoPreview;
partial void OnMspLogoPathChanged(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !File.Exists(value))
{
MspLogoPreview = null;
return;
}
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(value, UriKind.Absolute);
bmp.CacheOption = BitmapCacheOption.OnLoad; // close file handle immediately
bmp.EndInit();
MspLogoPreview = bmp;
}
No new library needed: BitmapImage lives in the WPF PresentationCore assembly, which is already a transitive dependency of any <UseWPF>true</UseWPF> project.
Feature 2: User Directory Browse Mode (Graph API)
Requirement: In the User Access Audit tab, add a "Browse" mode alternative to the people-picker search. Shows a paginated list of all users in the tenant — no search query, just the full directory — allowing the admin to pick users by scrolling/filtering locally.
Graph API endpoint: GET /users (no filter)
The existing GraphUserSearchService calls GET /users?$filter=startsWith(...) with ConsistencyLevel: eventual. Full directory listing removes the $filter and uses $select for the fields needed.
Minimum required fields for directory browse:
displayName, userPrincipalName, mail, jobTitle, department, userType, accountEnabled
userType: distinguish"Member"from"Guest"— useful for MSP admin contextaccountEnabled: allow filtering out disabled accountsjobTitle/department: helps admin identify the right user in large directories
Permissions required (confirmed from Microsoft Learn):
| Scope type | Minimum permission |
|---|---|
| Delegated (work/school) | User.Read.All |
The existing auth uses https://graph.microsoft.com/.default which resolves to whatever scopes the Azure AD app registration has consented. If the MSP's app has User.Read.All consented (required for the existing people-picker to work), no new permission is needed — GET /users without $filter uses the same User.Read.All scope.
Pagination — PageIterator pattern:
GET /users returns a default page size of 100 with a maximum of 999 via $top. For tenants with hundreds or thousands of users, pagination via @odata.nextLink is mandatory.
The Microsoft.Graph 5.x SDK (already installed at 5.74.0) includes PageIterator<TEntity, TCollectionResponse> in Microsoft.Graph.Core. No version upgrade required.
// In a new IGraphUserDirectoryService / GraphUserDirectoryService:
var firstPage = await graphClient.Users.GetAsync(config =>
{
config.QueryParameters.Select = new[]
{
"displayName", "userPrincipalName", "mail",
"jobTitle", "department", "userType", "accountEnabled"
};
config.QueryParameters.Top = 999; // max page size
config.QueryParameters.Orderby = new[] { "displayName" };
config.Headers.Add("ConsistencyLevel", "eventual");
config.QueryParameters.Count = true; // required for $orderby with eventual
}, ct);
var allUsers = new List<DirectoryUserResult>();
var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
graphClient,
firstPage,
user =>
{
if (user.AccountEnabled == true) // optionally skip disabled
allUsers.Add(new DirectoryUserResult(
user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
user.UserPrincipalName ?? string.Empty,
user.Mail,
user.JobTitle,
user.Department,
user.UserType == "Guest"));
return true; // continue iteration
});
await pageIterator.IterateAsync(ct);
Why PageIterator over manual nextLink loop:
The Graph SDK's PageIterator correctly handles the DirectoryPageTokenNotFoundException pitfall — it uses the token from the last successful non-retry response for the next page request. Manual loops using withUrl(nextLink) are susceptible to this error if any retry occurs mid-iteration. The SDK pattern is the documented recommendation (Microsoft Learn, updated 2025-08-06).
Performance consideration — large tenants:
A tenant with 5,000 users fetching $top=999 requires 5 API round-trips. At ~300-500 ms per call, this is 1.5–2.5 seconds total. This is acceptable for a browse-on-demand operation with a loading indicator. Do NOT load the directory automatically on tab open — require an explicit "Load Directory" button click.
Local filtering after load:
Once the full directory is in memory (as an ObservableCollection<DirectoryUserResult>), use ICollectionView with a Filter predicate for instant local text-filter — the same pattern already used in the PermissionsViewModel and StorageViewModel. No server round-trips needed for filtering once the list is loaded. This is already in-process for the existing ViewModels and requires no new library.
New model record:
// Core/Models/DirectoryUserResult.cs — or extend GraphUserResult
public record DirectoryUserResult(
string DisplayName,
string UserPrincipalName,
string? Mail,
string? JobTitle,
string? Department,
bool IsGuest);
New service interface:
// Services/IGraphUserDirectoryService.cs
public interface IGraphUserDirectoryService
{
Task<IReadOnlyList<DirectoryUserResult>> GetAllUsersAsync(
string clientId,
bool includeGuests = true,
bool includeDisabled = false,
CancellationToken ct = default);
}
The implementation follows the same GraphClientFactory + GraphServiceClient pattern as GraphUserSearchService. Wire it in DI alongside the existing search service.
No New NuGet Packages Required (v2.2)
| Feature | What's needed | How provided |
|---|---|---|
| Logo file → Base64 data URI | Convert.ToBase64String, File.ReadAllBytesAsync |
BCL (.NET 10) |
| Logo preview in WPF settings | BitmapImage, Image control |
WPF / PresentationCore |
| Logo file picker | OpenFileDialog |
WPF / Microsoft.Win32 |
| Store logo path in settings | AppSettings.MspLogoPath, TenantProfile.ClientLogoPath |
Extend existing models |
| User directory listing | graphClient.Users.GetAsync() + PageIterator |
Microsoft.Graph 5.74.0 (already installed) |
| Local filtering of directory list | ICollectionView.Filter |
WPF / System.Windows.Data |
Do NOT add:
- Any HTML template engine (Razor, Scriban, Handlebars) —
StringBuilderis sufficient for logo injection - Any image processing library (ImageSharp, SkiaSharp standalone, Magick.NET) — no image transformation is needed, only raw bytes → Base64
- Any new Graph SDK packages —
Microsoft.Graph5.74.0 already includesPageIterator
Impact on Existing Services (v2.2)
HTML Export Services
Each existing export service (HtmlExportService, UserAccessHtmlExportService, StorageHtmlExportService, DuplicatesHtmlExportService, SearchHtmlExportService) needs a logo injection point. Two options:
Option A (recommended): ReportBrandingContext parameter
Introduce a small record carrying resolved logo data URIs. Export services accept it as an optional parameter; when null, no logo header is rendered. This keeps the services testable without file I/O.
public record ReportBrandingContext(
string? MspLogoDataUri, // "data:image/png;base64,..." or null
string? ClientLogoDataUri, // "data:image/png;base64,..." or null
string? MspName,
string? ClientName);
A ReportBrandingService converts file paths to data URIs. ViewModels call it before invoking the export service.
Option B: Inject branding directly into all BuildHtml signatures
Less clean — modifies every export service signature and every call site.
Option A is preferred: it isolates file I/O from HTML generation and keeps existing tests passing without changes.
UserAccessAuditViewModel
Add a BrowseMode boolean property (bound to a RadioButton or ToggleButton). When true, show the directory list panel instead of the people-picker search box. The IGraphUserDirectoryService is injected alongside the existing IGraphUserSearchService.
v2.3 Stack Additions
Researched: 2026-04-09 Scope: Only what is NEW vs the validated v2.2 stack. The short answer: no new NuGet packages are required for any v2.3 feature.
Feature 1: App Registration on Target Tenant (Auto + Guided Fallback)
What is needed: Create an Entra app registration in a target (client) tenant from within the app, using the already-authenticated delegated token.
No new packages required. The existing Microsoft.Graph SDK (currently 5.74.0, latest stable is 5.103.0 as of 2026-02-20) already supports this via:
graphClient.Applications.PostAsync(new Application { DisplayName = "...", RequiredResourceAccess = [...] })— creates the app object; returns the newappIdgraphClient.ServicePrincipals.PostAsync(new ServicePrincipal { AppId = newAppId })— instantiates the enterprise app in the target tenant so it can be consentedgraphClient.Applications["{objectId}"].DeleteAsync()— removes the registration (soft-delete, 30-day recycle bin in Entra)
All three operations are Graph v1.0 endpoints confirmed in official Microsoft Learn documentation (HIGH confidence).
Required delegated permissions for these Graph calls:
| Operation | Minimum delegated scope |
|---|---|
Create application (POST /applications) |
Application.ReadWrite.All |
Create service principal (POST /servicePrincipals) |
Application.ReadWrite.All |
Delete application (DELETE /applications/{id}) |
Application.ReadWrite.All |
Grant app role consent (POST /servicePrincipals/{id}/appRoleAssignments) |
AppRoleAssignment.ReadWrite.All |
The calling user must also hold the Application Administrator or Cloud Application Administrator Entra role on the target tenant (or Global Administrator). Without the role, the delegated call returns 403 regardless of scope consent.
Integration point — GraphClientFactory scope extension:
The existing GraphClientFactory.CreateClientAsync uses ["https://graph.microsoft.com/.default"], relying on pre-consented .default resolution. For app registration, add an overload:
// New method — only used by AppRegistrationService
public async Task<GraphServiceClient> CreateRegistrationClientAsync(
string clientId, CancellationToken ct)
{
var pca = await _msalFactory.GetOrCreateAsync(clientId);
var accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault();
// Explicit scopes trigger incremental consent on first call
var scopes = new[]
{
"Application.ReadWrite.All",
"AppRoleAssignment.ReadWrite.All"
};
var tokenProvider = new MsalTokenProvider(pca, account, scopes);
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
return new GraphServiceClient(authProvider);
}
MSAL will prompt for incremental consent if not yet granted. This keeps the default CreateClientAsync scopes unchanged and avoids over-permissioning all Graph calls throughout the app.
TenantProfile model extension:
// Add to TenantProfile.cs
public string? AppObjectId { get; set; } // Entra object ID of the registered app
// null until registration completes
// used for deletion
Stored in JSON (existing ProfileService persistence). No schema migration needed — System.Text.Json deserializes missing properties as their default value (null).
Guided fallback path: If the automated registration fails (user lacks Application Administrator role, or consent blocked by tenant policy), open a browser to the Entra admin center app registration URL. No additional API calls needed for the fallback path.
Feature 2: App Removal from Target Tenant
Same stack as Feature 1. graphClient.Applications["{objectId}"].DeleteAsync() is the Graph v1.0 DELETE /applications/{id} endpoint. Returns 204 on success. AppObjectId stored in TenantProfile provides the handle.
Deletion behavior: apps go to Entra's 30-day deleted items container and can be restored via the admin center. The app does not need to handle restoration — that is admin-center territory.
Feature 3: Auto-Take Ownership of Sites on Access Denied (Global Toggle)
No new packages required. PnP Framework 1.18.0 already exposes the SharePoint tenant admin API via the Tenant class, which can add a site collection admin without requiring existing access to that site:
// Requires ClientContext pointed at the tenant admin site
// (e.g., https://contoso-admin.sharepoint.com)
var tenant = new Tenant(adminCtx);
tenant.SetSiteAdmin(siteUrl, userLogin, isAdmin: true);
adminCtx.ExecuteQueryRetry();
Critical constraint (HIGH confidence): Tenant.SetSiteAdmin calls the SharePoint admin tenant API. This bypasses the site-level permission check — it does NOT require the authenticated user to already be a member of the site. It DOES require the user to hold the SharePoint Administrator or Global Administrator Entra role. If the user lacks this role, the call throws ServerException with "Access denied."
Integration point — SessionManager admin context:
The existing SessionManager.GetOrCreateContextAsync accepts a TenantProfile and uses profile.TenantUrl as the site URL. For tenant admin operations, a second context is needed pointing at the admin URL. Add:
// New method in SessionManager (no new library, same PnP auth path)
public async Task<ClientContext> GetOrCreateAdminContextAsync(
TenantProfile profile, CancellationToken ct = default)
{
// Derive admin URL: https://contoso.sharepoint.com -> https://contoso-admin.sharepoint.com
var adminUrl = profile.TenantUrl
.TrimEnd('/')
.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
var adminProfile = profile with { TenantUrl = adminUrl };
return await GetOrCreateContextAsync(adminProfile, ct);
}
Global toggle storage: Add AutoTakeOwnership: bool to AppSettings (existing JSON settings file). No new model needed.
Feature 4: Expand Groups in HTML Reports (Clickable to Show Members)
No new packages required. Pure C# + inline JavaScript.
Server-side group member resolution: SharePoint group members are already loaded during the permissions scan via CSOM RoleAssignment.Member. For SharePoint groups, Member is a Group object. Load its Users collection:
// Already inside the permissions scan loop — extend it
if (roleAssignment.Member is Group spGroup)
{
ctx.Load(spGroup.Users);
// ExecuteQueryRetry already called in the scan loop
// Members available as spGroup.Users
}
No additional API calls beyond what the existing scan already performs (the Users collection is a CSOM lazy-load — one additional batch per group, amortized over the scan).
HTML export change: Pass group member lists into the export service as part of the existing PermissionEntry model (extend with IReadOnlyList<string>? GroupMembers). The export service renders members as a collapsible <details>/<summary> HTML element inline with each group-access row — pure HTML5, no JS library, no external dependency.
Report consolidation pre-processing: Consolidation is a LINQ step before export. No new model or service.
Feature 5: Report Consolidation Toggle (Merge Duplicate User Entries)
No new packages required. Standard LINQ aggregation on the existing IReadOnlyList<UserAccessEntry> before handing off to any export service.
Consolidation merges rows with the same (UserLogin, SiteUrl, PermissionLevel) key, collecting distinct GrantedThrough values into a semicolon-joined string. Add a ConsolidateEntries(IReadOnlyList<UserAccessEntry>) static helper in a shared location (e.g., UserAccessEntryExtensions). Toggle stored in AppSettings or passed as a flag at export time.
No New NuGet Packages Required (v2.3)
| Feature | What's needed | How provided |
|---|---|---|
| Create app registration | graphClient.Applications.PostAsync |
Microsoft.Graph 5.74.0 (existing) |
| Create service principal | graphClient.ServicePrincipals.PostAsync |
Microsoft.Graph 5.74.0 (existing) |
| Delete app registration | graphClient.Applications[id].DeleteAsync |
Microsoft.Graph 5.74.0 (existing) |
| Grant app role consent | graphClient.ServicePrincipals[id].AppRoleAssignments.PostAsync |
Microsoft.Graph 5.74.0 (existing) |
| Add site collection admin | new Tenant(ctx).SetSiteAdmin(...) |
PnP.Framework 1.18.0 (existing) |
| Admin site context | SessionManager.GetOrCreateAdminContextAsync — new method, no new lib |
PnP.Framework + MSAL (existing) |
| Group member loading | ctx.Load(spGroup.Users) + ExecuteQueryRetry |
PnP.Framework 1.18.0 (existing) |
| HTML group expansion | <details>/<summary> HTML5 element |
Plain HTML, BCL StringBuilder |
| Consolidation logic | GroupBy + LINQ |
BCL (.NET 10) |
| Incremental Graph scopes | MsalTokenProvider with explicit scopes |
MSAL 4.83.3 (existing) |
Do NOT add:
| Package | Reason to Skip |
|---|---|
Azure.Identity |
App uses Microsoft.Identity.Client (MSAL) directly via BaseBearerTokenAuthenticationProvider. Azure.Identity would duplicate auth and conflict with the existing PCA + MsalCacheHelper token-cache-sharing pattern. |
| PnP Core SDK | Distinct from PnP Framework (CSOM-based). Adding both creates confusion, ~15 MB extra weight, and no benefit since Tenant.SetSiteAdmin already exists in PnP.Framework 1.18.0. |
| Any HTML template engine (Razor, Scriban) | StringBuilder pattern is established and sufficient. Template engines add complexity with no gain for server-side HTML generation. |
| SignalR / polling / background service | Auto-ownership is a synchronous, on-demand CSOM call triggered by an access-denied event. No push infrastructure needed. |
Version Bump Consideration (v2.3)
| Package | Current | Latest Stable | Recommendation |
|---|---|---|---|
Microsoft.Graph |
5.74.0 | 5.103.0 | Optional. All new Graph API calls work on 5.74.0. Bump only if a specific bug is encountered. All 5.x versions maintain API compatibility. |
PnP.Framework |
1.18.0 | Check NuGet before bumping | Hold. Tenant.SetSiteAdmin works in 1.18.0. PnP Framework version bumps have historically introduced CSOM interop issues. Bump only with explicit testing. |
Existing Stack (Unchanged)
The full stack as validated through v2.2:
| Technology | Version | Purpose |
|---|---|---|
| .NET 10 | 10.x | Target runtime (LTS until Nov 2028) |
| WPF | built-in | UI framework |
| C# 13 | built-in | Language |
| PnP.Framework | 1.18.0 | SharePoint CSOM, provisioning engine |
| Microsoft.Graph | 5.74.0 | Graph API (users, Teams, Groups, app management) |
| Microsoft.Identity.Client (MSAL) | 4.83.3 | Multi-tenant auth, token acquisition |
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 | Token cache persistence |
| Microsoft.Identity.Client.Broker | 4.82.1 | Windows broker (WAM) |
| CommunityToolkit.Mvvm | 8.4.2 | MVVM base classes, source generators |
| Microsoft.Extensions.Hosting | 10.0.0 | DI container, app lifetime |
| LiveCharts2 (SkiaSharpView.WPF) | 2.0.0-rc5.4 | Storage charts |
| Serilog | 4.3.1 | Structured logging |
| Serilog.Extensions.Hosting | 10.0.0 | ILogger bridge |
| Serilog.Sinks.File | 7.0.0 | Rolling file output |
| CsvHelper | 33.1.0 | CSV export |
| System.Text.Json | built-in | JSON settings/profiles/templates |
| xUnit | 2.9.3 | Unit tests |
| Moq | 4.20.72 | Test mocking |
Sources
v2.2 sources:
- Microsoft Learn — List users (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — permissions, $top max 999, $orderby with ConsistencyLevel (HIGH confidence)
- Microsoft Learn — Page through a collection (Graph SDKs): https://learn.microsoft.com/en-us/graph/sdks/paging — PageIterator C# pattern (HIGH confidence)
v2.3 sources:
- Microsoft Learn — Create application (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0 — C# SDK 5.x pattern confirmed,
Application.ReadWrite.Allrequired (HIGH confidence, updated 2026-03-14) - Microsoft Learn — Create servicePrincipal (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals?view=graph-rest-1.0 —
Application.ReadWrite.Allrequired for multi-tenant apps (HIGH confidence, updated 2026-03-14) - Microsoft Learn — Delete application (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/application-delete?view=graph-rest-1.0 —
graphClient.Applications[id].DeleteAsync(), 204 response, 30-day soft-delete (HIGH confidence) - Microsoft Learn — Grant permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph —
AppRoleAssignment.ReadWrite.Allfor consent grants, C# SDK 5.x examples (HIGH confidence, updated 2026-03-21) - Microsoft Learn — Choose authentication providers: https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers — Interactive provider pattern for desktop apps confirmed (HIGH confidence, updated 2025-08-06)
- PnP Core SDK docs — Security/SetSiteCollectionAdmins: https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html — tenant API bypasses site-level permission check, requires SharePoint Admin role (MEDIUM confidence — PnP Core docs; maps to PnP Framework
Tenant.SetSiteAdminbehavior) - Microsoft.Graph NuGet package: https://www.nuget.org/packages/Microsoft.Graph/ — latest stable 5.103.0 confirmed 2026-02-20 (HIGH confidence)
- Codebase — GraphClientFactory.cs, SessionManager.cs, MsalClientFactory.cs — confirmed existing
BaseBearerTokenAuthenticationProvider+ MSAL PCA integration pattern (HIGH confidence, source read directly)