docs(phase-19): research app registration & removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
482
.planning/phases/19-app-registration-removal/19-RESEARCH.md
Normal file
482
.planning/phases/19-app-registration-removal/19-RESEARCH.md
Normal 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)
|
||||||
Reference in New Issue
Block a user