Files
Sharepoint-Toolbox/.planning/phases/19-app-registration-removal/19-RESEARCH.md
2026-04-09 14:43:00 +02:00

483 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)