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

439 lines
21 KiB
Markdown

# 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<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:
```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<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
```csharp
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:**
```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 `<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.
```html
<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/`:
```csharp
// 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 `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`: `<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
- 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