# Phase 19: App Registration & Removal - Research

**Researched:** 2026-04-09
**Domain:** Microsoft Graph API / Azure AD app registration / MSAL token cache / WPF dialog extension
**Confidence:** HIGH

---

## Summary

Phase 19 lets the Toolbox register itself as an Azure AD application on a target tenant directly from the profile dialog, with a guided manual fallback when the signed-in user lacks sufficient permissions. It also provides removal of that registration with full MSAL session eviction.

The entire operation runs through the existing `GraphServiceClient` (Microsoft.Graph 5.74.0 already in the project). Registration is a four-step sequential Graph API workflow: create Application object → create ServicePrincipal → look up the resource service principal (Microsoft Graph + SharePoint Online) → post AppRoleAssignment for each required permission. Deletion reverses it: delete Application object (which soft-deletes and cascades to the service principal); then clear MSAL in-memory accounts and the persistent cache file.

Global Admin detection uses `GET /me/memberOf/microsoft.graph.directoryRole` filtered on roleTemplateId `62e90394-69f5-4237-9190-012177145e10`. If that returns no results the fallback instruction panel is shown instead of attempting registration.

**Primary recommendation:** Implement `IAppRegistrationService` using `GraphServiceClient` with a try/catch rollback pattern; store the newly-created `appId` on `TenantProfile` (new nullable field) so the Remove action can look it up.

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| APPREG-01 | User can register the app on a target tenant from the profile create/edit dialog | New `RegisterAppCommand` on `ProfileManagementViewModel`; opens result/fallback panel in-dialog |
| APPREG-02 | App auto-detects if user has Global Admin permissions before attempting registration | `GET /me/memberOf/microsoft.graph.directoryRole` filtered on Global Admin template ID |
| APPREG-03 | App creates Azure AD application + service principal + grants required permissions atomically (with rollback on failure) | Sequential Graph API calls; rollback via `DELETE /applications/{id}` on any intermediate failure |
| APPREG-04 | User sees guided fallback instructions when auto-registration is not possible | Separate XAML panel with step-by-step manual instructions, shown when APPREG-02 returns false |
| APPREG-05 | User can remove the app registration from a target tenant | `RemoveAppCommand`; `DELETE /applications(appId='{appId}')` → clears MSAL session |
| APPREG-06 | App clears cached tokens and sessions when app registration is removed | MSAL `RemoveAsync` on all accounts + `MsalCacheHelper` unregister; `SessionManager.ClearSessionAsync` |
</phase_requirements>

---

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Microsoft.Graph | 5.74.0 (already in project) | Create/delete Application, ServicePrincipal, AppRoleAssignment | Project's existing Graph SDK — no new dependency |
| Microsoft.Identity.Client | 4.83.3 (already in project) | Clear MSAL token cache per-clientId | Same PCA used throughout the project |
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 (already in project) | Unregister/delete persistent cache file | `MsalCacheHelper` already wired in `MsalClientFactory` |

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| CommunityToolkit.Mvvm | 8.4.2 (already in project) | `IAsyncRelayCommand` for Register/Remove buttons | Matches all other VM commands in the project |
| Serilog | 4.3.1 (already in project) | Log registration steps and failures | Same structured logging used everywhere |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Graph SDK (typed) | Raw HttpClient | Graph SDK handles auth, retries, model deserialization — no reason to use raw HTTP |
| Sequential with rollback | Parallel creation | ServicePrincipal must exist before AppRoleAssignment — ordering is mandatory |

**Installation:** No new packages required. All dependencies already in `SharepointToolbox.csproj`.

---

## Architecture Patterns

### Recommended Project Structure

```
SharepointToolbox/
├── Services/
│   ├── IAppRegistrationService.cs   # interface
│   └── AppRegistrationService.cs    # implementation
├── Core/Models/
│   └── TenantProfile.cs             # add AppId (nullable string)
├── Core/Models/
│   └── AppRegistrationResult.cs     # success/failure/fallback discriminated result
└── ViewModels/
    └── ProfileManagementViewModel.cs # add RegisterAppCommand, RemoveAppCommand, status properties
```

XAML changes live in `Views/Dialogs/ProfileManagementDialog.xaml` — new rows in the existing grid for status/fallback panel.

### Pattern 1: Sequential Registration with Rollback

**What:** Each Graph API call is wrapped in a try/catch. If any step fails, previously created objects are deleted before rethrowing.

**When to use:** Any multi-step Entra creation where partial state (Application without ServicePrincipal, or SP without role assignments) is worse than nothing.

```csharp
// Source: https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0
// Source: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph

public async Task<AppRegistrationResult> RegisterAsync(
    string clientId,        // the Toolbox's own app (used to get an authed GraphClient)
    string displayName,
    CancellationToken ct)
{
    Application? createdApp = null;
    try
    {
        // Step 1: create Application object
        var appRequest = new Application
        {
            DisplayName = displayName,
            SignInAudience = "AzureADMyOrg",
            IsFallbackPublicClient = true,
            PublicClient = new PublicClientApplication
            {
                RedirectUris = new List<string>
                {
                    "https://login.microsoftonline.com/common/oauth2/nativeclient"
                }
            },
            RequiredResourceAccess = BuildRequiredResourceAccess()
        };
        createdApp = await _graphClient.Applications.PostAsync(appRequest, ct);

        // Step 2: create ServicePrincipal
        var sp = await _graphClient.ServicePrincipals.PostAsync(
            new ServicePrincipal { AppId = createdApp!.AppId }, ct);

        // Step 3: grant admin consent (AppRoleAssignments) for each required scope
        await GrantAppRolesAsync(sp!.Id!, ct);

        return AppRegistrationResult.Success(createdApp.AppId!);
    }
    catch (Exception ex)
    {
        // Rollback: delete the Application (cascades soft-delete of SP)
        if (createdApp?.Id is not null)
        {
            try { await _graphClient.Applications[createdApp.Id].DeleteAsync(ct); }
            catch { /* best-effort rollback */ }
        }
        return AppRegistrationResult.Failure(ex.Message);
    }
}
```

### Pattern 2: Global Admin Detection

**What:** Query the signed-in user's directory role memberships; filter on the well-known Global Admin template ID.

**When to use:** Before attempting registration — surfaces the fallback path immediately.

```csharp
// Source: https://learn.microsoft.com/en-us/graph/api/directoryrole-list?view=graph-rest-1.0
// Global Admin roleTemplateId is stable across all tenants
private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10";

public async Task<bool> IsGlobalAdminAsync(CancellationToken ct)
{
    var roles = await _graphClient.Me
        .MemberOf
        .GetAsync(r => r.QueryParameters.Filter =
            $"isof('microsoft.graph.directoryRole')", ct);

    return roles?.Value?
        .OfType<DirectoryRole>()
        .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
            StringComparison.OrdinalIgnoreCase)) ?? false;
}
```

**Note:** `GET /me/memberOf` requires `Directory.Read.All` or `RoleManagement.Read.Directory` delegated permission. With `https://graph.microsoft.com/.default`, the app will get whatever the user has consented to — if the user is Global Admin the existing consent scope typically covers this. If the call fails with 403, treat as "not admin" and show fallback.

### Pattern 3: MSAL Full Session Eviction (APPREG-06)

**What:** Clear in-memory MSAL accounts AND unregister the persistent cache file.

```csharp
// Source: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/clear-token-cache
public async Task ClearMsalSessionAsync(string clientId)
{
    // 1. Clear SessionManager's live ClientContext
    await _sessionManager.ClearSessionAsync(_profile.TenantUrl);

    // 2. Clear in-memory MSAL accounts
    var pca = await _msalFactory.GetOrCreateAsync(clientId);
    var accounts = (await pca.GetAccountsAsync()).ToList();
    while (accounts.Any())
    {
        await pca.RemoveAsync(accounts.First());
        accounts = (await pca.GetAccountsAsync()).ToList();
    }

    // 3. Unregister persistent cache so stale tokens don't survive app restart
    var helper = _msalFactory.GetCacheHelper(clientId);
    helper.UnregisterCache(pca.UserTokenCache);
    // Optionally delete the .cache file:
    // File.Delete(Path.Combine(_msalFactory.CacheDirectory, $"msal_{clientId}.cache"));
}
```

### Pattern 4: RequiredResourceAccess (app manifest scopes)

The Toolbox needs the following delegated permissions on the new app registration. These map to `requiredResourceAccess` in the Application object:

**Microsoft Graph (appId `00000003-0000-0000-c000-000000000000`):**
- `User.Read` — sign-in and read profile
- `User.Read.All` — read directory users (user directory feature)
- `Group.Read.All` — read AAD groups (group resolver)
- `Directory.Read.All` — list sites, read tenant info

**SharePoint Online (appId `00000003-0000-0ff1-ce00-000000000000`):**
- `AllSites.FullControl` (delegated) — required for Tenant admin operations via PnP

Note: `requiredResourceAccess` configures what's shown in the consent dialog. It does NOT auto-grant — the user still has to consent interactively on first login. For admin consent grant (AppRoleAssignment), only an admin can call `servicePrincipals/{id}/appRoleAssignedTo`.

**Important scope ID discovery:** The planner should look up the exact permission GUIDs at registration time by querying `GET /servicePrincipals?$filter=appId eq '{resourceAppId}'&$select=appRoles,oauth2PermissionScopes`. Hard-coding GUIDs is acceptable for well-known APIs (Graph appId is stable), but verify before hardcoding SharePoint's permission GUIDs.

### Anti-Patterns to Avoid

- **Creating AppRoleAssignment before ServicePrincipal exists:** SP must exist first — the assignment's `principalId` must resolve.
- **Swallowing rollback errors silently:** Log them as Warning — don't let rollback exceptions hide the original failure.
- **Storing appId in a separate config file:** Store on `TenantProfile` (already persisted to JSON via `ProfileService`).
- **Treating `GET /me/memberOf` 403 as an error:** It means the user doesn't have `Directory.Read.All` — treat as "not admin, show fallback."

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Token acquisition for Graph calls | Custom HTTP auth | `GraphClientFactory.CreateClientAsync` (already exists) | Handles silent→interactive fallback, shares MSAL cache |
| Persistent cache deletion | Manual file path manipulation | `MsalCacheHelper.UnregisterCache` + delete `msal_{clientId}.cache` | Cache path already known from `MsalClientFactory.CacheDirectory` |
| Dialog for manual fallback instructions | Custom dialog window | Inline panel in `ProfileManagementDialog` | Consistent with existing dialog-extension pattern (logo panel was added the same way) |

**Key insight:** The project already owns `GraphClientFactory` which handles the MSAL→Graph SDK bridge. Registration just calls Graph endpoints through that same factory. Zero new auth infrastructure needed.

---

## Common Pitfalls

### Pitfall 1: `/me/memberOf` Does Not Enumerate Transitive Role Memberships

**What goes wrong:** A user who is Global Admin through a nested group (rare but possible) won't appear in a direct `memberOf` query.

**Why it happens:** The `memberOf` endpoint returns direct memberships only by default.

**How to avoid:** Use `GET /me/transitiveMemberOf/microsoft.graph.directoryRole` for completeness. The roleTemplateId filter is the same.

**Warning signs:** Admin user reports "fallback instructions shown" even though they are confirmed Global Admin.

### Pitfall 2: Soft-Delete on Application Means the Name Is Reserved for 30 Days

**What goes wrong:** If the user registers, then removes, then tries to re-register with the same display name, the second `POST /applications` may succeed (display names are not unique) but the soft-deleted object still exists in the recycle bin.

**Why it happens:** Entra soft-deletes applications into a "deleted items" container for 30 days before permanent deletion.

**How to avoid:** Use a unique display name per registration (e.g., `"SharePoint Toolbox – {tenantDisplayName}"`). Document this behavior in the fallback instructions.

**Warning signs:** `DELETE /applications/{id}` succeeds but a second `POST /applications` still shows "409 conflict" in the portal.

### Pitfall 3: `RequiredResourceAccess` Does Not Grant Admin Consent

**What goes wrong:** The app is registered with the right scopes listed in the manifest, but the user is still prompted to consent on first use — or worse, consent is blocked for non-admins.

**Why it happens:** `RequiredResourceAccess` is the declared intent; actual grant requires the user to consent interactively OR an admin to post `AppRoleAssignment` for each app role.

**How to avoid:** After creating the app registration (APPREG-03), explicitly call `POST /servicePrincipals/{resourceId}/appRoleAssignedTo` for each delegated scope to pre-grant admin consent. This is what makes registration "atomic" — the user never sees a consent prompt.

**Warning signs:** First launch after registration opens a browser consent screen.

### Pitfall 4: ServicePrincipal Creation is Eventually Consistent

**What goes wrong:** Immediately querying the newly-created SP by filter right after creation returns 404 or empty.

**Why it happens:** Entra directory replication can take a few seconds.

**How to avoid:** Use the `id` returned from `POST /servicePrincipals` directly (no need to re-query). Never search by `appId` immediately after creation.

### Pitfall 5: MSAL Cache File Locked During Eviction

**What goes wrong:** `helper.UnregisterCache(...)` leaves the cache file on disk; deleting the file while the PCA still holds a handle causes `IOException`.

**Why it happens:** `MsalCacheHelper` keeps a file lock for cross-process safety.

**How to avoid:** Call `UnregisterCache` first (releases the lock), then delete the file. Or simply leave the file — after `RemoveAsync` on all accounts the file is written with an empty account list and is effectively harmless.

---

## Code Examples

### Create Application with Required Resource Access

```csharp
// Source: https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0
private static List<RequiredResourceAccess> BuildRequiredResourceAccess()
{
    // Microsoft Graph resource
    var graphAccess = new RequiredResourceAccess
    {
        ResourceAppId = "00000003-0000-0000-c000-000000000000", // Microsoft Graph
        ResourceAccess = new List<ResourceAccess>
        {
            new() { Id = Guid.Parse("e1fe6dd8-ba31-4d61-89e7-88639da4683d"), Type = "Scope" }, // User.Read
            new() { Id = Guid.Parse("a154be20-db9c-4678-8ab7-66f6cc099a59"), Type = "Scope" }, // User.Read.All (delegated)
            new() { Id = Guid.Parse("5b567255-7703-4780-807c-7be8301ae99b"), Type = "Scope" }, // Group.Read.All (delegated)
            new() { Id = Guid.Parse("06da0dbc-49e2-44d2-8312-53f166ab848a"), Type = "Scope" }, // Directory.Read.All (delegated)
        }
    };

    // SharePoint Online resource  
    var spoAccess = new RequiredResourceAccess
    {
        ResourceAppId = "00000003-0000-0ff1-ce00-000000000000", // SharePoint Online
        ResourceAccess = new List<ResourceAccess>
        {
            new() { Id = Guid.Parse("56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6"), Type = "Scope" }, // AllSites.FullControl (delegated)
        }
    };
    return new List<RequiredResourceAccess> { graphAccess, spoAccess };
}
```

**NOTE (LOW confidence on GUIDs):** The Graph delegated permission GUIDs above are well-known and stable. The SharePoint `AllSites.FullControl` GUID should be verified at plan time by querying `GET /servicePrincipals?$filter=appId eq '00000003-0000-0ff1-ce00-000000000000'&$select=oauth2PermissionScopes`. The Microsoft Graph GUIDs are HIGH confidence from official docs.

### Create ServicePrincipal

```csharp
// Source: https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals?view=graph-rest-1.0
var sp = await graphClient.ServicePrincipals.PostAsync(
    new ServicePrincipal { AppId = createdApp.AppId }, ct);
```

### Grant AppRole Assignment (Admin Consent)

```csharp
// Source: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
// Get resource SP id for Microsoft Graph
var graphSp = await graphClient.ServicePrincipals.GetAsync(r => {
    r.QueryParameters.Filter = "appId eq '00000003-0000-0000-c000-000000000000'";
    r.QueryParameters.Select = new[] { "id", "oauth2PermissionScopes" };
}, ct);
var graphResourceId = Guid.Parse(graphSp!.Value!.First().Id!);

// Grant delegated permission (oauth2PermissionGrant, not appRoleAssignment for delegated scopes)
await graphClient.Oauth2PermissionGrants.PostAsync(new OAuth2PermissionGrant
{
    ClientId = sp.Id,      // service principal Object ID of the new app
    ConsentType = "AllPrincipals",
    ResourceId = graphResourceId.ToString(),
    Scope = "User.Read User.Read.All Group.Read.All Directory.Read.All"
}, ct);
```

**Important distinction:** For delegated permissions (Type = "Scope"), use `POST /oauth2PermissionGrants` (admin consent for all users). For application permissions (Type = "Role"), use `POST /servicePrincipals/{resourceId}/appRoleAssignedTo`. The Toolbox uses interactive login (delegated flow), so use `oauth2PermissionGrants`.

### Delete Application

```csharp
// Source: https://learn.microsoft.com/en-us/graph/api/application-delete?view=graph-rest-1.0
// Can address by object ID or by appId
await graphClient.Applications[$"(appId='{profile.AppId}')"].DeleteAsync(ct);
```

### Clear MSAL Session

```csharp
// Source: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/clear-token-cache
var pca = await _msalFactory.GetOrCreateAsync(clientId);
var accounts = (await pca.GetAccountsAsync()).ToList();
while (accounts.Any())
{
    await pca.RemoveAsync(accounts.First());
    accounts = (await pca.GetAccountsAsync()).ToList();
}
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Azure AD Graph API (`graph.windows.net`) | Microsoft Graph v1.0 (`graph.microsoft.com`) | Deprecated 2023 | Use Graph SDK v5 exclusively |
| `oauth2PermissionGrants` for app roles | `appRoleAssignedTo` for application permissions, `oauth2PermissionGrants` for delegated | Graph v1.0 | Both still valid; use correct one per permission type |
| Interactive consent dialog for each scope | Pre-grant via `oauth2PermissionGrants` with `ConsentType=AllPrincipals` | Current | Eliminates user consent prompt on first run |

**Deprecated/outdated:**
- Azure AD Graph (`graph.windows.net`) endpoints: replaced entirely by Microsoft Graph.
- Listing roles via `GET /directoryRoles` (requires activating role first): use `transitiveMemberOf` on `/me` instead.

---

## Open Questions

1. **SharePoint `AllSites.FullControl` delegated permission GUID**
   - What we know: The permission exists and is required; the SPO app appId is `00000003-0000-0ff1-ce00-000000000000`
   - What's unclear: The exact GUID may vary; it must be confirmed by querying the SPO service principal at plan/implementation time
   - Recommendation: Planner should include a Wave 0 step to look up and hard-code the GUID from the target tenant, or query it dynamically at registration time

2. **`/me/memberOf` permission requirement**
   - What we know: Requires `Directory.Read.All` or `RoleManagement.Read.Directory`
   - What's unclear: Whether the `.default` scope grants this for a freshly registered app vs. an existing one
   - Recommendation: Treat 403 on the role check as "not admin" and show fallback — same user experience, no crash

3. **TenantProfile.AppId field and re-registration**
   - What we know: We need to store the registered `appId` on the profile to support removal
   - What's unclear: Whether the user wants to re-register on the same profile after removal
   - Recommendation: Null out `AppId` after successful removal; show "Register App" button when `AppId` is null

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | xUnit 2.9.3 |
| Config file | `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` |
| Quick run command | `dotnet test --filter "Category=Unit" --no-build` |
| Full suite command | `dotnet test --no-build` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| APPREG-01 | `RegisterAppCommand` exists on `ProfileManagementViewModel` | unit | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |
| APPREG-02 | `IsGlobalAdminAsync` returns false when Graph returns no matching role | unit | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |
| APPREG-02 | `IsGlobalAdminAsync` returns true when Global Admin role present | unit | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |
| APPREG-03 | Registration rollback calls delete when SP creation fails | unit (mock) | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |
| APPREG-03 | `AppRegistrationResult.Success` carries appId | unit | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |
| APPREG-04 | Fallback path returned when IsGlobalAdmin = false | unit | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |
| APPREG-05 | `RemoveAppCommand` calls delete on Graph and clears AppId | unit (mock) | `dotnet test --filter "FullyQualifiedName~ProfileManagementViewModelRegistrationTests" --no-build` | ❌ Wave 0 |
| APPREG-06 | Session and MSAL cleared after removal | unit (mock MsalFactory) | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |
| APPREG-06 | `TenantProfile.AppId` is null-able and round-trips via JSON | unit | `dotnet test --filter "FullyQualifiedName~AppRegistrationServiceTests" --no-build` | ❌ Wave 0 |

**Note:** Live Graph API calls (actual Entra tenant) are integration tests — skip them the same way other live SharePoint tests are skipped (`[Trait("Category","Integration")]`). All unit tests mock the `GraphServiceClient` or test pure model/logic in isolation.

### Sampling Rate

- **Per task commit:** `dotnet test --filter "Category=Unit" --no-build`
- **Per wave merge:** `dotnet test --no-build`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `SharepointToolbox.Tests/Services/AppRegistrationServiceTests.cs` — covers APPREG-02, APPREG-03, APPREG-04, APPREG-06
- [ ] `SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs` — covers APPREG-01, APPREG-05
- [ ] `SharepointToolbox/Core/Models/AppRegistrationResult.cs` — discriminated union model
- [ ] `SharepointToolbox/Services/IAppRegistrationService.cs` + `AppRegistrationService.cs` — interface + implementation stubs

---

## Sources

### Primary (HIGH confidence)

- [Microsoft Graph: Create application — POST /applications](https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0) — C# SDK v5 code confirmed
- [Microsoft Graph: Grant/revoke API permissions programmatically](https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph) — full sequential workflow with C# examples (updated 2026-03-21)
- [Microsoft Graph: Delete application](https://learn.microsoft.com/en-us/graph/api/application-delete?view=graph-rest-1.0) — `DELETE /applications/{id}` endpoint
- [Microsoft Graph: Delete servicePrincipal](https://learn.microsoft.com/en-us/graph/api/serviceprincipal-delete?view=graph-rest-1.0) — cascade behavior confirmed
- [MSAL.NET: Clear the token cache](https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/clear-token-cache) — `RemoveAsync` loop pattern

### Secondary (MEDIUM confidence)

- [Microsoft Q&A: Check admin status via Graph API](https://learn.microsoft.com/en-us/answers/questions/67411/graph-api-best-way-to-check-admin-status) — confirms `/me/memberOf` approach
- [CIAOPS: Getting Global Administrators using the Graph](https://blog.ciaops.com/2024/07/27/getting-global-administrators-using-the-graph/) — confirms `roleTemplateId = 62e90394-69f5-4237-9190-012177145e10`
- [Microsoft Graph: List directoryRoles](https://learn.microsoft.com/en-us/graph/api/directoryrole-list?view=graph-rest-1.0) — role template ID source

### Tertiary (LOW confidence)

- SharePoint Online `AllSites.FullControl` delegated permission GUID — not independently verified; must be confirmed by querying the SPO service principal at plan time.

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all libraries already in project, no new dependencies
- Architecture: HIGH — Graph SDK v5 code patterns confirmed from official docs (updated 2026)
- Global Admin detection: HIGH — roleTemplateId confirmed from multiple sources
- Registration flow: HIGH — sequential 4-step pattern confirmed from official MS Learn
- Permission GUIDs (Graph): MEDIUM — well-known stable IDs, but verify at implementation time
- Permission GUIDs (SharePoint): LOW — must be queried dynamically or confirmed at plan time
- Token cache eviction: HIGH — official MSAL docs

**Research date:** 2026-04-09
**Valid until:** 2026-05-09 (Graph API and MSAL patterns are stable)
