docs(phase-19): research app registration & removal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-09 14:43:00 +02:00
parent 9549314f22
commit bb3ba7b177

View File

@@ -0,0 +1,482 @@
# 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)