# Feature Landscape

**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 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: 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

| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| 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

| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| 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

| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| 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

```
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:** 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 |
|----------|------------|--------|
| 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: 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

| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| 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 |
|--------------|-----------|-------------------|
| 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

```
Existing:
  AppRegistrationService (from Feature 1)
    + RemoveApplicationAsync(clientId) : Task
      → GET /applications?$filter=appId eq '{clientId}' → resolve object ID
      → DELETE /applications/{objectId}

ProfileManagementDialog
  → Remove Registration button (separate from Delete Profile)
  → or confirmation step during profile deletion flow
```

### Complexity Assessment

| Sub-task | Complexity | Reason |
|----------|------------|--------|
| 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

```
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
```

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.

---

## Build Order Recommendation

Sequence by lowest-risk-to-highest-risk, each independently releasable:

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:
- 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 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
