Files
Dev 853f47c4a6 docs: complete v2.3 project research (STACK, FEATURES, ARCHITECTURE, PITFALLS)
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>
2026-04-09 10:58:58 +02:00

25 KiB
Raw Permalink Blame History

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, and Path.GetExtension are 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 file
  • TenantProfile.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 context
  • accountEnabled: allow filtering out disabled accounts
  • jobTitle / 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.52.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) — StringBuilder is 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.Graph 5.74.0 already includes PageIterator

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 new appId
  • graphClient.ServicePrincipals.PostAsync(new ServicePrincipal { AppId = newAppId }) — instantiates the enterprise app in the target tenant so it can be consented
  • graphClient.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:

v2.3 sources: