# 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` ```csharp // 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) ```csharp services.AddTransient(); ``` `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: ```csharp 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` ```csharp // 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: ```csharp 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` 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 ```csharp services.AddTransient(); ``` --- ## 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:** ```csharp 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 ```csharp // 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`: ```csharp 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 `
/` expandable block. The `
/` element requires zero JavaScript, is self-contained, and is universally supported in all modern browsers (Chrome, Edge, Firefox, Safari) since 2016. ```html
Members Group Name
alice@contoso.com bob@contoso.com
``` `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`. It requires no new service, no CSOM calls, no Graph calls. **Location:** New static helper class in `Core/Helpers/`: ```csharp // Core/Helpers/PermissionConsolidator.cs public static class PermissionConsolidator { public static IReadOnlyList Consolidate( IReadOnlyList 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 `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`: ```csharp 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`: `
/` 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 `
/` Compatibility Self-contained HTML reports target any modern browser. `
/` 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