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>
This commit is contained in:
Dev
2026-04-09 10:58:58 +02:00
parent 9318bb494d
commit 853f47c4a6
4 changed files with 1314 additions and 480 deletions

View File

@@ -1,443 +1,438 @@
# Architecture Patterns
**Domain:** C#/WPF MVVM desktop app — SharePoint Online MSP admin tool
**Feature scope:** Report branding (MSP/client logos in HTML) + User directory browse mode
**Researched:** 2026-04-08
**Confidence:** HIGH — based on direct codebase inspection, not assumptions
**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/ — TenantProfile, AppSettings, domain records (all POCOs/records)
Messages/ — WeakReferenceMessenger value message types
Helpers/ — Static utility classes
Models/ Pure data records and enums (no dependencies)
Helpers/ — Static utility methods
Messages/ — WeakReferenceMessenger message types
Infrastructure/
Auth/ — MsalClientFactory, GraphClientFactory (MSAL PCA per-tenant + Graph SDK bridge)
Persistence/ — ProfileRepository, SettingsRepository, TemplateRepository (JSON, atomic write-then-replace)
Logging/ — LogPanelSink (Serilog sink to in-app RichTextBox)
Auth/ — MsalClientFactory, GraphClientFactory, SessionManager wiring
Persistence/ JSON-backed repositories (ProfileRepository, BrandingRepository, etc.)
Services/
Export/Concrete HTML/CSV export services per domain (no interface, consumed directly)
*.cs Domain services with IXxx interfaces
*.cs Interface + implementation pairs (feature business logic)
Export/HTML and CSV export services per feature area
ViewModels/
FeatureViewModelBase.cs — Abstract base: RunCommand, CancelCommand, ProgressValue, StatusMessage,
GlobalSites, WeakReferenceMessenger registration
MainWindowViewModel.cs — Toolbar: tenant picker, Connect, global site picker, broadcasts TenantSwitchedMessage
Tabs/ — One ViewModel per tab, all extend FeatureViewModelBase
ProfileManagementViewModel.cs — Profile CRUD dialog VM
FeatureViewModelBase — Abstract base: RunCommand, CancelCommand, progress, WeakReferenceMessenger
Tabs/ — One ViewModel per tab
ProfileManagementViewModel — Tenant profile CRUD + logo management
Views/
Dialogs/ — ProfileManagementDialog, SitePickerDialog, ConfirmBulkOperationDialog, FolderBrowserDialog
Tabs/ — One UserControl per tab (XAML + code-behind)
App.xaml.cs — Generic Host IServiceCollection DI registration for all layers
Tabs/ — XAML views, pure DataBinding
Dialogs/ — Modal dialogs (ProfileManagementDialog, SitePickerDialog, etc.)
```
### Key Patterns Already Established
### Key Architectural Invariants (must not be broken)
| Pattern | How It Works |
|---------|-------------|
| Tenant switching | `MainWindowViewModel.OnSelectedProfileChanged` broadcasts `TenantSwitchedMessage` via `WeakReferenceMessenger`; each tab VM overrides `OnTenantSwitched(profile)` |
| Global site propagation | `GlobalSitesChangedMessage` received in `FeatureViewModelBase.OnGlobalSitesReceived` |
| HTML export | Concrete service class (e.g. `UserAccessHtmlExportService`), `BuildHtml(entries)` returns a string, `WriteAsync(entries, path, ct)` writes it. No interface. Pure data-in, HTML-out. |
| JSON persistence | Repository pattern: constructor takes `string filePath`, atomic write via `.tmp` + round-trip JSON validation before `File.Move`, `SemaphoreSlim` write lock. |
| DI registration | All in `App.xaml.cs RegisterServices()`. Export services and ViewModels are `AddTransient`; shared infrastructure is `AddSingleton`. |
| Dialog factory | View code-behind sets `ViewModel.OpenXxxDialog = () => new XxxDialog(...)` — keeps dialogs out of ViewModel layer |
| People-picker search | `IGraphUserSearchService.SearchUsersAsync(clientId, query, maxResults, ct)` calls Graph `/users?$filter=startsWith(...)` with `ConsistencyLevel: eventual` |
| Test constructor | `UserAccessAuditViewModel` has a `internal` 3-param constructor without export services — test pattern to replicate for new injections |
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: Report Branding (MSP/Client Logos in HTML Reports)
## Feature 1: App Registration via Graph API
### What It Needs
### 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).
- **MSP logo** — one global image, shown in every HTML report from every tenant
- **Client logo** — one image per tenant, shown in reports for that tenant only
- **Storage** — base64-encoded strings in JSON (no separate image files — preserves atomic save semantics and single-data-folder design)
- **Embedding** — `data:image/...;base64,...` `<img>` tag injected into the HTML header (maintains self-contained HTML invariant — zero external file references)
- **User action** — file picker → read bytes → detect MIME type → convert to base64 → store in JSON → preview in UI
### 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:
### New Components (create from scratch)
- **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/BrandingSettings.cs`**
```csharp
public class BrandingSettings
{
public string? MspLogoBase64 { get; set; }
public string? MspLogoMimeType { get; set; } // "image/png", "image/jpeg", etc.
}
// 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
);
```
Belongs in Core/Models alongside AppSettings. Kept separate — branding may grow independently of general app settings.
**`Core/Models/ReportBranding.cs`**
### 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
public record ReportBranding(
string? MspLogoBase64,
string? MspLogoMimeType,
string? ClientLogoBase64,
string? ClientLogoMimeType);
services.AddTransient<IAppRegistrationService, AppRegistrationService>();
```
Lightweight data transfer record assembled at export time from BrandingSettings + current TenantProfile. Not persisted directly — constructed on demand.
**`Infrastructure/Persistence/BrandingRepository.cs`**
Same pattern as `SettingsRepository`: constructor takes `string filePath`, atomic write-then-replace, `SemaphoreSlim` write lock. File: `%AppData%\SharepointToolbox\branding.json`.
`ProfileManagementViewModel` registration remains `AddTransient`; the new interface is added to its constructor.
**`Services/BrandingService.cs`**
---
## 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
public class BrandingService
{
public Task<BrandingSettings> GetBrandingAsync();
public Task SetMspLogoAsync(string filePath); // reads file, detects MIME, converts to base64, saves
public Task ClearMspLogoAsync();
}
var tenant = new Tenant(adminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isAdmin: true);
adminCtx.ExecuteQueryAsync();
```
Thin orchestration, same pattern as `SettingsService`. MSP logo only — client logo is managed via `ProfileService` (it belongs to `TenantProfile`).
This does NOT require having access to the site — only SharePoint Admin role on the tenant, which the interactive login flow already acquires.
### Modified Components
### New Setting Property: `AppSettings.AutoTakeOwnership`
**`Core/Models/TenantProfile.cs`** — Add two nullable string properties:
```csharp
public string? ClientLogoBase64 { get; set; }
public string? ClientLogoMimeType { get; set; }
// Core/Models/AppSettings.cs — ADD property
public bool AutoTakeOwnership { get; set; } = false;
```
This is backward-compatible. `ProfileRepository` uses `JsonSerializer` with `PropertyNameCaseInsensitive: true` — missing JSON fields deserialize to null without error. Existing `profiles.json` files continue to load correctly.
**All HTML export services** — Add `ReportBranding? branding = null` optional parameter to every `BuildHtml()` overload. When non-null and at least one logo is present, inject a branding header div between `<body>` open and `<h1>`:
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
<div class="brand-header" style="display:flex;align-items:center;gap:16px;padding:16px 24px 0;">
<!-- only rendered if logo present -->
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="MSP" />
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="Client" />
<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>
```
When `branding` is null (existing callers) the block is omitted entirely. No behavior change for callers that do not pass branding.
`UserAccessHtmlExportService` gets the same treatment in the "Granted Through" column where group access is reported.
Affected services (all in `Services/Export/`):
- `HtmlExportService` (two `BuildHtml` overloads — `PermissionEntry` and `SimplifiedPermissionEntry`)
- `UserAccessHtmlExportService`
- `StorageHtmlExportService` (two `BuildHtml` overloads — with and without `FileTypeMetric`)
- `SearchHtmlExportService`
- `DuplicatesHtmlExportService`
### ViewModel Changes: `PermissionsViewModel`
**ViewModels that call HTML export** — All `ExportHtmlAsync` methods need to resolve branding before calling the export service. The ViewModel calls `BrandingService.GetBrandingAsync()` and reads `_currentProfile.ClientLogoBase64` to assemble a `ReportBranding`, then passes it to `BuildHtml`.
Affected ViewModels: `PermissionsViewModel`, `UserAccessAuditViewModel`, `StorageViewModel`, `SearchViewModel`, `DuplicatesViewModel`. Each gets `BrandingService` injected via constructor.
**`ViewModels/Tabs/SettingsViewModel.cs`** — Add MSP logo management:
```csharp
[ObservableProperty] private string? _mspLogoPreviewBase64;
public RelayCommand BrowseMspLogoCommand { get; }
public RelayCommand ClearMspLogoCommand { get; }
```
On browse: open `OpenFileDialog` (filter: PNG, JPG, GIF) → call `BrandingService.SetMspLogoAsync(path)` → reload and refresh `MspLogoPreviewBase64`.
**`Views/Tabs/SettingsView.xaml`** — Add a "Report Branding — MSP Logo" section:
- `<Image>` bound to `MspLogoPreviewBase64` via a base64-to-BitmapSource converter
- "Browse Logo" button → `BrowseMspLogoCommand`
- "Clear" button → `ClearMspLogoCommand`
- Note label: "Applies to all reports"
**Client logo placement:** Client logo belongs to a `TenantProfile`, not to global settings. The natural place to manage it is `ProfileManagementDialog` (already handles profile CRUD). Add logo fields there rather than in SettingsView.
**`ViewModels/ProfileManagementViewModel.cs`** — Add client logo management per profile:
```csharp
[ObservableProperty] private string? _clientLogoPreviewBase64;
public RelayCommand BrowseClientLogoCommand { get; }
public RelayCommand ClearClientLogoCommand { get; }
```
On browse: read image bytes → base64 → set on the being-edited `TenantProfile` object before saving. Uses `ProfileService.AddProfileAsync` / rename pipeline that already exists.
**`Views/Dialogs/ProfileManagementDialog.xaml`** — Add client logo fields to the add/edit profile form (same pattern as SettingsView branding section).
### Data Flow: Report Branding
```
User picks MSP logo (SettingsView "Browse Logo" button)
→ SettingsViewModel.BrowseMspLogoCommand
→ OpenFileDialog in View code-behind or VM (follow existing BrowseFolder pattern)
→ BrandingService.SetMspLogoAsync(path)
→ File.ReadAllBytesAsync → Convert.ToBase64String
→ detect MIME from extension (.png → image/png, .jpg/.jpeg → image/jpeg, .gif → image/gif)
→ BrandingRepository.SaveAsync(BrandingSettings)
→ ViewModel refreshes MspLogoPreviewBase64
User runs export (e.g. ExportHtmlCommand in UserAccessAuditViewModel)
→ BrandingService.GetBrandingAsync() → BrandingSettings
→ reads _currentProfile.ClientLogoBase64, _currentProfile.ClientLogoMimeType
→ new ReportBranding(mspBase64, mspMime, clientBase64, clientMime)
→ UserAccessHtmlExportService.BuildHtml(entries, branding)
→ injects <img> data URIs in header when base64 is non-null
→ writes HTML file
```
Add `ExpandGroupMembers` observable bool. Include in `ScanOptions` construction in `RunOperationAsync`. Add checkbox to `PermissionsView.xaml`.
---
## Feature 2: User Directory Browse Mode
## Feature 4: Report Entry Consolidation Toggle
### What It Needs
### 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.
The existing `UserAccessAuditView` has a people-picker: search box → Graph API `startsWith` filter → autocomplete dropdown → add to `SelectedUsers`. Directory browse mode is an alternative to the search box: show a paginated, filterable list of all tenant users, allow multi-select, bulk-add to `SelectedUsers`.
### Where Consolidation Lives
This is purely additive. The underlying audit logic (`IUserAccessAuditService`, `RunOperationAsync`, `SelectedUsers` collection, export commands) is completely unchanged.
This is a pure post-processing transformation on the already-collected `IReadOnlyList<PermissionEntry>`. It requires no new service, no CSOM calls, no Graph calls.
### New Components (create from scratch)
**Location:** New static helper class in `Core/Helpers/`:
**`Core/Models/PagedUserResult.cs`**
```csharp
public record PagedUserResult(
IReadOnlyList<GraphUserResult> Users,
string? NextPageToken); // null = last page
```
**`Services/IGraphUserDirectoryService.cs`**
```csharp
public interface IGraphUserDirectoryService
// Core/Helpers/PermissionConsolidator.cs
public static class PermissionConsolidator
{
Task<PagedUserResult> GetUsersPageAsync(
string clientId,
string? filter = null,
string? pageToken = null,
int pageSize = 100,
CancellationToken ct = default);
public static IReadOnlyList<PermissionEntry> Consolidate(
IReadOnlyList<PermissionEntry> entries);
}
```
**`Services/GraphUserDirectoryService.cs`**
Reuses `GraphClientFactory` (already injected elsewhere). Calls `graphClient.Users.GetAsync()` without the `startsWith` constraint used in search — uses `$top=100` with cursor-based paging via Graph's `@odata.nextLink`. Returns `PagedUserResult` so callers control pagination. Uses `ConsistencyLevel: eventual` + `$count=true` (same as existing search service).
**Consolidation key:** `(ObjectType, Title, Url, UserLogin)` — one row per (object, user) pair across all login tokens in a semicolon-delimited `UserLogins` field.
### Modified Components
**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
**`ViewModels/Tabs/UserAccessAuditViewModel.cs`** — Add browse mode state:
`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
[ObservableProperty] private bool _isBrowseModeActive;
[ObservableProperty] private ObservableCollection<GraphUserResult> _directoryUsers = new();
[ObservableProperty] private string _directoryFilter = string.Empty;
[ObservableProperty] private bool _isLoadingDirectory;
[ObservableProperty] private bool _hasMoreDirectoryPages;
private string? _directoryNextPageToken;
public IAsyncRelayCommand LoadDirectoryCommand { get; }
public IAsyncRelayCommand LoadMoreDirectoryCommand { get; }
public RelayCommand<IList<GraphUserResult>> AddDirectoryUsersCommand { get; }
if (ConsolidateEntries)
allEntries = PermissionConsolidator.Consolidate(allEntries).ToList();
```
`partial void OnIsBrowseModeActiveChanged(bool value)` → when `value == true`, fire `LoadDirectoryCommand` to populate page 1.
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.
`partial void OnDirectoryFilterChanged(string value)` → debounce 300ms (same pattern as `OnSearchQueryChanged`), re-fire `LoadDirectoryCommand` with new filter, clear `_directoryNextPageToken`.
---
The `IGraphUserDirectoryService` is added to the constructor. The internal test constructor (currently 3 params) gets a 4-param overload adding the directory service with a null-safe default, or a new explicit test constructor.
**`App.xaml.cs RegisterServices()`** — Add:
```csharp
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
```
The `UserAccessAuditViewModel` transient registration picks up the new injection automatically (DI resolves by type).
**`UserAccessAuditViewModel.OnTenantSwitched`** — Also clear `DirectoryUsers`, reset `_directoryNextPageToken`, `HasMoreDirectoryPages`, `IsLoadingDirectory`.
**`Views/Tabs/UserAccessAuditView.xaml`** — Add to the top of the left panel:
- Mode toggle: two `RadioButton`s or `ToggleButton`s bound to `IsBrowseModeActive`
- "Search" panel: existing `GroupBox` shown when `IsBrowseModeActive == false`
- "Browse" panel: new `GroupBox` shown when `IsBrowseModeActive == true`, containing:
- Filter `TextBox` bound to `DirectoryFilter`
- `ListView` with `SelectionMode="Extended"` bound to `DirectoryUsers`, `SelectionChanged` handler in code-behind
- "Add Selected" `Button``AddDirectoryUsersCommand`
- "Load more" `Button` shown when `HasMoreDirectoryPages == true``LoadMoreDirectoryCommand`
- Loading indicator (existing `IsSearching` pattern, but for `IsLoadingDirectory`)
- Show/hide panels via `DataTrigger` on `IsBrowseModeActive`
**`Views/Tabs/UserAccessAuditView.xaml.cs`** — Add `SelectionChanged` handler to pass `ListView.SelectedItems` (as `IList<GraphUserResult>`) to `AddDirectoryUsersCommand`. Follow the existing `SearchResultsListBox_SelectionChanged` pattern.
### Data Flow: Directory Browse Mode
## Component Dependency Map
```
User clicks "Browse" mode toggle
→ IsBrowseModeActive = true
→ OnIsBrowseModeActiveChanged fires LoadDirectoryCommand
→ GraphUserDirectoryService.GetUsersPageAsync(clientId, filter: null, pageToken: null, 100, ct)
→ Graph GET /users?$select=displayName,userPrincipalName,mail&$top=100&$orderby=displayName
→ returns PagedUserResult { Users = [...100 items], NextPageToken = "..." }
→ DirectoryUsers = new collection of returned users
→ HasMoreDirectoryPages = (NextPageToken != null)
→ _directoryNextPageToken = returned token
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)
User types in DirectoryFilter
→ debounce 300ms
→ LoadDirectoryCommand re-fires with filter
→ DirectoryUsers replaced with filtered page 1
PermissionConsolidator PermissionEntry (existing)
User selects users in ListView + clicks "Add Selected"
→ AddDirectoryUsersCommand(selectedItems)
→ for each: if not already in SelectedUsers by UPN → SelectedUsers.Add(user)
IAppRegistrationService —
AppRegistrationService GraphServiceClient (existing via GraphClientFactory)
Microsoft.Graph SDK (existing)
User clicks "Load more"
→ LoadMoreDirectoryCommand
→ GetUsersPageAsync(clientId, currentFilter, _directoryNextPageToken, 100, ct)
→ DirectoryUsers items appended (not replaced)
→ _directoryNextPageToken updated
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
```
---
## Component Boundary Summary
## Suggested Build Order
### New Components (create)
Dependencies flow upward; each step can be tested before the next begins.
| Component | Layer | Type | Purpose |
|-----------|-------|------|---------|
| `BrandingSettings` | Core/Models | class | MSP logo storage (base64 + MIME type) |
| `ReportBranding` | Core/Models | record | Data passed to `BuildHtml` overloads at export time |
| `BrandingRepository` | Infrastructure/Persistence | class | JSON load/save for `BrandingSettings` |
| `BrandingService` | Services | class | Orchestrates logo file read / MIME detect / base64 convert / save |
| `PagedUserResult` | Core/Models | record | Page of `GraphUserResult` items + next-page token |
| `IGraphUserDirectoryService` | Services | interface | Contract for paginated tenant user enumeration |
| `GraphUserDirectoryService` | Services | class | Graph API user listing with cursor pagination |
### 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)
Total new files: 7
### Step 2: Pure-logic helper
Fully unit-testable with no services.
- `PermissionConsolidator` in `Core/Helpers/`
### Modified Components (extend)
### Step 3: New services
Depend only on existing infrastructure (SessionManager, GraphClientFactory).
- `ISiteOwnershipService` + `SiteOwnershipService`
- `IAppRegistrationService` + `AppRegistrationService`
| Component | Change | Risk |
|-----------|--------|------|
| `TenantProfile` | + 2 nullable logo props | LOW — JSON backward-compatible |
| `HtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW — optional param, existing callers unaffected |
| `UserAccessHtmlExportService` | + optional `ReportBranding?` | LOW |
| `StorageHtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW |
| `SearchHtmlExportService` | + optional `ReportBranding?` | LOW |
| `DuplicatesHtmlExportService` | + optional `ReportBranding?` | LOW |
| `PermissionsViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `UserAccessAuditViewModel` | + inject `BrandingService` + `IGraphUserDirectoryService`, browse mode state/commands | MEDIUM |
| `StorageViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `SearchViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `DuplicatesViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `SettingsViewModel` | + inject `BrandingService`, MSP logo commands + preview property | LOW |
| `ProfileManagementViewModel` | + client logo browse/preview/clear | LOW |
| `SettingsView.xaml` | + branding section with logo preview + buttons | LOW |
| `ProfileManagementDialog.xaml` | + client logo fields | LOW |
| `UserAccessAuditView.xaml` | + mode toggle + browse panel in left column | MEDIUM |
| `App.xaml.cs RegisterServices()` | + 3 new registrations | LOW |
### Step 4: SettingsService extension
Thin method addition, no structural change.
- `SetAutoTakeOwnershipAsync(bool)` on existing `SettingsService`
Total modified files: 17
### 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)
---
## Build Order (Dependency-Aware)
## New vs. Modified Summary
The two features are independent of each other. Phases can run in parallel if worked by two developers; solo they should follow top-to-bottom order.
| 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 |
### Phase A — Data Models (no dependencies)
1. `Core/Models/BrandingSettings.cs` (new)
2. `Core/Models/ReportBranding.cs` (new)
3. `Core/Models/PagedUserResult.cs` (new)
4. `Core/Models/TenantProfile.cs` — add nullable logo props (modification)
All files are POCOs/records. Unit-testable in isolation. No risk.
### Phase B — Persistence + Service Layer
5. `Infrastructure/Persistence/BrandingRepository.cs` (new) — depends on BrandingSettings
6. `Services/BrandingService.cs` (new) — depends on BrandingRepository
7. `Services/IGraphUserDirectoryService.cs` (new) — depends on PagedUserResult
8. `Services/GraphUserDirectoryService.cs` (new) — depends on GraphClientFactory (already exists)
Unit tests for BrandingService (mock repository) and GraphUserDirectoryService (mock Graph client) can be written at this phase.
### Phase C — HTML Export Service Extensions
9. All 5 `Services/Export/*HtmlExportService.cs` modifications — add optional `ReportBranding?` param
These are independent of each other. Tests: verify that passing `null` branding produces identical HTML to current output (regression), and that passing a branding record injects the expected `<img>` tags.
### Phase D — ViewModel Integration (branding)
10. `SettingsViewModel.cs` — add MSP logo commands + preview
11. `ProfileManagementViewModel.cs` — add client logo commands + preview
12. `PermissionsViewModel.cs` — add BrandingService injection, use in ExportHtmlAsync
13. `StorageViewModel.cs` — same
14. `SearchViewModel.cs` — same
15. `DuplicatesViewModel.cs` — same
16. `App.xaml.cs` — register BrandingRepository, BrandingService
Steps 12-15 follow an identical pattern and can be batched together.
### Phase E — ViewModel Integration (directory browse)
17. `UserAccessAuditViewModel.cs` — add IGraphUserDirectoryService injection, browse mode state/commands
Note: UserAccessAuditViewModel also gets BrandingService at this phase (from Phase D pattern). Do both together to avoid touching the constructor twice.
### Phase F — View Layer (branding UI)
18. `SettingsView.xaml` — add MSP branding section
19. `ProfileManagementDialog.xaml` — add client logo fields
Requires a base64-to-BitmapSource `IValueConverter` (add to `Views/Converters/`). This is a common WPF pattern — implement once, reuse in both views.
### Phase G — View Layer (directory browse UI)
20. `UserAccessAuditView.xaml` — add mode toggle + browse panel
21. `UserAccessAuditView.xaml.cs` — add SelectionChanged handler for directory ListView
This is the highest-risk UI change: the left panel is being restructured. Do this last, after all ViewModel behavior is proven by unit tests.
**No new tabs. No new XAML files. No new dialog windows required.** All four features extend existing surfaces.
---
## Anti-Patterns to Avoid
## Critical Integration Notes
### Storing Logo Images as Separate Files
**Why bad:** Breaks the single-data-folder design. Reports become non-self-contained if they reference external paths. Atomic save semantics break.
**Instead:** Base64-encode into JSON. Logo thumbnails are typically 10-200KB. Base64 overhead (~33%) is negligible.
### 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.
### Adding an `IHtmlExportService` Interface Just for Branding
**Why bad:** The existing pattern is 5 concrete classes with no interfaces, consumed directly by ViewModels. Adding an interface for a parameter change creates ceremony without value.
**Instead:** Add `ReportBranding? branding = null` as optional parameter. Existing callers compile unchanged.
### 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`.
### Loading All Tenant Users at Once
**Why bad:** Enterprise tenants regularly have 20,000-100,000 users. A full load blocks the UI for 30+ seconds and allocates hundreds of MB.
**Instead:** `PagedUserResult` pattern — page 1 on mode toggle, "Load more" button, server-side filter applied to DirectoryFilter text.
### 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.
### Async in ViewModel Constructor
**Why bad:** DI constructs ViewModels synchronously on the UI thread. Async work in constructors requires fire-and-forget which loses exceptions.
**Instead:** `partial void OnIsBrowseModeActiveChanged` fires `LoadDirectoryCommand` when browse mode activates. Constructor only wires up commands and state.
### 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`.
### Client Logo in `AppSettings` or `BrandingSettings`
**Why bad:** Client logos are per-tenant. `AppSettings` and `BrandingSettings` are global. Mixing them makes per-profile deletion awkward and serialization structure unclear.
**Instead:** `ClientLogoBase64` + `ClientLogoMimeType` directly on `TenantProfile` (serialized in `profiles.json`). MSP logo goes in `branding.json` via `BrandingRepository`.
### 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.
### Changing `BuildHtml` Signatures to Required Parameters
**Why bad:** All 5 HTML export services currently have callers without branding. Making the parameter required is a breaking change forcing simultaneous updates across 5 VMs.
**Instead:** `ReportBranding? branding = null` is optional. Inject only where branding is desired. Existing call sites remain unchanged.
---
## Scalability Considerations
| Concern | Impact | Mitigation |
|---------|--------|------------|
| Logo storage size in JSON | PNG logos base64-encoded: 10-200KB per logo. `profiles.json` grows by at most that per tenant | Acceptable — config files, not bulk data |
| HTML report file size | +2-10KB per logo (base64 inline) | Negligible — reports are already 100-500KB |
| Directory browse load time | 100-user pages from Graph: ~200-500ms per page | Loading indicator, pagination. Acceptable UX. |
| Large tenants (50k+ users) | Full load would take minutes and exceed memory budgets | Pagination via `PagedUserResult` prevents this entirely |
| ViewModel constructor overhead | BrandingService adds one lazy JSON read at first export | Not at construction — no startup impact |
### 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
All findings are based on direct inspection of the codebase at `C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/`. No external research needed — this is an integration architecture document for a known codebase.
Key files examined:
- `Core/Models/TenantProfile.cs`, `AppSettings.cs`
- `Infrastructure/Persistence/ProfileRepository.cs`, `SettingsRepository.cs`
- `Infrastructure/Auth/GraphClientFactory.cs`
- `Services/SettingsService.cs`, `ProfileService.cs`
- `Services/GraphUserSearchService.cs`, `IGraphUserSearchService.cs`
- `Services/Export/HtmlExportService.cs`, `UserAccessHtmlExportService.cs`, `StorageHtmlExportService.cs`
- `ViewModels/FeatureViewModelBase.cs`, `MainWindowViewModel.cs`
- `ViewModels/Tabs/UserAccessAuditViewModel.cs`, `SettingsViewModel.cs`
- `Views/Tabs/UserAccessAuditView.xaml`, `SettingsView.xaml`
- `App.xaml.cs`
- 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

View File

@@ -1,211 +1,534 @@
# Feature Landscape
**Domain:** MSP IT admin desktop tool — SharePoint audit report branding + user directory browse
**Milestone:** v2.2 — Report Branding & User Directory
**Researched:** 2026-04-08
**Overall confidence:** HIGH (verified via official Graph API docs + direct codebase inspection)
**Domain:** MSP IT admin desktop tool — Tenant Management & Report Enhancements
**Milestone:** v2.3
**Researched:** 2026-04-09
**Overall confidence:** HIGH (verified via official Graph API docs, PnP docs, and direct codebase inspection)
---
## Scope Boundary
This file covers only the two net-new features in v2.2:
1. HTML report branding (MSP logo + client logo per tenant)
2. User directory browse mode in the user access audit tab
This file covers only the five net-new features in v2.3:
1. Automated app registration on target tenant (with guided fallback)
2. App removal from target tenant
3. Auto-take ownership of SharePoint sites on access denied (global toggle)
4. Expand groups in HTML reports (clickable to show members)
5. Report consolidation toggle (merge duplicate user entries across locations)
Everything else is already shipped. Dependencies on existing code are called out explicitly.
---
## Feature 1: HTML Report Branding
## Feature 1: Automated App Registration on Target Tenant
### What it is
During profile creation/editing, the app can register itself as an Azure AD application on the
target tenant. This eliminates the current manual step where admins must open Entra portal, create
an app registration, copy the Client ID, and paste it into the profile form.
### How it works (technical)
Graph API app registration is a two-phase operation when performed programmatically:
**Phase 1 — Create the Application object:**
`POST /applications` with `displayName` and optionally `requiredResourceAccess` (permission
declarations). Returns `appId` (client ID) and `id` (object ID). Requires delegated permission
`Application.ReadWrite.All` (least privilege for delegated scenarios), or the calling user must
hold a role of Application Developer, Cloud Application Administrator, or higher.
**Phase 2 — Create the Service Principal:**
`POST /servicePrincipals` with `appId` from Phase 1. This is a required explicit step when
registering via Graph API — the portal creates the SP automatically, the API does not.
Requires the same `Application.ReadWrite.All` delegated permission.
**Phase 3 — Grant admin consent for required permissions:**
`POST /servicePrincipals/{resourceId}/appRoleAssignedTo` for each application permission
(SharePoint, Graph scopes needed). The calling user must hold Cloud Application Administrator
or Global Administrator to grant tenant-wide consent. Requires delegated permissions
`Application.Read.All` + `AppRoleAssignment.ReadWrite.All`.
**Phase 4 — Store credentials (optional):**
`POST /applications/{id}/addPassword` to create a client secret. The `secretText` is only
returned once at creation — must be stored immediately in the profile. Alternatively, the app
can use a MSAL public client flow (interactive login), which does not require a client secret.
**Guided fallback:** If the calling user lacks Application.ReadWrite.All or admin consent cannot
be granted programmatically (e.g., tenant has restricted app consent policies), the automated
path fails. The fallback shows step-by-step instructions + a deep-link to the Entra portal app
registration wizard, with the `appId` field pre-fill-ready when the admin returns.
### Table Stakes
Features an MSP admin expects without being asked. If missing, the reports feel unfinished and
unprofessional to hand to a client.
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| MSP global logo in report header | Every white-label MSP tool shows the MSP's own brand on deliverables | Low | Single image stored in AppSettings or a dedicated branding settings section |
| Client (per-tenant) logo in report header | MSP reports are client-facing; client should see their own logo next to the MSP's | Medium | Stored in TenantProfile; 2 sources: import from file or pull from tenant |
| Logo renders in self-contained HTML (no external URL) | Reports are often emailed or archived; external URLs break offline | Low | Base64-encode and embed as `data:image/...;base64,...` inline in `<img src=` |
| Logo graceful absence (no logo configured = no broken image) | Admins will run the tool before configuring logos | Trivial | Conditional render — omit the `<img>` block entirely when no logo is set |
| Consistent placement across all HTML export types | App already ships 5+ HTML exporters; logos must appear in all of them | Medium | Extract a shared header-builder method or inject a branding context into each export service |
| Create app registration on target tenant via Graph API | MSPs manage 10-50 tenants; manual Entra portal steps per-tenant is the biggest onboarding friction | High | 4 API calls; requires `Application.ReadWrite.All` + admin consent grant scope in the calling token |
| Return and store the Client ID automatically | The resulting `appId` must be wired into the TenantProfile as the registered clientId | Low | Phase 1 response body contains `appId`; persist to TenantProfile.ClientId |
| Guided fallback (manual instructions + portal deep-link) if automated path fails | Tenant admin consent policies may block programmatic app creation | Medium | Detect 403/insufficient_scope errors; render a modal with numbered steps and a link to `https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/CreateApplicationBlade` |
| Progress/status feedback during multi-step registration | 4 API calls; each can fail independently | Low | Use existing OperationProgress pattern; surface per-step status in the UI |
### Differentiators
Features not expected by default, but add meaningful value once table stakes are covered.
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Auto-pull client logo from Microsoft Entra tenant branding | Zero-config for tenants that already have a banner logo set in Entra ID | Medium | Graph API: `GET /organization/{id}/branding/localizations/default/bannerLogo` returns raw image bytes. Least-privileged scope is `User.Read` (delegated, already in use). Returns empty body or 404 when not configured — must handle gracefully. |
| Report timestamp and tenant display name in header | Contextualizes archived reports without needing to inspect the filename | Low | TenantProfile.TenantUrl already available; display name derivable from domain |
| Pre-configure required Graph/SharePoint permissions in the app manifest | Avoids admin having to manually tick permissions in Entra portal after creation | Medium | Include `requiredResourceAccess` in POST /applications body, targeting Graph SP (appId `00000003-0000-0000-c000-000000000000`) and SharePoint SP (appId `00000003-0000-0ff1-ce00-000000000000`) |
| Verify existing registration before creating a new one | Prevents duplicate registrations on re-run or retry | Low | `GET /applications?$filter=displayName eq '{name}'` before POST; surface existing one if found |
### Anti-Features
Do not build these. They add scope without proportionate MSP value.
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Color theme / CSS customization per tenant | Complexity explodes — per-tenant CSS is a design system problem, not an admin tool feature | Stick to a single professional neutral theme; logo is sufficient branding |
| PDF export with embedded logo | PDF generation requires a third-party library (iTextSharp, QuestPDF, etc.) adding binary size to the 200 MB EXE | Document in release notes that users can print-to-PDF from browser |
| Animated or SVG logo support | MIME handling complexity; SVG in data-URIs introduces XSS risk | Support PNG/JPG/GIF only; reject SVG at import time |
| Logo URL field (hotlinked) | Reports break when URL becomes unavailable; creates external dependency for a local-first tool | Force file import with base64 embedding |
| Store client secrets in the profile JSON | Secrets at rest in a local JSON file are a liability; the app uses delegated (interactive) auth, not app-only | Use MSAL interactive delegated flow; no client secret needed at runtime |
| Certificate-based credential management | Cert lifecycle (expiry, rotation) is out of scope for an MSP admin tool | Interactive user auth handles token refresh automatically |
| Silent background retry on consent failures | The calling user may not have the right role; silent retry without user action would spin indefinitely | Detect error class, surface actionable UI immediately |
### Feature Dependencies
```
AppSettings + MspLogoBase64 (string?, nullable)
TenantProfile + ClientLogoBase64 (string?, nullable)
+ ClientLogoSource (enum: None | Imported | AutoPulled)
Shared branding helper → called by HtmlExportService, UserAccessHtmlExportService,
StorageHtmlExportService, DuplicatesHtmlExportService,
SearchHtmlExportService
Auto-pull code path → Graph API call via existing GraphClientFactory
Logo import UI → WPF OpenFileDialog -> File.ReadAllBytes -> Convert.ToBase64String
-> stored in profile JSON via existing ProfileRepository
Existing:
GraphClientFactory → provides authenticated GraphServiceClient for target tenant
TenantProfile.ClientId → stores the resulting appId after registration
ProfileManagementDialog → hosts the registration trigger button
OperationProgress → used for per-step status display
New:
IAppRegistrationService / AppRegistrationService
→ CreateApplicationAsync(tenantId, displayName) : Task<Application>
→ CreateServicePrincipalAsync(appId) : Task<ServicePrincipal>
→ GrantAdminConsentAsync(servicePrincipalId) : Task (app role assignments)
→ RemoveApplicationAsync(appId) : Task (Feature 2)
AppRegistrationFallbackDialog (new WPF dialog)
→ renders numbered steps when automated path fails
→ deep-link button to Entra portal
```
**Key existing code note:** All 5+ HTML export services currently build their `<body>` independently
with no shared header. Branding requires one of:
- (a) a `ReportBrandingContext` record passed into each exporter's `BuildHtml` method, or
- (b) a `HtmlReportHeaderBuilder` static/injectable helper all exporters call.
Option (b) is lower risk — it does not change method signatures that existing unit tests already call.
**Key existing code note:** GraphClientFactory already acquires delegated tokens with the
tenant's registered clientId. For the registration flow specifically, the app needs a token
scoped to the *management* tenant (where Entra lives), not just SharePoint/Graph read scopes.
The MSAL PCA must request `Application.ReadWrite.All` and `AppRoleAssignment.ReadWrite.All`
for the registration step — these are broader than the app's normal operation scopes and will
trigger a new consent prompt if not previously consented.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| AppSettings + TenantProfile model field additions | Low | Trivial nullable-string fields; JSON serialization already in place |
| Settings UI: MSP logo upload + preview | Low | WPF OpenFileDialog + BitmapImage from base64, standard pattern |
| ProfileManagementDialog: client logo upload per tenant | Low | Same pattern as MSP logo |
| Shared HTML header builder with logo injection | Low-Medium | One helper; replaces duplicated header HTML in 5 exporters |
| Auto-pull from Entra `bannerLogo` endpoint | Medium | Async Graph call; must handle 404, empty stream, no branding configured |
| Localization keys EN/FR for new labels | Low | ~6-10 new keys; 220+ already managed |
| POST /applications + POST /servicePrincipals | Medium | Two sequential calls; error handling at each step |
| Grant admin consent (appRoleAssignment per permission) | High | Must look up resource SP IDs, match appRole GUIDs by permission name; 4-6 role assignments needed |
| TenantProfile.ClientId persistence after registration | Low | Existing JSON serialization; add one field |
| UI: Register button in ProfileManagementDialog | Low | Button + status label; hooks into existing async command pattern |
| Guided fallback modal | Medium | Error detection logic + WPF dialog with instructional content |
| Localization EN/FR | Low | ~12-16 new keys |
| Unit tests for AppRegistrationService | High | Requires mocking Graph SDK Application/ServicePrincipal/AppRoleAssignment calls; 15-20 test cases |
---
## Feature 2: User Directory Browse Mode
## Feature 2: App Removal from Target Tenant
### What it is
Inverse of Feature 1. When a tenant profile is deleted or when the admin explicitly removes the
registration, the app deletes the Azure AD application object from the target tenant.
### How it works (technical)
`DELETE /applications/{id}` — soft-deletes the application object (moved to deleted items for
30 days). Requires `Application.ReadWrite.All` delegated. The `{id}` here is the object ID
(not the `appId`/client ID) — must be resolved first via
`GET /applications?$filter=appId eq '{clientId}'`.
Optionally: `DELETE /directory/deletedItems/{id}` for permanent deletion (requires
`Application.ReadWrite.All`; irreversible within the 30-day window — avoid).
### Table Stakes
Features an admin expects when a "browse all users" mode is offered alongside the existing search.
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Full directory listing (all member users, paginated) | Browse implies seeing everyone, not just name-search hits | Medium | Graph `GET /users` with `$top=100`, follow `@odata.nextLink` until null. Max page size is 999 but 100 pages give better progress feedback |
| Searchable/filterable within the loaded list | Once loaded, admins filter locally without re-querying | Low | In-memory filter on DisplayName, UPN, Mail — same pattern used in PermissionsView DataGrid |
| Sortable columns (Name, UPN) | Standard expectation for any directory table | Low | WPF DataGrid column sorting, already used in other tabs |
| Select user from list to run access audit | The whole point — browse replaces the people-picker for users the admin cannot spell | Low | Bind selected item; reuse the existing IUserAccessAuditService pipeline unchanged |
| Loading indicator with progress count | Large tenants (5k+ users) take several seconds to page through | Low | Existing OperationProgress pattern; show "Loaded X users..." counter |
| Toggle between Browse mode and Search (people-picker) mode | Search is faster for known users; browse is for discovery | Low | RadioButton or ToggleButton in the tab toolbar; visibility-toggle two panels |
### Differentiators
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Filter by account type (member vs guest) | MSPs care about guest proliferation; helps scope audit targets | Low | Graph returns `userType` field; add a toggle filter. Include in `$select` |
| Department / Job Title columns | Helps identify the right user in large tenants with common names | Low-Medium | Include `department`, `jobTitle` in `$select`; optional columns in DataGrid |
| Session-scoped directory cache | Avoids re-fetching full tenant list on every tab visit | Medium | Store list in ViewModel or session-scoped service; invalidate on TenantSwitchedMessage |
| Remove app registration when profile is deleted | Avoid Entra app sprawl in client tenants; clean exit | Medium | Resolve object ID by appId, then DELETE /applications/{id} |
| Confirmation prompt before removal | Deletion is irreversible within the session; accidental removal would break other automations using the same app | Low | Modal confirm dialog with clientId displayed |
| Graceful handling when app no longer exists | Re-run, manual deletion in portal, or already removed | Low | Handle 404 as success (idempotent delete) |
### Anti-Features
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Eager load on tab open | Large tenants (10k+ users) block UI and risk Graph throttling on every tab navigation | Lazy-load on explicit "Load Directory" button click; show a clear affordance |
| Delta query / incremental sync | Delta queries are for maintaining a local replica over time; wrong pattern for a one-time audit session | Single paginated GET per session; add a Refresh button |
| Multi-user bulk select for simultaneous audit | The audit pipeline is per-user by design; multi-user requires a fundamentally different results model | Out of scope; single-user selection only |
| Export the user directory to CSV | That is an identity reporting feature (AdminDroid et al.), not an access audit feature | Out of scope for this milestone |
| Show disabled accounts by default | Disabled users do not have active SharePoint access; pollutes the list for audit purposes | Default `$filter=accountEnabled eq true`; optionally expose a toggle |
| Permanent hard-delete from deletedItems | 30-day soft-delete is a safety net; no MSP needs immediate permanent removal | Soft-delete only (default DELETE /applications behavior) |
| Auto-remove on profile deletion without prompt | Silent data destruction is never acceptable | Always require explicit user confirmation |
### Feature Dependencies
```
New IGraphDirectoryService + GraphDirectoryService
→ GET /users?$select=displayName,userPrincipalName,mail,jobTitle,department,userType
&$filter=accountEnabled eq true
&$top=100
→ Follow @odata.nextLink in a loop until null
→ Uses existing GraphClientFactory (DI, unchanged)
Existing:
AppRegistrationService (from Feature 1)
+ RemoveApplicationAsync(clientId) : Task
→ GET /applications?$filter=appId eq '{clientId}' → resolve object ID
→ DELETE /applications/{objectId}
UserAccessAuditViewModel additions:
+ IsBrowseMode (bool property, toggle)
+ DirectoryUsers (ObservableCollection<GraphUserResult> or new DirectoryUserEntry model)
+ DirectoryFilterText (string, filters in-memory)
+ LoadDirectoryCommand (async, cancellable)
+ IsDirectoryLoading (bool)
+ SelectedDirectoryUser → feeds into existing audit execution path
TenantSwitchedMessage handler in ViewModel: clear DirectoryUsers, reset IsBrowseMode
UserAccessAuditView.xaml:
+ Toolbar toggle (Search | Browse)
+ Visibility-collapsed people-picker panel when in browse mode
+ New DataGrid panel for browse mode
ProfileManagementDialog
→ Remove Registration button (separate from Delete Profile)
→ or confirmation step during profile deletion flow
```
**Key existing code note:** `GraphUserSearchService` does filtered search only (`startsWith` filter +
`ConsistencyLevel: eventual`). Directory listing is a different call pattern — no filter, plain
pagination without `ConsistencyLevel`. A separate `GraphDirectoryService` is cleaner than extending
the existing service; search and browse have different cancellation and retry needs.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| IGraphDirectoryService + GraphDirectoryService (pagination loop) | Low-Medium | Standard Graph paging; same GraphClientFactory in DI |
| ViewModel additions (browse toggle, load command, filter, loading state) | Medium | New async command with progress, cancellation on tenant switch |
| View XAML: toggle + browse DataGrid panel | Medium | Visibility-toggle two panels; DataGrid column definitions |
| In-memory filter + column sort | Low | DataGrid pattern already used in PermissionsView |
| Loading indicator integration | Low | OperationProgress + IsLoading used by every tab |
| Localization keys EN/FR | Low | ~8-12 new keys |
| Unit tests for GraphDirectoryService | Low | Same mock pattern as GraphUserSearchService tests |
| Unit tests for ViewModel browse mode | Medium | Async load command, pagination mock, filter behavior |
| RemoveApplicationAsync (resolve then delete) | Low-Medium | Two calls; 404 idempotency handling |
| Confirmation dialog | Low | Reuse existing ConfirmationDialog pattern |
| Wire into profile deletion flow | Low | Existing profile delete command; add optional app removal step |
---
## Feature 3: Auto-Take Ownership on Access Denied (Global Toggle)
### What it is
When the scanner hits a site with "Access Denied", and the global toggle is on, the app
automatically adds the scanning account as a Site Collection Administrator for that site, retries
the scan, and (optionally) removes itself afterward. The admin controls this with a global on/off.
### How it works (technical)
**Option A — PnP Framework (preferred):**
`context.Web.Context.Site.Owner = user` combined with
`Tenant.SetSiteProperties(siteUrl, owners: loginName)` or
`SPOTenantContext.SetSiteAdmin(siteUrl, loginName, isAdmin: true)` — all available via
`PnP.Framework` which is already a project dependency.
The calling account must hold the SharePoint Administrator role at the tenant level (not just
site admin) to add itself as site collection admin to a site it is currently denied from.
**Option B — Graph API:**
`POST /sites/{siteId}/permissions` with `roles: ["owner"]` — grants the service principal owner
access to a specific site. This works for application permissions with Sites.FullControl.All
but requires additional Graph permission scopes not currently in use.
**Recommended:** PnP Framework path (Option A) because:
- PnP.Framework is already a dependency (no new package)
- The app already uses delegated PnP context for all SharePoint operations
- `Tenant.SetSiteAdmin` is a single method call, well-understood in the MSP ecosystem
- Graph site permissions path requires Sites.FullControl.All which is a very broad app permission
**Self-healing sequence:**
1. Site scan returns 401/403
2. If toggle is ON: call `SetSiteAdmin(siteUrl, currentUserLogin, isAdmin: true)`
3. Retry the scan operation
4. If auto-remove-after is ON: call `SetSiteAdmin(siteUrl, currentUserLogin, isAdmin: false)`
5. Log the takeover action (site, timestamp, user) for audit trail
### Table Stakes
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Global toggle in Settings to enable/disable auto-ownership | Some MSPs want this; others consider it too aggressive for compliance reasons | Low | Boolean field in AppSettings; surfaced in Settings tab |
| Take ownership on 401/403 and retry the scan | The core capability; without the retry it is pointless | Medium | Error interception in the scan pipeline; conditional branch |
| Audit log of takeover actions | Compliance requirement — admin must know which sites were temporarily owned | Low | Extend existing Serilog logging; optionally surface in the scan results list |
| Respect the toggle per-scan-run (not retroactive) | Some scans are read-only audits; the toggle state at run-start should be captured | Low | Capture AppSettings.AutoTakeOwnership at scan start; pass through as scan context |
### Differentiators
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Auto-remove ownership after scan completes | Least-privilege principle; the scanning account should not retain admin rights beyond the scan | Medium | Track which sites were auto-granted; remove in a finally block or post-scan cleanup step |
| Per-scan results column showing "Ownership Taken" flag | Transparency — admin sees which sites required escalation | Low | Add a flag to the ScanResultItem model; render as icon/badge in the results DataGrid |
### Anti-Features
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Auto-take enabled by default | Too aggressive for compliance-conscious MSPs; could violate client change-control policies | Default OFF; require explicit opt-in |
| Permanently retain ownership | Violates least-privilege; creates audit exposure for the MSP | Always remove after scan unless admin explicitly retains |
| Silent ownership changes with no audit trail | Undiscoverable by the client tenant's own admins | Log every takeover with timestamp and account UPN |
### Feature Dependencies
```
Existing:
AppSettings + AutoTakeOwnership (bool, default false)
+ AutoRemoveOwnershipAfterScan (bool, default true)
IPnPContextFactory → provides PnP context for Tenant-level operations
PermissionsScanService → where 401/403 errors currently surface per site
ScanResultItem → add OwnershipTakenFlag (bool)
New:
ISiteOwnershipService / SiteOwnershipService
→ TakeOwnershipAsync(siteUrl, loginName) : Task
→ RemoveOwnershipAsync(siteUrl, loginName) : Task
→ Uses PnP.Framework Tenant.SetSiteAdmin
ScanContext record (or existing scan parameters)
→ Carry AutoTakeOwnership bool captured at scan-start
```
**Key existing code note:** The existing BulkOperationRunner pattern handles per-item continue-on-
error. The ownership-takeover path should not be grafted into BulkOperationRunner directly;
instead it wraps the per-site scan call with a retry decorator that intercepts 401/403 and
invokes SiteOwnershipService before retrying.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| AppSettings fields + Settings UI toggle | Low | Trivial bool fields; existing settings pattern |
| ISiteOwnershipService + PnP SetSiteAdmin calls | Low-Medium | Well-known PnP API; success/failure handling |
| Error interception + retry in scan pipeline | Medium | Must not break existing error reporting; retry must not loop on non-permissions errors |
| Auto-remove in finally block after scan | Medium | Must track which sites were granted and clean up even if scan errors |
| Audit log / results column | Low | Extend existing model + DataGrid |
| Localization EN/FR | Low | ~8-10 new keys |
| Unit tests | High | Retry logic + cleanup path requires careful mock setup; ~15 test cases |
---
## Feature 4: Expand Groups in HTML Reports
### What it is
In HTML permission reports, security groups currently appear as a flat entry (e.g., "IT Team — Edit").
With this feature, each group row has an expand/collapse toggle that shows its members inline,
without leaving the report page. The expanded members list is embedded at report-generation time
(not lazy-loaded via an API call when the report is opened).
### How it works (technical)
At report generation time, for each permission entry that is a group:
1. Resolve group membership: `GET /groups/{id}/members?$select=displayName,userPrincipalName`
2. Embed member data inline in the HTML as a hidden `<tbody>` or `<div>` with a stable CSS class
3. Emit a small inline `<script>` block (vanilla JS, no external dependencies) that toggles
`display:none` on the child rows when the group header is clicked
The `<details>/<summary>` HTML5 approach is also viable and requires zero JavaScript, but gives
less control over styling and the expand icon placement. The onclick/toggle pattern with a
`<span>` chevron is more consistent with the existing report CSS.
**Group member resolution** requires the `GroupMember.Read.All` Graph permission (delegated) or
`Group.Read.All`. The app likely already consumes `Group.Read.All` for the existing group-in-
permissions display — confirm scope list during implementation.
**Depth limit:** Nested groups (groups-within-groups) should be resolved one level deep only.
Full recursive expansion of nested groups can return hundreds of entries and is overkill for a
permissions audit. Mark nested group entries with a "nested group — expand separately" note.
### Table Stakes
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Group rows in HTML report are expandable to show members | A flat "IT Team — Edit" entry is not auditable; admins need to see who is actually in the group | Medium | Member data embedded at generation time; vanilla JS toggle |
| Collapsed by default | Reports may have dozens of groups; expanded by default would be overwhelming | Low | CSS `display:none` on child rows by default; toggle on click |
| Member count shown on collapsed group row | Gives the admin a preview of group size without expanding | Low | `memberCount` available from group metadata or `members.length` at generation time |
| Groups without members (empty) still render correctly | Empty groups exist; collapsed empty list should not crash or show a spinner | Low | Conditional render: no chevron and "(0 members)" label when empty |
### Differentiators
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| "Expand all / Collapse all" button in report header | Useful for small reports or print-to-PDF workflows | Low | Two buttons calling a JS `querySelectorAll('.group-members').forEach(...)` |
| Distinguish direct members vs nested group members visually | Clear hierarchy: direct members vs members-via-nested-group | Medium | Color code or indent nested group entries; requires recursive resolution with depth tracking |
### Anti-Features
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Live API call when user clicks expand (lazy load in browser) | HTML reports are static files — often emailed or archived offline; API calls from a saved HTML file will fail | Embed all member data at generation time, unconditionally |
| Full recursive group expansion (unlimited depth) | Deep nesting can multiply entries 100x; makes reports unusable | One level deep; label nested group entries as such |
| Add group expansion to CSV exports | CSV is flat by nature | CSV stays flat; group expansion is HTML-only |
### Feature Dependencies
```
Existing:
IGraphGroupService (or existing permission resolution code)
→ MemberResolutionAsync(groupId) : Task<IEnumerable<GroupMemberEntry>>
→ Uses existing GraphClientFactory
HtmlExportService (and all other HTML exporters that include group entries)
→ Pass group members into the template at generation time
→ New: HtmlGroupExpansionHelper
→ Renders group header row with expand chevron + member count
→ Renders hidden member rows
→ Emits the inline toggle JS snippet once per report (idempotent)
PermissionEntry model (or equivalent)
→ Add: ResolvedMembers (IList<GroupMemberEntry>?, nullable — only populated for groups)
```
**Key existing code note:** v2.2 already has a shared `HtmlReportHeaderBuilder`. The group
expansion helper follows the same pattern — a shared renderer called from each HTML export
service that emits the group expand/collapse markup and the one-time JS snippet.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| Group member resolution at export time | Medium | Graph call per group; rate-limit awareness; empty group handling |
| HTML template for expandable group rows | Medium | Markup + CSS; inline vanilla JS toggle |
| Embed member data in report model | Low | Extend permission entry model; nullable field |
| Wire up in all HTML exporters that render groups | Medium | Multiple exporters (permissions, user access); each needs the helper |
| Localization EN/FR | Low | ~6-8 new keys |
| Unit tests | Medium | Mock member resolution; verify HTML output contains toggle structure |
---
## Feature 5: Report Consolidation Toggle (Merge Duplicate Entries)
### What it is
In the permissions report, a user who appears in multiple groups — or has direct AND group-based
access — currently generates multiple rows (one per access path). With consolidation ON, these
rows are merged into a single row showing the user's highest-permission level and a count of
access paths.
Example before: "Alice — Edit (via IT Team)", "Alice — Read (direct)"
Example after: "Alice — Edit (2 access paths)" [with a detail-expand or tooltip]
### How it works (technically)
Pure in-memory post-processing on the list of resolved permission entries:
1. Group entries by UPN (or object ID for robustness)
2. For each group: keep the highest-privilege entry, aggregate source paths into a list
3. Annotate the merged entry with access path count and source summary
4. The toggle lives in the export settings or the results toolbar — not a permanent report setting
This is entirely client-side (in-memory in C#) — no additional API calls needed.
**Privilege ordering** must be well-defined:
`FullControl > Edit/Contribute > Read > Limited Access > View Only`
### Table Stakes
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Consolidation toggle in the report/export UI | Auditors want one row per user for a clean headcount view; default OFF preserves existing behavior | Low | Toggle in ViewModel; filters the display/export collection |
| Merge duplicate user rows, keep highest permission | Core consolidation logic | Medium | LINQ GroupBy on UPN + MaxBy on permission level; requires a defined privilege enum ordering |
| Show access path count on consolidated row | "Alice — Edit (3 access paths)" is auditable; silent deduplication is not | Low | Derived count from the group; add to the display model |
| Consolidated export to both HTML and CSV | The toggle must apply equally to all export formats | Low-Medium | Apply consolidation in the ViewModel before passing to export services |
### Differentiators
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Expand consolidated row to see individual access paths (HTML only) | Same expand pattern as Feature 4 (groups); user sees "why Edit" on click | Medium | Reuse the group expansion HTML pattern; embed source paths as hidden child rows |
| Summary line: "X users, Y consolidated entries" in report header | Gives auditors the before/after count immediately | Low | Simple count comparison; rendered in the report header |
### Anti-Features
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Consolidation ON by default | Breaks existing workflow; MSPs relying on multi-path audit output would lose data silently | Default OFF; opt-in per export run |
| Permanent merge (no way to see individual paths) | Auditors must be able to see all access paths for security review | Always preserve the unexpanded detail; consolidation is a view layer only |
| Merge across sites | A user's Edit on Site A and Read on Site B are not the same permission; cross-site merge would lose site context | Consolidate within a site only; separate sections per site remain intact |
### Feature Dependencies
```
Existing:
PermissionEntry / UserAccessEntry models
→ No schema changes needed; consolidation is a view-model transform
PermissionsScanViewModel / UserAccessAuditViewModel
→ Add: IsConsolidated (bool toggle, default false)
→ Add: ConsolidatedResults (computed from raw results via LINQ on toggle change)
HtmlExportService / CsvExportService
→ Accept either raw or consolidated entry list based on toggle state
New:
PermissionConsolidationService (or static helper)
→ Consolidate(IEnumerable<PermissionEntry>, siteScope) : IEnumerable<ConsolidatedEntry>
→ Defines PermissionLevel enum with ordering for MaxBy
```
**Key existing code note:** The app already has a detail-level toggle (simplified vs full
permissions view — shipped in v1.1). The consolidation toggle follows the same UX pattern:
a toolbar toggle that switches between two display modes. Reuse that toggle component and the
pattern of maintaining a filtered/transformed display collection alongside the raw results.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| PermissionLevel enum with ordering | Low | Define once; used by consolidation service and existing simplified view |
| PermissionConsolidationService (LINQ GroupBy + MaxBy) | Low-Medium | Straightforward transformation; edge cases around tie-breaking and LimitedAccess entries |
| ViewModel toggle + computed consolidated collection | Low | Mirrors existing simplified/detail toggle pattern |
| Wire consolidated list into export services | Low | Both exporters already accept IEnumerable; no signature change needed |
| HTML: expand access paths for consolidated entries | Medium | Reuse group expansion markup from Feature 4 |
| Localization EN/FR | Low | ~8-10 new keys |
| Unit tests | Medium | Consolidation logic has many edge cases (direct+group, multiple groups, empty) |
---
## Cross-Feature Dependencies
Both features touch the same data models. Changes must be coordinated:
```
TenantProfile model — gains fields for branding (ClientLogoBase64, ClientLogoSource)
AppSettings model — gains MspLogoBase64
ProfileRepository — serializes/deserializes new TenantProfile fields (JSON, backward-compat)
SettingsRepository — serializes/deserializes new AppSettings field
GraphClientFactory — used by both features (no changes needed)
TenantSwitchedMessage — consumed by UserAccessAuditViewModel to clear directory cache
Graph API scopes (cumulative for this milestone):
Application.ReadWrite.All → Features 1+2 (app registration/removal)
AppRoleAssignment.ReadWrite.All → Feature 1 (consent grant)
GroupMember.Read.All → Feature 4 (group expansion)
SharePoint Sites.FullControl.All → Optional alt path for Feature 3 (avoid if possible)
Model changes:
AppSettings + AutoTakeOwnership (bool)
+ AutoRemoveOwnershipAfterScan (bool)
+ (no new branding fields — v2.2 shipped those)
TenantProfile + ClientId may be auto-populated (Feature 1)
PermissionEntry + ResolvedMembers (Feature 4)
ScanResultItem + OwnershipTakenFlag (Feature 3)
New services (all injectable, interface-first):
IAppRegistrationService → Features 1+2
ISiteOwnershipService → Feature 3
IPermissionConsolidationService → Feature 5
HtmlGroupExpansionHelper → Feature 4 (not a full service, a renderer helper)
Shared infrastructure (no changes needed):
GraphClientFactory → unchanged
BulkOperationRunner → unchanged; Feature 3 wraps around it
HtmlReportHeaderBuilder → extended by Feature 4 helper
Serilog logging → unchanged; Features 1+3 add audit log entries
```
Neither feature requires new NuGet packages. The Graph SDK, MSAL, and System.Text.Json are
already present. No new binary dependencies means no EXE size increase.
No new NuGet packages are needed for Features 3-5. Features 1-2 are already covered by the
Microsoft Graph SDK which is a current dependency. The self-contained EXE size is not expected
to increase.
---
## MVP Recommendation
## Build Order Recommendation
Build in this order, each independently releasable:
Sequence by lowest-risk-to-highest-risk, each independently releasable:
1. **MSP logo in HTML reports** — highest visible impact, lowest complexity. AppSettings field + Settings UI upload + shared header builder.
2. **Client logo in HTML reports (import from file)** — completes the co-branding pattern. TenantProfile field + ProfileManagementDialog upload UI.
3. **User directory browse (load + select + filter)** — core browse UX. Toggle, paginated load, in-memory filter, pipe into existing audit.
4. **Auto-pull client logo from Entra branding** — differentiator, zero-config polish. Build after manual import works so the fallback path is proven.
5. **Directory: guest filter + department/jobTitle columns** — low-effort differentiators; add after core browse is stable.
1. **Report Consolidation Toggle (Feature 5)** — Pure in-memory LINQ; zero new API calls; zero
risk to existing pipeline. Builds confidence before touching external APIs.
2. **Group Expansion in HTML Reports (Feature 4)** — Graph call at export time; reuses existing
GraphClientFactory; lower blast radius than account/registration operations.
3. **Auto-Take Ownership Toggle (Feature 3)** — Modifies tenant state (site admin changes);
must be tested on a non-production tenant. PnP path is well-understood.
4. **App Registration (Feature 1)** — Modifies Entra configuration on the target tenant; highest
blast radius if something goes wrong; save for last when the rest of the milestone is stable.
5. **App Removal (Feature 2)** — Depends on Feature 1 infra (AppRegistrationService); build
immediately after Feature 1 is stable and tested.
Defer to a later milestone:
- Directory session caching across tab switches — a Refresh button is sufficient for v2.2.
- Logo on CSV exports — CSV has no image support; not applicable.
- Certificate-based credentials for registered apps (out of scope by design)
- Cross-site consolidation (different problem domain)
- Recursive group expansion beyond 1 level (complexity/value ratio too low)
---
## Sources
- Graph API List Users (v1.0 official): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — HIGH confidence
- Graph API Get organizationalBranding (v1.0 official): https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get?view=graph-rest-1.0 — HIGH confidence
- Graph API bannerLogo stream: `GET /organization/{id}/branding/localizations/default/bannerLogo` — HIGH confidence (verified in official docs)
- Graph pagination concepts: https://learn.microsoft.com/en-us/graph/paging — HIGH confidence
- ControlMap co-branding (MSP + client logo pattern): https://help.controlmap.io/hc/en-us/articles/24174398424347 — MEDIUM confidence
- ManageEngine ServiceDesk Plus MSP per-account branding: https://www.manageengine.com/products/service-desk-msp/rebrand.html — MEDIUM confidence
- SolarWinds MSP report customization: http://allthings.solarwindsmsp.com/2013/06/customize-your-branding-on-client.html — MEDIUM confidence
- Direct codebase inspection: HtmlExportService.cs, GraphUserSearchService.cs, AppSettings.cs, TenantProfile.cs — HIGH confidence
- Graph API POST /applications (v1.0 official): https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0 — HIGH confidence
- Graph API grant/revoke permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph — HIGH confidence
- Graph API POST /servicePrincipals: https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals?view=graph-rest-1.0 — HIGH confidence (confirmed SP creation is an explicit separate step when using Graph API)
- PnP PowerShell Add-PnPSiteCollectionAdmin: https://pnp.github.io/powershell/cmdlets/Add-PnPSiteCollectionAdmin.html — HIGH confidence (C# equivalent available via PnP.Framework Tenant API)
- PnP PowerShell Set-PnPTenantSite -Owners: https://pnp.github.io/powershell/cmdlets/Set-PnPTenantSite.html — HIGH confidence
- HTML collapsible pattern (details/summary + JS onclick): https://dev.to/jordanfinners/creating-a-collapsible-section-with-nothing-but-html-4ip9 — HIGH confidence (standard HTML5)
- W3Schools collapsible JS pattern: https://www.w3schools.com/howto/howto_js_collapsible.asp — HIGH confidence
- Graph API programmatically manage Entra apps: https://learn.microsoft.com/en-us/graph/tutorial-applications-basics — HIGH confidence
- Required Entra role for app registration: Application.ReadWrite.All + Cloud Application Administrator minimum — HIGH confidence (official permissions reference)
- Direct codebase inspection: AppSettings.cs, TenantProfile.cs, GraphClientFactory.cs, BulkOperationRunner.cs, HtmlExportService.cs, PermissionsScanService.cs — HIGH confidence

View File

@@ -800,3 +800,324 @@ bitmap.Freeze(); // Makes it immutable and thread-safe; also releases the file h
---
*v2.2 pitfalls appended: 2026-04-08*
---
# v2.3 Pitfalls: Tenant Management & Report Enhancements
**Milestone:** v2.3 — App registration, auto-ownership, HTML group expansion, report consolidation
**Researched:** 2026-04-09
**Confidence:** HIGH for app registration sequence and group expansion limits (official Microsoft Learn docs); MEDIUM for auto-ownership security implications (multiple official sources cross-verified); MEDIUM for report consolidation (general deduplication principles applied to specific codebase model)
These pitfalls are specific to the four new feature areas in v2.3. They complement all prior pitfall sections above.
---
## Critical Pitfalls (v2.3)
### Pitfall v2.3-1: Missing Service Principal Creation After App Registration
**What goes wrong:**
`POST /applications` creates the application object (the registration) but does NOT automatically create the service principal (enterprise app entry) in the target tenant. Attempting to grant permissions or use the app before creating the service principal produces cryptic 400/404 errors with no clear explanation. The application appears in Entra "App registrations" but is absent from "Enterprise applications."
**Why it happens:**
The distinction between the application object (one across all tenants, lives in home tenant) and the service principal (one per tenant that uses the app) is not obvious. Most UI flows in the Azure portal create both atomically; the Graph API does not.
**Consequences:**
Permission grants fail. Admin consent cannot be completed. The automated registration path appears broken with no recoverable error message.
**Prevention:**
Implement app creation as a three-step atomic transaction with rollback on any failure:
1. `POST /applications` — capture `appId` and object `id`
2. `POST /servicePrincipals` with `{ "appId": "<appId>" }` — capture service principal `id`
3. `POST /servicePrincipals/{spId}/appRoleAssignments` — grant each required app role
If step 2 or 3 fail, delete the application object created in step 1 to avoid orphaned registrations. Surface the failure with a specific message: "App was registered but could not be configured. It has been removed. Try again or use the manual setup guide."
**Detection:**
- App appears in Azure portal App Registrations but not in Enterprise Applications.
- Token acquisition fails with AADSTS700016 ("Application not found in directory").
- `appRoleAssignment` POST returns 404 "Resource not found."
**Phase to address:** App Registration feature — before writing any registration code.
---
### Pitfall v2.3-2: Circular Consent Dependency for the Automated Registration Path
**What goes wrong:**
The automated path calls Graph APIs to create an app registration on a target tenant. These calls require the MSP own `TenantProfile.ClientId` app to have `Application.ReadWrite.All` and `AppRoleAssignment.ReadWrite.All` delegated permissions consented. These permissions are high-privilege and almost certainly not in the MSP app current consent grant (which was configured for SharePoint auditing). Without them, the automated path fails with 403 Forbidden on the very first Graph call.
**Why it happens:**
The MSP app was registered for auditing scopes (SharePoint, Graph user read). App management scopes are a distinct, highly privileged category. Developers test against their own dev tenant where they have unrestricted access and never hit this problem.
**Consequences:**
The "auto via Graph API" mode works only in the narrow case where the MSP has pre-configured their own app with these elevated permissions. For all other deployments, it fails silently or with a confusing 403.
**Prevention:**
- Design two modes from day one: **automated** (MSP app already has `Application.ReadWrite.All` + `AppRoleAssignment.ReadWrite.All`) and **guided fallback** (step-by-step portal instructions shown in UI).
- Before attempting the automated path, detect whether the required permissions are available: request a token with the required scopes and handle `MsalUiRequiredException` or `MsalServiceException` with error code `insufficient_scope` as a signal to fall back.
- The guided fallback must be a first-class feature, not an afterthought. It should produce a pre-filled PowerShell script or direct portal URLs the target tenant admin can follow.
- Never crash on a 403; always degrade gracefully to guided mode.
**Detection:**
- MSAL token request returns `insufficient_scope` or Graph returns `Authorization_RequestDenied`.
- Works on dev machine (dev has Global Admin + explicit consent), fails on first real MSP deployment.
**Phase to address:** App Registration design — resolve guided vs. automated split before implementation begins.
---
### Pitfall v2.3-3: App Removal Leaves Orphaned Service Principals in Target Tenant
**What goes wrong:**
`DELETE /applications/{objectId}` removes the application object from the home tenant but does NOT delete the service principal in the target tenant. OAuth2 permission grants and app role assignments linked to that service principal also remain. On re-registration, Entra may reject with a duplicate `appId` error, or the target tenant accumulates zombie enterprise app entries that confuse tenant admins.
**Why it happens:**
The service principal is owned by the target tenant Entra directory, not by the application home tenant. The MSP app may not have permission to delete service principals in the target tenant.
**Prevention:**
Define the removal sequence as:
1. Revoke all app role assignments: `DELETE /servicePrincipals/{spId}/appRoleAssignments/{id}` for each grant
2. Delete the service principal: `DELETE /servicePrincipals/{spId}`
3. Delete the application object: `DELETE /applications/{appObjectId}`
If step 2 fails with 403 (cross-tenant restriction), surface a guided step: "Open the target tenant Azure portal -> Enterprise Applications -> search for the app name -> Delete." Do not silently skip — leaving an orphaned SP is a security artifact.
Require the stored `ManagedAppObjectId` and `ManagedServicePrincipalId` fields (see Pitfall v2.3-5) for this operation; never search by display name.
**Detection:**
- After deletion, the Enterprise Application still appears in the target tenant portal.
- Re-registration attempt produces `AADSTS70011: Invalid scope. The scope ... is not valid`.
**Phase to address:** App Removal feature.
---
### Pitfall v2.3-4: Auto-Ownership Elevation Not Cleaned Up on Crash or Token Expiry
**What goes wrong:**
The "auto-take ownership on access denied" flow elevates the tool to site collection administrator, performs the scan, then removes itself. If the app crashes mid-scan, the user closes the window, or the MSAL token expires and the removal call fails, the elevation is never reverted. The MSP account now has persistent, undocumented site collection admin rights on a client site — a security and compliance risk.
**Why it happens:**
The take-ownership -> act -> release pattern requires reliable cleanup in all failure paths. WPF desktop apps can be terminated by the OS (BSOD, force close, low memory). Token expiry is time-based and unpredictable. No amount of `try/finally` protects against hard process termination.
**Consequences:**
- MSP account silently holds elevated permissions on client sites.
- If audited, the MSP appears to have persistent admin access without justification.
- Client tenant admins may notice unexplained site collection admins and raise a security concern.
**Prevention:**
- `try/finally` is necessary but not sufficient. Also maintain a persistent "cleanup pending" list in a local JSON file (e.g., `pending_ownership_cleanup.json`). Write the site URL and elevation timestamp to this file BEFORE the elevation happens. Remove the entry AFTER successful cleanup.
- On every app startup, check this file and surface a non-dismissable warning listing any pending cleanups with links to the SharePoint admin center for manual resolution.
- The UI toggle label should reflect the risk: "Auto-take site ownership on access denied (will attempt to release after scan)."
- Log every elevation and every release attempt to Serilog with outcome (success/failure), site URL, and timestamp.
**Detection:**
- After a scan that uses auto-ownership, check the site Site Collection Administrators in SharePoint admin center. The MSP account should not be present.
- Simulate a crash mid-scan; restart the app. Verify the cleanup warning appears.
**Phase to address:** Auto-Ownership feature — persistence mechanism and startup check must be built before the elevation logic.
---
## Moderate Pitfalls (v2.3)
### Pitfall v2.3-5: TenantProfile Model Missing Fields for Registration Metadata
**What goes wrong:**
`TenantProfile` currently has `Name`, `TenantUrl`, `ClientId`, and `ClientLogo`. After app registration, the tool needs to store the created application Graph object ID, `appId`, and service principal ID for later removal. Without these fields, removal requires searching by display name — fragile if a tenant admin renamed the app — or is impossible programmatically.
**Prevention:**
Extend `TenantProfile` with optional fields before writing any registration code:
```csharp
public string? ManagedAppObjectId { get; set; } // Graph object ID of created application
public string? ManagedAppId { get; set; } // appId (client ID) of created app
public string? ManagedServicePrincipalId { get; set; }
public DateTimeOffset? ManagedAppRegisteredAt { get; set; }
```
These are nullable: profiles created before v2.3 or using manually configured app registrations will have them null, which signals "use guided removal."
Persist atomically to the JSON profile file immediately after successful registration (using the existing write-then-replace pattern from the foundation pitfall section).
**Phase to address:** App Registration feature — model change must precede implementation of both registration and removal.
---
### Pitfall v2.3-6: `$expand=members` Silently Truncates Group Members at ~20
**What goes wrong:**
The simplest approach to get group members for HTML report expansion is `GET /groups/{id}?$expand=members`. This is hard-capped at approximately 20 members and is not paginable — `$top` does not increase the limit for expanded navigational properties. For any real-world group (department group, "All Employees"), the expanded list is silently incomplete with no `@odata.nextLink` or warning.
**Why it happens:**
`$expand` is a navigational shortcut for small relationships, not for large collection fetches. Developers use it because it retrieves the parent object and its members in one call.
**Prevention:**
Always use the dedicated endpoint: `GET /groups/{id}/transitiveMembers?$select=displayName,mail,userPrincipalName&$top=999` and follow `@odata.nextLink` until exhausted. `transitiveMembers` resolves nested group membership server-side, eliminating the need for manual recursion in most cases.
Group member data must be resolved server-side at report generation time (in C#). The HTML output is a static offline file — no live Graph calls are possible after export.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-7: Nested Group Recursion Without Cycle Detection
**What goes wrong:**
If `transitiveMembers` is not used and manual recursion is implemented, groups can form cycles in edge cases. Even without true cycles, the same group ID can appear via multiple paths (group A and group B both contain group C), causing its members to be listed twice.
**Prevention:**
- Prefer `transitiveMembers` over manual recursion for M365/Entra groups — Graph resolves transitivity server-side.
- If manual recursion is needed (e.g., for SharePoint groups which are not M365 groups), maintain a `HashSet<string>` of visited group IDs. If a group ID is already in the set, skip it.
- Cap recursion depth at 5. Surface a "(nesting limit reached)" indicator in the HTML if the cap is hit.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-8: Report Consolidation Changes the Output Schema Users Depend On
**What goes wrong:**
`UserAccessEntry` is a flat record: one row = one permission assignment. Users (and any downstream automation) expect this structure. Consolidation merges rows for the same user across sites/objects into a single row with aggregated data. This is a breaking change to the report format. Existing users treating the CSV export as structured input will have their scripts break silently (wrong row count, missing columns, or changed column semantics).
**Why it happens:**
Consolidation is useful but changes the fundamental shape of the data. If it is on by default or a persistent global setting, users who do not read release notes discover the breakage in production.
**Prevention:**
- Consolidation toggle must be **off by default** and **per-report-generation** (a checkbox at export time, not a persistent global preference).
- Introduce a new `ConsolidatedUserAccessEntry` type; do not modify `UserAccessEntry`. The existing audit pipeline, CSV export, and HTML export continue to use `UserAccessEntry` unchanged.
- Consolidation produces a clearly labelled report (e.g., a "Consolidated View" header in the HTML, or a `_consolidated` filename suffix for CSV).
- Both CSV and HTML exports must honour the toggle consistently. A mismatch (CSV not consolidated, HTML consolidated for the same run) is a data integrity error.
**Phase to address:** Report Consolidation feature — model and toggle design must be settled before building the consolidation logic.
---
### Pitfall v2.3-9: Graph API Throttling During Bulk Group Expansion at Report Generation
**What goes wrong:**
A user access report across 20 sites may surface 50+ distinct groups. Expanding all of them via sequential Graph calls can trigger HTTP 429. After September 2025, Microsoft reduced per-app per-user throttling limits to half of the tenant total, making this more likely under sustained MSP use.
**Prevention:**
- Cache group membership results within a single report generation run: if the same `groupId` appears in multiple sites, resolve it once and reuse the result. A `Dictionary<string, IReadOnlyList<GroupMember>>` keyed by group ID is sufficient.
- Process group expansions with bounded concurrency: `SemaphoreSlim(3)` (max 3 concurrent) rather than `Task.WhenAll` over all groups.
- Apply exponential backoff on 429 responses using the `Retry-After` response header value.
- The existing `BulkOperationRunner` pattern can be adapted for this purpose.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-10: SharePoint Admin Role Required for Site Ownership Changes (Not Just Global Admin)
**What goes wrong:**
Adding a user as site collection administrator via PnP Framework or Graph requires the authenticated account to be a SharePoint Administrator (the role in the Microsoft 365 admin center), not just a Global Administrator. A user can be Global Admin in Entra without being SharePoint Admin. In testing environments the developer is typically both; in production MSP deployments a dedicated service account may only have the roles explicitly needed for auditing.
**Why it happens:**
SharePoint has its own RBAC layer. PnP `AddAdministratorToSiteAsync` and equivalent CSOM calls check SharePoint-level admin role, not just Entra admin roles.
**Prevention:**
- Before enabling the auto-ownership feature for a profile, validate that the current authenticated account has SharePoint admin rights. Attempt a low-risk admin API call (e.g., `GET /admin/sharepoint/sites`) and handle 403 as "insufficient permissions — SharePoint Administrator role required."
- Document the requirement in the UI tooltip and guided setup text.
- Test the feature against an account that is Global Admin but NOT SharePoint Admin to confirm the error path and message.
**Phase to address:** Auto-Ownership feature.
---
### Pitfall v2.3-11: HTML Report Size Explosion from Embedded Group Member Data
**What goes wrong:**
The current HTML export embeds all data inline as JavaScript variables. Expanding group members for a large report (50 groups x 200 members) embeds 10,000 additional name/email strings inline. Report file size can grow from ~200 KB to 5+ MB. Opening the file in Edge on an older machine becomes slow; in extreme cases the browser tab crashes.
**Prevention:**
- Cap embedded member data at a configurable limit (e.g., 200 members per group). Display the actual count alongside a "(showing first 200 of 1,450)" indicator.
- Render member lists as hidden `<div>` blocks toggled by the existing clickable-expand JavaScript pattern — do not pre-render all member rows into visible DOM nodes.
- Do not attempt to implement live API calls from the HTML file. It is a static offline report and has no authentication context.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-12: App Registration Display Name Collision
**What goes wrong:**
Using a fixed display name (e.g., "SharePoint Toolbox") for every app registration created across all client tenants, combined with looking up apps by display name for removal, causes the removal flow to target the wrong app if a tenant admin manually created another app with the same name.
**Prevention:**
- Use a unique display name per registration that includes a recognizable prefix and ideally the MSP name, e.g., "SharePoint Toolbox - Contoso MSP."
- Never use display name for targeting deletions. Always use the stored `ManagedAppObjectId` (see Pitfall v2.3-5).
**Phase to address:** App Registration feature.
---
## Phase-Specific Warnings (v2.3)
| Phase Topic | Likely Pitfall | Mitigation |
|---|---|---|
| App Registration — design | Automated path fails due to missing `Application.ReadWrite.All` (v2.3-2) | Design guided fallback before automated path; detect permission gaps before first API call |
| App Registration — data model | `TenantProfile` cannot store created app IDs (v2.3-5) | Add nullable fields to model; persist atomically after registration |
| App Registration — sequence | Forgetting `POST /servicePrincipals` after `POST /applications` (v2.3-1) | Implement as atomic 3-step transaction with rollback |
| App Registration — display name | Collision with manually created apps (v2.3-12) | Unique name including MSP identifier; never search/delete by name |
| App Removal | Orphaned service principal in target tenant (v2.3-3) | Three-step removal with guided fallback if cross-tenant SP deletion fails |
| Auto-Ownership — cleanup | Elevation not reverted on crash (v2.3-4) | Persistent cleanup-pending JSON + startup check + non-dismissable warning |
| Auto-Ownership — permissions | Works in dev (Global Admin), fails in production (no SharePoint Admin role) (v2.3-10) | Validate SharePoint admin role before first elevation; test against restricted account |
| Group Expansion — member fetch | `$expand=members` silently truncates at ~20 (v2.3-6) | Use `transitiveMembers` with `$top=999` + follow `@odata.nextLink` |
| Group Expansion — recursion | Cycle / duplication in nested groups (v2.3-7) | `HashSet<string>` visited set; prefer `transitiveMembers` over manual recursion |
| Group Expansion — throttling | 429 from bulk group member fetches (v2.3-9) | Per-session member cache; `SemaphoreSlim(3)`; exponential backoff on 429 |
| Group Expansion — HTML size | Report file grows to 5+ MB (v2.3-11) | Cap members per group; lazy-render hidden blocks; display "first N of M" indicator |
| Report Consolidation — schema | Breaking change to row structure (v2.3-8) | Off by default; new model type; consistent CSV+HTML behaviour |
---
## v2.3 Integration Gotchas
| Integration | Common Mistake | Correct Approach |
|---|---|---|
| Graph `POST /applications` | Assuming service principal is auto-created | Always follow with `POST /servicePrincipals { "appId": "..." }` before granting permissions |
| Graph admin consent grant | Using delegated flow without `Application.ReadWrite.All` pre-consented | Detect missing scope at startup; fall back to guided mode gracefully |
| Graph group members | `$expand=members` on group object | `GET /groups/{id}/transitiveMembers?$select=...&$top=999` + follow `nextLink` |
| PnP set site collection admin | Global Admin account without SharePoint Admin role | Validate SharePoint admin role before attempting; test against restricted account |
| Auto-ownership cleanup | `try/finally` assumed sufficient | Persistent JSON cleanup list + startup check handles hard process termination |
| `TenantProfile` for removal | Search for app by display name | Store `ManagedAppObjectId` at registration time; use object ID for all subsequent operations |
| Report consolidation toggle | Persistent global setting silently changes future exports | Per-export-run checkbox, off by default; new model type; never modify `UserAccessEntry` |
---
## v2.3 "Looks Done But Isn't" Checklist
- [ ] **App registration — service principal:** After automated registration, verify the app appears in the target tenant Enterprise Applications (not just App Registrations).
- [ ] **App registration — guided fallback:** Disable `Application.ReadWrite.All` on the MSP app and attempt automated registration. Verify graceful fallback to guided mode with a clear explanation, not a crash.
- [ ] **App removal — SP cleanup:** After removal, verify the Enterprise Application is gone from the target tenant. If SP deletion failed, verify the guided manual step is surfaced.
- [ ] **Auto-ownership — cleanup on crash:** Start an auto-ownership scan, force-close the app mid-scan, restart. Verify the cleanup-pending warning appears with the site URL.
- [ ] **Auto-ownership — release after scan:** Complete a full auto-ownership scan. Verify the MSP account is no longer in the site collection admins list.
- [ ] **Group expansion — large group:** Expand a group with 200+ members. Verify all members are shown (not just 20), or the cap indicator is correct.
- [ ] **Group expansion — nested groups:** Expand a group that contains a sub-group. Verify sub-group members appear without duplicates.
- [ ] **Group expansion — throttle recovery:** Simulate 429 during group expansion. Verify the operation pauses, logs "Retrying in Xs", and completes.
- [ ] **Report consolidation — off by default:** Generate a user access report without enabling the toggle. Verify the output is identical to v2.2 output for the same data.
- [ ] **Report consolidation — CSV + HTML consistency:** Enable consolidation and export both CSV and HTML. Verify both show the same number of consolidated rows.
- [ ] **TenantProfile persistence:** After app registration, open the profile JSON file and verify `ManagedAppObjectId`, `ManagedAppId`, and `ManagedServicePrincipalId` are present and non-empty.
---
## v2.3 Sources
- Microsoft Learn: Create application — Graph v1.0 — https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0
- Microsoft Learn: Grant and revoke API permissions programmatically — https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
- Microsoft Learn: Grant tenant-wide admin consent to an application — https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent
- Microsoft Learn: Grant an appRoleAssignment to a service principal — https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-approleassignments?view=graph-rest-1.0
- Microsoft Learn: List group transitive members — Graph v1.0 — https://learn.microsoft.com/en-us/graph/api/group-list-transitivemembers?view=graph-rest-1.0
- Microsoft Learn: Microsoft Graph service-specific throttling limits — https://learn.microsoft.com/en-us/graph/throttling-limits
- Microsoft Q&A: How to use $expand=members parameter with pagination — https://learn.microsoft.com/en-us/answers/questions/5526721/how-to-use-the-expand-members-parameter-with-pagin
- Microsoft Learn: Create SharePoint site ownership policy — https://learn.microsoft.com/en-us/sharepoint/create-sharepoint-site-ownership-policy
- PnP PowerShell GitHub Issue #542: Add-PnPSiteCollectionAdmin Access Is Denied — https://github.com/pnp/powershell/issues/542
- Pim Widdershoven: Privilege escalation using Azure App Registration and Microsoft Graph — https://www.pimwiddershoven.nl/entry/privilege-escalation-azure-app-registration-microsoft-graph/
- 4Spot Consulting: Deduplication Pitfalls — When Not to Merge Data — https://4spotconsulting.com/when-clean-data-damages-your-business-the-perils-of-over-deduplication/
- Existing codebase: `TenantProfile.cs`, `UserAccessEntry.cs`, `UserAccessHtmlExportService.cs`, `SessionManager.cs` (reviewed 2026-04-09)
---
*v2.3 pitfalls appended: 2026-04-09*

View File

@@ -189,7 +189,7 @@ The implementation follows the same `GraphClientFactory` + `GraphServiceClient`
---
## No New NuGet Packages Required
## No New NuGet Packages Required (v2.2)
| Feature | What's needed | How provided |
|---|---|---|
@@ -207,7 +207,7 @@ The implementation follows the same `GraphClientFactory` + `GraphServiceClient`
---
## Impact on Existing Services
## Impact on Existing Services (v2.2)
### HTML Export Services
@@ -239,9 +239,196 @@ Add a `BrowseMode` boolean property (bound to a RadioButton or ToggleButton). Wh
---
---
## 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:
```csharp
// 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:**
```csharp
// 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:
```csharp
// 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:
```csharp
// 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:
```csharp
// 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 v1.1:
The full stack as validated through v2.2:
| Technology | Version | Purpose |
|---|---|---|
@@ -249,13 +436,13 @@ The full stack as validated through v1.1:
| 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) |
| 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 (in use, stable enough) |
| LiveCharts2 (SkiaSharpView.WPF) | 2.0.0-rc5.4 | Storage charts |
| Serilog | 4.3.1 | Structured logging |
| Serilog.Extensions.Hosting | 10.0.0 | ILogger<T> bridge |
| Serilog.Sinks.File | 7.0.0 | Rolling file output |
@@ -268,8 +455,16 @@ The full stack as validated through v1.1:
## Sources
- Microsoft Learn — List users (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — permissions, $top max 999, $orderby with ConsistencyLevel, default fields (HIGH confidence, updated 2025-07-23)
- Microsoft Learn — Page through a collection (Graph SDKs): https://learn.microsoft.com/en-us/graph/sdks/paging — PageIterator C# pattern, DirectoryPageTokenNotFoundException warning (HIGH confidence, updated 2025-08-06)
- Microsoft Learn — Get organizationalBranding: https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get?view=graph-rest-1.0 — branding stream retrieval via localizations/default/bannerLogo (HIGH confidence, updated 2025-11-08) — note: tenant branding pull is optional/future, not required for v2.2 which relies on user-supplied logo files
- .NET Perls / BCL docs — Convert.ToBase64String + data URI pattern: confirmed BCL, no library needed (HIGH confidence)
- Existing codebase inspection: GraphClientFactory.cs, GraphUserSearchService.cs, HtmlExportService.cs, UserAccessHtmlExportService.cs, TenantProfile.cs, AppSettings.cs — confirmed exact integration points
**v2.2 sources:**
- Microsoft Learn — List users (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — permissions, $top max 999, $orderby with ConsistencyLevel (HIGH confidence)
- Microsoft Learn — Page through a collection (Graph SDKs): https://learn.microsoft.com/en-us/graph/sdks/paging — PageIterator C# pattern (HIGH confidence)
**v2.3 sources:**
- Microsoft Learn — Create application (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0 — C# SDK 5.x pattern confirmed, `Application.ReadWrite.All` required (HIGH confidence, updated 2026-03-14)
- Microsoft Learn — Create servicePrincipal (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals?view=graph-rest-1.0 — `Application.ReadWrite.All` required for multi-tenant apps (HIGH confidence, updated 2026-03-14)
- Microsoft Learn — Delete application (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/application-delete?view=graph-rest-1.0 — `graphClient.Applications[id].DeleteAsync()`, 204 response, 30-day soft-delete (HIGH confidence)
- Microsoft Learn — Grant permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph — `AppRoleAssignment.ReadWrite.All` for consent grants, C# SDK 5.x examples (HIGH confidence, updated 2026-03-21)
- Microsoft Learn — Choose authentication providers: https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers — Interactive provider pattern for desktop apps confirmed (HIGH confidence, updated 2025-08-06)
- PnP Core SDK docs — Security/SetSiteCollectionAdmins: https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html — tenant API bypasses site-level permission check, requires SharePoint Admin role (MEDIUM confidence — PnP Core docs; maps to PnP Framework `Tenant.SetSiteAdmin` behavior)
- Microsoft.Graph NuGet package: https://www.nuget.org/packages/Microsoft.Graph/ — latest stable 5.103.0 confirmed 2026-02-20 (HIGH confidence)
- Codebase — GraphClientFactory.cs, SessionManager.cs, MsalClientFactory.cs — confirmed existing `BaseBearerTokenAuthenticationProvider` + MSAL PCA integration pattern (HIGH confidence, source read directly)