From 93dbb8c5b03579e484a5e44bed2b5ac53fbcc1e2 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 9 Apr 2026 15:12:51 +0200 Subject: [PATCH] feat(19-01): add AppRegistrationService with rollback, model, and interface - AppRegistrationResult discriminated result (Success/Failure/FallbackRequired) - TenantProfile.AppId nullable string for storing registered app ID - IAppRegistrationService interface (IsGlobalAdminAsync, RegisterAsync, RemoveAsync, ClearMsalSessionAsync) - AppRegistrationService: sequential registration with rollback, transitiveMemberOf admin check, MSAL eviction --- .../Core/Models/AppRegistrationResult.cs | 33 +++ .../Core/Models/TenantProfile.cs | 7 + .../Services/AppRegistrationService.cs | 229 ++++++++++++++++++ .../Services/IAppRegistrationService.cs | 35 +++ 4 files changed, 304 insertions(+) create mode 100644 SharepointToolbox/Core/Models/AppRegistrationResult.cs create mode 100644 SharepointToolbox/Services/AppRegistrationService.cs create mode 100644 SharepointToolbox/Services/IAppRegistrationService.cs diff --git a/SharepointToolbox/Core/Models/AppRegistrationResult.cs b/SharepointToolbox/Core/Models/AppRegistrationResult.cs new file mode 100644 index 0000000..5b63b3a --- /dev/null +++ b/SharepointToolbox/Core/Models/AppRegistrationResult.cs @@ -0,0 +1,33 @@ +namespace SharepointToolbox.Core.Models; + +/// +/// Discriminated result type for app registration operations. +/// Use the static factory methods to construct instances. +/// +public class AppRegistrationResult +{ + public bool IsSuccess { get; } + public bool IsFallback { get; } + public string? AppId { get; } + public string? ErrorMessage { get; } + + private AppRegistrationResult(bool isSuccess, bool isFallback, string? appId, string? errorMessage) + { + IsSuccess = isSuccess; + IsFallback = isFallback; + AppId = appId; + ErrorMessage = errorMessage; + } + + /// Registration succeeded; carries the newly-created appId. + public static AppRegistrationResult Success(string appId) => + new(isSuccess: true, isFallback: false, appId: appId, errorMessage: null); + + /// Registration failed; carries an error message. + public static AppRegistrationResult Failure(string errorMessage) => + new(isSuccess: false, isFallback: false, appId: null, errorMessage: errorMessage); + + /// User lacks the required permissions — caller should show fallback instructions. + public static AppRegistrationResult FallbackRequired() => + new(isSuccess: false, isFallback: true, appId: null, errorMessage: null); +} diff --git a/SharepointToolbox/Core/Models/TenantProfile.cs b/SharepointToolbox/Core/Models/TenantProfile.cs index f20663f..dc1517f 100644 --- a/SharepointToolbox/Core/Models/TenantProfile.cs +++ b/SharepointToolbox/Core/Models/TenantProfile.cs @@ -6,4 +6,11 @@ public class TenantProfile public string TenantUrl { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty; public LogoData? ClientLogo { get; set; } + + /// + /// The Azure AD application (client) ID registered for this tenant profile. + /// Null when no app registration has been performed yet. + /// Cleared to null after successful removal. + /// + public string? AppId { get; set; } } diff --git a/SharepointToolbox/Services/AppRegistrationService.cs b/SharepointToolbox/Services/AppRegistrationService.cs new file mode 100644 index 0000000..b050331 --- /dev/null +++ b/SharepointToolbox/Services/AppRegistrationService.cs @@ -0,0 +1,229 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Graph; +using Microsoft.Graph.Models; +using SharepointToolbox.Core.Models; +using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; +using AppMsalClientFactory = SharepointToolbox.Infrastructure.Auth.MsalClientFactory; + +namespace SharepointToolbox.Services; + +/// +/// Manages Azure AD app registration and removal using the Microsoft Graph API. +/// All operations use for token acquisition. +/// +public class AppRegistrationService : IAppRegistrationService +{ + private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10"; + private const string GraphAppId = "00000003-0000-0000-c000-000000000000"; + private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000"; + + private readonly AppGraphClientFactory _graphFactory; + private readonly AppMsalClientFactory _msalFactory; + private readonly ISessionManager _sessionManager; + private readonly ILogger _logger; + + public AppRegistrationService( + AppGraphClientFactory graphFactory, + AppMsalClientFactory msalFactory, + ISessionManager sessionManager, + ILogger logger) + { + _graphFactory = graphFactory; + _msalFactory = msalFactory; + _sessionManager = sessionManager; + _logger = logger; + } + + /// + public async Task IsGlobalAdminAsync(string clientId, CancellationToken ct) + { + try + { + var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); + var roles = await graphClient.Me.TransitiveMemberOf.GetAsync(req => + { + req.QueryParameters.Filter = "isof('microsoft.graph.directoryRole')"; + }, ct); + + return roles?.Value? + .OfType() + .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId, + StringComparison.OrdinalIgnoreCase)) ?? false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "IsGlobalAdminAsync failed — treating as non-admin. ClientId={ClientId}", clientId); + return false; + } + } + + /// + public async Task RegisterAsync( + string clientId, + string tenantDisplayName, + CancellationToken ct) + { + Application? createdApp = null; + try + { + var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); + + // Step 1: Create Application object + var appRequest = new Application + { + DisplayName = $"SharePoint Toolbox - {tenantDisplayName}", + 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, cancellationToken: ct); + _logger.LogInformation("Created Application {AppId} ({ObjectId})", createdApp!.AppId, createdApp.Id); + + // Step 2: Create ServicePrincipal + var sp = await graphClient.ServicePrincipals.PostAsync( + new ServicePrincipal { AppId = createdApp.AppId }, cancellationToken: ct); + _logger.LogInformation("Created ServicePrincipal {SpId}", sp!.Id); + + // Step 3a: Look up Microsoft Graph resource SP + var graphSpCollection = await graphClient.ServicePrincipals.GetAsync(req => + { + req.QueryParameters.Filter = $"appId eq '{GraphAppId}'"; + req.QueryParameters.Select = new[] { "id" }; + }, ct); + var graphResourceId = graphSpCollection?.Value?.FirstOrDefault()?.Id + ?? throw new InvalidOperationException("Microsoft Graph service principal not found."); + + // Step 3b: Look up SharePoint Online resource SP + var spoSpCollection = await graphClient.ServicePrincipals.GetAsync(req => + { + req.QueryParameters.Filter = $"appId eq '{SharePointAppId}'"; + req.QueryParameters.Select = new[] { "id" }; + }, ct); + var spoResourceId = spoSpCollection?.Value?.FirstOrDefault()?.Id + ?? throw new InvalidOperationException("SharePoint Online service principal not found."); + + // Step 4a: Grant delegated permissions for Microsoft Graph + await graphClient.Oauth2PermissionGrants.PostAsync(new OAuth2PermissionGrant + { + ClientId = sp.Id, + ConsentType = "AllPrincipals", + ResourceId = graphResourceId, + Scope = "User.Read User.Read.All Group.Read.All Directory.Read.All" + }, cancellationToken: ct); + + // Step 4b: Grant delegated permissions for SharePoint Online + await graphClient.Oauth2PermissionGrants.PostAsync(new OAuth2PermissionGrant + { + ClientId = sp.Id, + ConsentType = "AllPrincipals", + ResourceId = spoResourceId, + Scope = "AllSites.FullControl" + }, cancellationToken: ct); + + _logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId); + return AppRegistrationResult.Success(createdApp.AppId!); + } + catch (Exception ex) + { + _logger.LogError(ex, "RegisterAsync failed. Attempting rollback."); + + if (createdApp?.Id is not null) + { + try + { + var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); + await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct); + _logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id); + } + catch (Exception rollbackEx) + { + _logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id); + } + } + + return AppRegistrationResult.Failure(ex.Message); + } + } + + /// + public async Task RemoveAsync(string clientId, string appId, CancellationToken ct) + { + try + { + var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); + await graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct); + _logger.LogInformation("Removed Application appId={AppId}", appId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "RemoveAsync failed for appId={AppId}", appId); + } + } + + /// + public async Task ClearMsalSessionAsync(string clientId, string tenantUrl) + { + // 1. Clear live ClientContext from SessionManager + await _sessionManager.ClearSessionAsync(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 (releases file lock, harmless if not registered) + var helper = _msalFactory.GetCacheHelper(clientId); + helper.UnregisterCache(pca.UserTokenCache); + + _logger.LogInformation("MSAL session cleared for clientId={ClientId}", clientId); + } + + /// + /// Builds the RequiredResourceAccess list for the app registration manifest. + /// Declares delegated permissions for Microsoft Graph and SharePoint Online. + /// + /// + /// Graph permission GUIDs are HIGH confidence (stable, from official Microsoft docs). + /// SharePoint AllSites.FullControl GUID (56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6) is LOW confidence + /// and should be verified against a live tenant by querying: + /// GET /servicePrincipals?$filter=appId eq '00000003-0000-0ff1-ce00-000000000000'&$select=oauth2PermissionScopes + /// + internal static List BuildRequiredResourceAccess() + { + var graphAccess = new RequiredResourceAccess + { + ResourceAppId = GraphAppId, + 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) + } + }; + + // LOW confidence GUID — verify against live tenant before deploying + var spoAccess = new RequiredResourceAccess + { + ResourceAppId = SharePointAppId, + ResourceAccess = new List + { + new() { Id = Guid.Parse("56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6"), Type = "Scope" }, // AllSites.FullControl (delegated) — LOW confidence GUID + } + }; + + return new List { graphAccess, spoAccess }; + } +} diff --git a/SharepointToolbox/Services/IAppRegistrationService.cs b/SharepointToolbox/Services/IAppRegistrationService.cs new file mode 100644 index 0000000..85f6e7f --- /dev/null +++ b/SharepointToolbox/Services/IAppRegistrationService.cs @@ -0,0 +1,35 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +/// +/// Manages Azure AD app registration and removal for a target tenant. +/// +public interface IAppRegistrationService +{ + /// + /// Returns true if the currently-authenticated user has the Global Administrator + /// directory role (checked via transitiveMemberOf for nested-group coverage). + /// Returns false on any failure, including 403, rather than throwing. + /// + Task IsGlobalAdminAsync(string clientId, CancellationToken ct); + + /// + /// Creates an Azure AD Application + ServicePrincipal + OAuth2PermissionGrants + /// atomically. On any intermediate failure the Application is deleted before + /// returning a Failure result (best-effort rollback). + /// + Task RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct); + + /// + /// Deletes the registered application by its appId. + /// Logs a warning on failure but does not throw. + /// + Task RemoveAsync(string clientId, string appId, CancellationToken ct); + + /// + /// Clears the live SessionManager context, evicts all in-memory MSAL accounts, + /// and unregisters the persistent token cache for the given clientId. + /// + Task ClearMsalSessionAsync(string clientId, string tenantUrl); +}