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>
21 KiB
Architecture Patterns
Project: SharePoint Toolbox v2.3 — Tenant Management & Report Enhancements Researched: 2026-04-09 Scope: Integration of four new features into the existing MVVM/DI architecture
Existing Architecture (Baseline)
The app uses a clean layered architecture. Understanding the layers is prerequisite to placing new features correctly.
Core/
Models/ — Pure data records and enums (no dependencies)
Helpers/ — Static utility methods
Messages/ — WeakReferenceMessenger message types
Infrastructure/
Auth/ — MsalClientFactory, GraphClientFactory, SessionManager wiring
Persistence/ — JSON-backed repositories (ProfileRepository, BrandingRepository, etc.)
Services/
*.cs — Interface + implementation pairs (feature business logic)
Export/ — HTML and CSV export services per feature area
ViewModels/
FeatureViewModelBase — Abstract base: RunCommand, CancelCommand, progress, WeakReferenceMessenger
Tabs/ — One ViewModel per tab
ProfileManagementViewModel — Tenant profile CRUD + logo management
Views/
Tabs/ — XAML views, pure DataBinding
Dialogs/ — Modal dialogs (ProfileManagementDialog, SitePickerDialog, etc.)
Key Architectural Invariants (must not be broken)
- SessionManager is the sole holder of ClientContext. All services receive it via constructor injection; none store it.
- GraphClientFactory.CreateClientAsync(clientId) produces a GraphServiceClient scoped to a specific tenant's PCA from MsalClientFactory.
- FeatureViewModelBase provides RunCommand/CancelCommand/progress wiring. All tab VMs extend it.
- WeakReferenceMessenger carries cross-cutting signals:
TenantSwitchedMessage,GlobalSitesChangedMessage. VMs react inOnTenantSwitched/OnGlobalSitesChanged. - BulkOperationRunner.RunAsync is the shared continue-on-error runner for all multi-item operations.
- HTML export services are independent per-feature classes under
Services/Export/; they receiveReportBranding?and callBrandingHtmlHelper.BuildBrandingHeader(). - DI registration is in
App.xaml.cs → RegisterServices. New services register there.
Feature 1: App Registration via Graph API
What It Does
During profile create/edit, attempt to register a new Azure AD app on the target tenant (auto path), or instruct the user through manual steps (guided fallback path).
Graph API Constraint (HIGH confidence)
Creating an application registration via POST /applications requires the caller to hold Application.ReadWrite.All. This is an admin-consent-required delegated permission. The existing GraphClientFactory uses .default scope, which only acquires permissions already pre-consented on the PCA's app registration. This means:
- The Toolbox's own client app registration (the one the MSP registered to run this tool) must have
Application.ReadWrite.Alldelegated and admin-consented before the auto path can work. - If that permission is absent, the Graph call returns 403. The auto path must catch
ODataErrorwith status 403 and fall through to guided fallback automatically. - The guided fallback shows the MSP admin step-by-step instructions for creating the app registration manually in the Azure portal and entering the resulting ClientId.
New Service: IAppRegistrationService / AppRegistrationService
Location: Services/AppRegistrationService.cs + Services/IAppRegistrationService.cs
Responsibilities:
RegisterAppAsync(GraphServiceClient, string tenantName, CancellationToken)— Creates the app registration and optional service principal on the target tenant. ReturnsAppRegistrationResult(success + new ClientId, or failure reason).RemoveAppAsync(GraphServiceClient, string clientId, CancellationToken)— Deletes the app object by clientId. Also cleans up service principal.
Required Graph calls (inside AppRegistrationService):
POST /applications— create the app with requiredrequiredResourceAccess(SharePoint delegated scopes)POST /servicePrincipals— create service principal for the new app so it can receive admin consentDELETE /applications/{id}for removalDELETE /servicePrincipals/{id}for service principal cleanup
New Model: AppRegistrationResult
// Core/Models/AppRegistrationResult.cs
public record AppRegistrationResult(
bool Success,
string? ClientId, // set when Success=true
string? ApplicationId, // object ID, needed for deletion
string? FailureReason // set when Success=false
);
Integration Point: ProfileManagementViewModel
This is the only ViewModel that changes. ProfileManagementViewModel already receives GraphClientFactory. Add:
IAppRegistrationServiceinjected via constructorRegisterAppCommand(IAsyncRelayCommand) — triggers auto-registration, falls back to guided mode on 403RemoveAppCommand(IAsyncRelayCommand) — available whenSelectedProfile != null && SelectedProfile.ClientId != nullIsRegisteringobservable bool for busy stateAppRegistrationStatusobservable string for feedback
Data flow:
ProfileManagementViewModel.RegisterAppCommand
→ GraphClientFactory.CreateClientAsync(currentMspClientId) // uses MSP's own clientId
→ AppRegistrationService.RegisterAppAsync(graphClient, tenantName)
→ POST /applications, POST /servicePrincipals
→ returns AppRegistrationResult
→ on success: populate NewClientId, surface "Copy ClientId" affordance
→ on 403: set guided fallback mode (show instructions panel)
→ on other error: set ValidationMessage
No new ViewModel is needed. The guided fallback is a conditional UI panel in ProfileManagementDialog.xaml controlled by a new IsGuidedFallbackVisible bool property on ProfileManagementViewModel.
DI Registration (App.xaml.cs)
services.AddTransient<IAppRegistrationService, AppRegistrationService>();
ProfileManagementViewModel registration remains AddTransient; the new interface is added to its constructor.
Feature 2: Auto-Take Ownership on Access Denied
What It Does
A global toggle in Settings. When enabled, if any SharePoint operation returns an access-denied error, the app automatically adds the authenticated account as a site collection administrator using the tenant admin API, then retries the operation.
Tenant Admin API Mechanism (HIGH confidence from PnP Framework source)
PnP Framework's Tenant class (in Microsoft.Online.SharePoint.TenantAdministration) exposes site management. The pattern already used in SiteListService (which clones to the -admin URL) is exactly the right entry point.
To add self as admin:
var tenant = new Tenant(adminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isAdmin: true);
adminCtx.ExecuteQueryAsync();
This does NOT require having access to the site — only SharePoint Admin role on the tenant, which the interactive login flow already acquires.
New Setting Property: AppSettings.AutoTakeOwnership
// Core/Models/AppSettings.cs — ADD property
public bool AutoTakeOwnership { get; set; } = false;
This persists in settings.json automatically via SettingsRepository.
New Service: ISiteOwnershipService / SiteOwnershipService
Location: Services/SiteOwnershipService.cs + Services/ISiteOwnershipService.cs
Responsibility: One method:
Task AddCurrentUserAsSiteAdminAsync(
TenantProfile profile,
string siteUrl,
CancellationToken ct);
Uses SessionManager to get the authenticated context, clones to the admin URL (same pattern as SiteListService.DeriveAdminUrl), constructs Tenant, and calls SetSiteAdmin.
Integration Point: ExecuteQueryRetryHelper or Caller Wrap
Rather than modifying ExecuteQueryRetryHelper (which is stateless and generic), the retry-with-ownership logic belongs in a per-operation wrapper:
- Calls the operation
- Catches
ServerExceptionwith "Access Denied" message - If
AppSettings.AutoTakeOwnership == true, callsSiteOwnershipService.AddCurrentUserAsSiteAdminAsync - Retries exactly once
- If retry also fails, propagates the error with a message indicating ownership was attempted
Recommended placement: A new static helper SiteAccessRetryHelper in Core/Helpers/, wrapping CSOM executeQuery invocations in PermissionsService, UserAccessAuditService, and SiteListService. Each of these services already has an IProgress<OperationProgress> parameter and CancellationToken — the helper signature matches naturally.
SettingsViewModel Changes
- Add
AutoTakeOwnershipobservable bool property - Wire to new
SettingsService.SetAutoTakeOwnershipAsync(bool)method - Bind to a checkbox in
SettingsView.xaml
DI Registration
services.AddTransient<ISiteOwnershipService, SiteOwnershipService>();
Feature 3: Expand Groups in HTML Reports
What It Does
In the permissions HTML report, SharePoint group entries (where PrincipalType == "SharePointGroup") currently show the group name as a single user pill. When expanded (click on the group), the report shows the individual group members.
Data Model Change
PermissionEntry is a record. Group member data must be captured at scan time because the HTML report is self-contained offline — no live API calls from the browser are possible.
Approach: Resolve at scan time. During PermissionsService.ExtractPermissionsAsync, when principalType == "SharePointGroup", load group members via CSOM and store them in a new optional field on PermissionEntry.
Model change — additive, backward-compatible:
public record PermissionEntry(
// ... all existing parameters unchanged ...
string? GroupMembers = null // semicolon-joined login names; null when not a group or not expanded
);
Using a default parameter keeps all existing constructors and test data valid without changes.
New Scan Option
// Core/Models/ScanOptions.cs — ADD parameter with default
public record ScanOptions(
bool IncludeInherited,
bool ScanFolders,
int FolderDepth,
bool IncludeSubsites,
bool ExpandGroupMembers = false // NEW — defaults off
);
Service Changes: PermissionsService
In ExtractPermissionsAsync, when principalType == "SharePointGroup" and options.ExpandGroupMembers == true:
ctx.Load(ra.Member, m => m.Users.Include(u => u.LoginName, u => u.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var groupMembers = string.Join(";", ra.Member.Users.Select(u => u.LoginName));
This adds one CSOM round-trip per SharePoint group entry. Performance note: default is false.
HTML Export Changes: HtmlExportService
When rendering user pills for an entry with GroupMembers != null, render the group name as an HTML5 <details>/<summary> expandable block. The <details>/<summary> element requires zero JavaScript, is self-contained, and is universally supported in all modern browsers (Chrome, Edge, Firefox, Safari) since 2016.
<details class="group-expand">
<summary class="user-pill group-pill">Members Group Name</summary>
<div class="group-members">
<span class="user-pill">alice@contoso.com</span>
<span class="user-pill">bob@contoso.com</span>
</div>
</details>
UserAccessHtmlExportService gets the same treatment in the "Granted Through" column where group access is reported.
ViewModel Changes: PermissionsViewModel
Add ExpandGroupMembers observable bool. Include in ScanOptions construction in RunOperationAsync. Add checkbox to PermissionsView.xaml.
Feature 4: Report Entry Consolidation Toggle
What It Does
When a user appears in multiple SharePoint groups that all have access to the same object, they generate multiple PermissionEntry rows. The consolidation toggle merges rows for the same (Object, User) combination, joining permission levels and grant sources.
Where Consolidation Lives
This is a pure post-processing transformation on the already-collected IReadOnlyList<PermissionEntry>. It requires no new service, no CSOM calls, no Graph calls.
Location: New static helper class in Core/Helpers/:
// Core/Helpers/PermissionConsolidator.cs
public static class PermissionConsolidator
{
public static IReadOnlyList<PermissionEntry> Consolidate(
IReadOnlyList<PermissionEntry> entries);
}
Consolidation key: (ObjectType, Title, Url, UserLogin) — one row per (object, user) pair across all login tokens in a semicolon-delimited UserLogins field.
Merge logic:
PermissionLevels: union of distinct values (semicolon-joined)GrantedThrough: all distinct grant sources joined (e.g., "Direct Permissions; SharePoint Group: X")HasUniquePermissions: true if any source entry has it trueUsers,UserLogins: from the first occurrence (same person)PrincipalType: from the first occurrence
PermissionEntry is a record — PermissionConsolidator.Consolidate() produces new instances, never mutates. Consistent with the existing pattern in PermissionsViewModel where Results is replaced wholesale.
For SimplifiedPermissionEntry: Consolidation applies to PermissionEntry first; SimplifiedPermissionEntry.WrapAll() then operates on the consolidated list. No changes to SimplifiedPermissionEntry needed.
ViewModel Changes: PermissionsViewModel
Add ConsolidateEntries observable bool property. In RunOperationAsync, after collecting allEntries:
if (ConsolidateEntries)
allEntries = PermissionConsolidator.Consolidate(allEntries).ToList();
The export commands (ExportCsvCommand, ExportHtmlCommand) already consume Results, so consolidated data flows into all export formats automatically. No export service changes required for this feature.
Component Dependency Map
NEW COMPONENT DEPENDS ON (existing unless marked new)
──────────────────────────────────────────────────────────────────────────
AppRegistrationResult (model) — none
AppSettings.AutoTakeOwnership AppSettings (existing model)
ScanOptions.ExpandGroupMembers ScanOptions (existing model)
PermissionEntry.GroupMembers PermissionEntry (existing record)
PermissionConsolidator PermissionEntry (existing)
IAppRegistrationService —
AppRegistrationService GraphServiceClient (existing via GraphClientFactory)
Microsoft.Graph SDK (existing)
ISiteOwnershipService —
SiteOwnershipService SessionManager (existing)
TenantProfile (existing)
Tenant CSOM class (existing via PnP Framework)
SiteListService.DeriveAdminUrl pattern (existing)
SettingsService (modified) AppSettings (existing + new field)
PermissionsService (modified) ScanOptions.ExpandGroupMembers (new field)
ExecuteQueryRetryHelper (existing)
HtmlExportService (modified) PermissionEntry.GroupMembers (new field)
BrandingHtmlHelper (existing)
ProfileManagementViewModel (mod) IAppRegistrationService (new)
PermissionsViewModel (modified) ExpandGroupMembers, ConsolidateEntries, PermissionConsolidator
SettingsViewModel (modified) AutoTakeOwnership, SettingsService new method
Suggested Build Order
Dependencies flow upward; each step can be tested before the next begins.
Step 1: Model additions
No external dependencies. All existing tests continue to pass.
AppRegistrationResultrecord (new file)AppSettings.AutoTakeOwnershipbool property (default false)ScanOptions.ExpandGroupMembersbool parameter (default false)PermissionEntry.GroupMembersoptional string parameter (default null)
Step 2: Pure-logic helper
Fully unit-testable with no services.
PermissionConsolidatorinCore/Helpers/
Step 3: New services
Depend only on existing infrastructure (SessionManager, GraphClientFactory).
ISiteOwnershipService+SiteOwnershipServiceIAppRegistrationService+AppRegistrationService
Step 4: SettingsService extension
Thin method addition, no structural change.
SetAutoTakeOwnershipAsync(bool)on existingSettingsService
Step 5: PermissionsService modification
- Group member CSOM load in
ExtractPermissionsAsync(guarded byExpandGroupMembers) - Access-denied retry using
SiteOwnershipService(guarded byAutoTakeOwnership)
Step 6: Export service modifications
HtmlExportService.BuildHtml:<details>/<summary>rendering forGroupMembersUserAccessHtmlExportService.BuildHtml: same for group access entries
Step 7: ViewModel modifications
SettingsViewModel:AutoTakeOwnershipproperty wired toSettingsServicePermissionsViewModel:ExpandGroupMembers,ConsolidateEntries, updatedScanOptionsProfileManagementViewModel:IAppRegistrationServiceinjection,RegisterAppCommand,RemoveAppCommand, guided fallback state
Step 8: View/XAML additions
SettingsView.xaml: AutoTakeOwnership checkboxPermissionsView.xaml: ExpandGroupMembers checkbox, ConsolidateEntries checkboxProfileManagementDialog.xaml: Register App button, Remove App button, guided fallback panel
Step 9: DI wiring (App.xaml.cs)
- Register
IAppRegistrationService,ISiteOwnershipService ProfileManagementViewModelconstructor change is picked up automatically (AddTransient)
New vs. Modified Summary
| Component | Status | Layer |
|---|---|---|
AppRegistrationResult |
NEW | Core/Models |
AppSettings.AutoTakeOwnership |
MODIFIED | Core/Models |
ScanOptions.ExpandGroupMembers |
MODIFIED | Core/Models |
PermissionEntry.GroupMembers |
MODIFIED | Core/Models |
PermissionConsolidator |
NEW | Core/Helpers |
IAppRegistrationService |
NEW | Services |
AppRegistrationService |
NEW | Services |
ISiteOwnershipService |
NEW | Services |
SiteOwnershipService |
NEW | Services |
SettingsService.SetAutoTakeOwnershipAsync |
MODIFIED | Services |
PermissionsService.ExtractPermissionsAsync |
MODIFIED | Services |
HtmlExportService.BuildHtml |
MODIFIED | Services/Export |
UserAccessHtmlExportService.BuildHtml |
MODIFIED | Services/Export |
ProfileManagementViewModel |
MODIFIED | ViewModels |
PermissionsViewModel |
MODIFIED | ViewModels/Tabs |
SettingsViewModel |
MODIFIED | ViewModels/Tabs |
ProfileManagementDialog.xaml |
MODIFIED | Views/Dialogs |
PermissionsView.xaml |
MODIFIED | Views/Tabs |
SettingsView.xaml |
MODIFIED | Views/Tabs |
App.xaml.cs RegisterServices |
MODIFIED | Root |
No new tabs. No new XAML files. No new dialog windows required. All four features extend existing surfaces.
Critical Integration Notes
App Registration: Permission Prerequisite
The auto-registration path requires Application.ReadWrite.All to be granted and admin-consented on the MSP's own client app registration. The tool cannot bootstrap this permission itself. The guided fallback path is the safe default — auto path is an enhancement for pre-prepared deployments. Catch ODataError with ResponseStatusCode == 403 to trigger the fallback automatically.
Auto-Ownership: Retry Once, Not Infinitely
Retry exactly once per site. If the second attempt fails (account lacks tenant admin rights), propagate the original error with a clear message indicating that ownership take-over was attempted. Log both attempts via ILogger.
Group Expansion: Scan Performance Impact
Loading group members adds one CSOM round-trip per unique SharePoint group encountered. The ExpandGroupMembers toggle must default to false and be labeled clearly in the UI (e.g., "Expand group members in report (slower scan)"). On tenants with many groups across many sites, this could multiply scan time significantly.
Consolidation: Records Are Immutable
PermissionEntry is a record. PermissionConsolidator.Consolidate() produces new record instances — no mutation. Consistent with how Results is already replaced wholesale in PermissionsViewModel.
HTML <details>/<summary> Compatibility
Self-contained HTML reports target any modern browser. <details>/<summary> is fully supported without JavaScript since 2016 across all major browsers. This is the correct choice over adding onclick JS toggle logic.
No Breaking Changes to Existing Tests
All model changes use optional parameters with defaults. Existing test data and constructors remain valid. PermissionConsolidator and SiteOwnershipService are new testable units that can use the existing InternalsVisibleTo pattern for test access.
Sources
- Microsoft Graph permissions reference: https://learn.microsoft.com/en-us/graph/permissions-reference
- Graph API grant permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
- PnP Core SDK site security (SetSiteCollectionAdmins): https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html
- PnP Framework TenantExtensions source: https://github.com/pnp/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/Extensions/TenantExtensions.cs