Files
Sharepoint-Toolbox/.planning/research/ARCHITECTURE.md
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

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)

  1. SessionManager is the sole holder of ClientContext. All services receive it via constructor injection; none store it.
  2. GraphClientFactory.CreateClientAsync(clientId) produces a GraphServiceClient scoped to a specific tenant's PCA from MsalClientFactory.
  3. FeatureViewModelBase provides RunCommand/CancelCommand/progress wiring. All tab VMs extend it.
  4. WeakReferenceMessenger carries cross-cutting signals: TenantSwitchedMessage, GlobalSitesChangedMessage. VMs react in OnTenantSwitched / OnGlobalSitesChanged.
  5. BulkOperationRunner.RunAsync is the shared continue-on-error runner for all multi-item operations.
  6. HTML export services are independent per-feature classes under Services/Export/; they receive ReportBranding? and call BrandingHtmlHelper.BuildBrandingHeader().
  7. 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.All delegated and admin-consented before the auto path can work.
  • If that permission is absent, the Graph call returns 403. The auto path must catch ODataError with 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. Returns AppRegistrationResult (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):

  1. POST /applications — create the app with required requiredResourceAccess (SharePoint delegated scopes)
  2. POST /servicePrincipals — create service principal for the new app so it can receive admin consent
  3. DELETE /applications/{id} for removal
  4. DELETE /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:

  • IAppRegistrationService injected via constructor
  • RegisterAppCommand (IAsyncRelayCommand) — triggers auto-registration, falls back to guided mode on 403
  • RemoveAppCommand (IAsyncRelayCommand) — available when SelectedProfile != null && SelectedProfile.ClientId != null
  • IsRegistering observable bool for busy state
  • AppRegistrationStatus observable 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:

  1. Calls the operation
  2. Catches ServerException with "Access Denied" message
  3. If AppSettings.AutoTakeOwnership == true, calls SiteOwnershipService.AddCurrentUserAsSiteAdminAsync
  4. Retries exactly once
  5. 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 AutoTakeOwnership observable 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 true
  • Users, UserLogins: from the first occurrence (same person)
  • PrincipalType: from the first occurrence

PermissionEntry is a recordPermissionConsolidator.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.

  • AppRegistrationResult record (new file)
  • AppSettings.AutoTakeOwnership bool property (default false)
  • ScanOptions.ExpandGroupMembers bool parameter (default false)
  • PermissionEntry.GroupMembers optional string parameter (default null)

Step 2: Pure-logic helper

Fully unit-testable with no services.

  • PermissionConsolidator in Core/Helpers/

Step 3: New services

Depend only on existing infrastructure (SessionManager, GraphClientFactory).

  • ISiteOwnershipService + SiteOwnershipService
  • IAppRegistrationService + AppRegistrationService

Step 4: SettingsService extension

Thin method addition, no structural change.

  • SetAutoTakeOwnershipAsync(bool) on existing SettingsService

Step 5: PermissionsService modification

  • Group member CSOM load in ExtractPermissionsAsync (guarded by ExpandGroupMembers)
  • Access-denied retry using SiteOwnershipService (guarded by AutoTakeOwnership)

Step 6: Export service modifications

  • HtmlExportService.BuildHtml: <details>/<summary> rendering for GroupMembers
  • UserAccessHtmlExportService.BuildHtml: same for group access entries

Step 7: ViewModel modifications

  • SettingsViewModel: AutoTakeOwnership property wired to SettingsService
  • PermissionsViewModel: ExpandGroupMembers, ConsolidateEntries, updated ScanOptions
  • ProfileManagementViewModel: IAppRegistrationService injection, RegisterAppCommand, RemoveAppCommand, guided fallback state

Step 8: View/XAML additions

  • SettingsView.xaml: AutoTakeOwnership checkbox
  • PermissionsView.xaml: ExpandGroupMembers checkbox, ConsolidateEntries checkbox
  • ProfileManagementDialog.xaml: Register App button, Remove App button, guided fallback panel

Step 9: DI wiring (App.xaml.cs)

  • Register IAppRegistrationService, ISiteOwnershipService
  • ProfileManagementViewModel constructor 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