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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user