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
This commit is contained in:
33
SharepointToolbox/Core/Models/AppRegistrationResult.cs
Normal file
33
SharepointToolbox/Core/Models/AppRegistrationResult.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discriminated result type for app registration operations.
|
||||||
|
/// Use the static factory methods to construct instances.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registration succeeded; carries the newly-created appId.</summary>
|
||||||
|
public static AppRegistrationResult Success(string appId) =>
|
||||||
|
new(isSuccess: true, isFallback: false, appId: appId, errorMessage: null);
|
||||||
|
|
||||||
|
/// <summary>Registration failed; carries an error message.</summary>
|
||||||
|
public static AppRegistrationResult Failure(string errorMessage) =>
|
||||||
|
new(isSuccess: false, isFallback: false, appId: null, errorMessage: errorMessage);
|
||||||
|
|
||||||
|
/// <summary>User lacks the required permissions — caller should show fallback instructions.</summary>
|
||||||
|
public static AppRegistrationResult FallbackRequired() =>
|
||||||
|
new(isSuccess: false, isFallback: true, appId: null, errorMessage: null);
|
||||||
|
}
|
||||||
@@ -6,4 +6,11 @@ public class TenantProfile
|
|||||||
public string TenantUrl { get; set; } = string.Empty;
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
public string ClientId { get; set; } = string.Empty;
|
public string ClientId { get; set; } = string.Empty;
|
||||||
public LogoData? ClientLogo { get; set; }
|
public LogoData? ClientLogo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string? AppId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
229
SharepointToolbox/Services/AppRegistrationService.cs
Normal file
229
SharepointToolbox/Services/AppRegistrationService.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages Azure AD app registration and removal using the Microsoft Graph API.
|
||||||
|
/// All operations use <see cref="GraphClientFactory"/> for token acquisition.
|
||||||
|
/// </summary>
|
||||||
|
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<AppRegistrationService> _logger;
|
||||||
|
|
||||||
|
public AppRegistrationService(
|
||||||
|
AppGraphClientFactory graphFactory,
|
||||||
|
AppMsalClientFactory msalFactory,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
ILogger<AppRegistrationService> logger)
|
||||||
|
{
|
||||||
|
_graphFactory = graphFactory;
|
||||||
|
_msalFactory = msalFactory;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<bool> 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<DirectoryRole>()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<AppRegistrationResult> 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<string>
|
||||||
|
{
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the RequiredResourceAccess list for the app registration manifest.
|
||||||
|
/// Declares delegated permissions for Microsoft Graph and SharePoint Online.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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
|
||||||
|
/// </remarks>
|
||||||
|
internal static List<RequiredResourceAccess> BuildRequiredResourceAccess()
|
||||||
|
{
|
||||||
|
var graphAccess = new RequiredResourceAccess
|
||||||
|
{
|
||||||
|
ResourceAppId = GraphAppId,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// LOW confidence GUID — verify against live tenant before deploying
|
||||||
|
var spoAccess = new RequiredResourceAccess
|
||||||
|
{
|
||||||
|
ResourceAppId = SharePointAppId,
|
||||||
|
ResourceAccess = new List<ResourceAccess>
|
||||||
|
{
|
||||||
|
new() { Id = Guid.Parse("56680e0d-d2a3-4ae1-80d8-3c4a5c70c4a6"), Type = "Scope" }, // AllSites.FullControl (delegated) — LOW confidence GUID
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new List<RequiredResourceAccess> { graphAccess, spoAccess };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
SharepointToolbox/Services/IAppRegistrationService.cs
Normal file
35
SharepointToolbox/Services/IAppRegistrationService.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages Azure AD app registration and removal for a target tenant.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAppRegistrationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an Azure AD Application + ServicePrincipal + OAuth2PermissionGrants
|
||||||
|
/// atomically. On any intermediate failure the Application is deleted before
|
||||||
|
/// returning a Failure result (best-effort rollback).
|
||||||
|
/// </summary>
|
||||||
|
Task<AppRegistrationResult> RegisterAsync(string clientId, string tenantDisplayName, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the registered application by its appId.
|
||||||
|
/// Logs a warning on failure but does not throw.
|
||||||
|
/// </summary>
|
||||||
|
Task RemoveAsync(string clientId, string appId, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the live SessionManager context, evicts all in-memory MSAL accounts,
|
||||||
|
/// and unregisters the persistent token cache for the given clientId.
|
||||||
|
/// </summary>
|
||||||
|
Task ClearMsalSessionAsync(string clientId, string tenantUrl);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user