# 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 | 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` | --- ## 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 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 { "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 IsGlobalAdminAsync(CancellationToken ct) { var roles = await _graphClient.Me .MemberOf .GetAsync(r => r.QueryParameters.Filter = $"isof('microsoft.graph.directoryRole')", ct); return roles?.Value? .OfType() .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 BuildRequiredResourceAccess() { // Microsoft Graph resource var graphAccess = new RequiredResourceAccess { ResourceAppId = "00000003-0000-0000-c000-000000000000", // Microsoft Graph ResourceAccess = new List { 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 { new() { Id = Guid.Parse("56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6"), Type = "Scope" }, // AllSites.FullControl (delegated) } }; return new List { 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)