Wire auto-elevate ownership across all SharePoint operations
The "Auto-elevate ownership when permission scan is denied" setting was dead code: the toggle was persisted but never read, the audit flow never passed its onAccessDenied callback, and EnrichException wrapped every CSOM error (including ServerUnauthorizedAccessException) into a generic InvalidOperationException so the access-denied catch could never match. Centralize elevation instead of per-call-site callbacks: - Throw typed SharePointAccessDeniedException from EnrichException on access-denied, preserving the failing site URL and enriched diagnostic. - Add scoped IElevationCoordinator that catches it, and when AutoTakeOwnership is enabled takes site-collection admin via the tenant admin endpoint and retries the operation once. Per-site dedupe prevents loops; admin-host denials are not treated as ownership issues. Retry is safe because each wrapped operation closure re-issues its own CSOM loads. - Wrap all site-scoped operations (Storage, Permissions, Duplicates, Search, VersionCleanup, FolderStructure, BulkMembers, FileTransfer, Templates) and the UserAccessAudit per-site scan in the coordinator. - Drop the unused onAccessDenied parameter from IUserAccessAuditService. Elevation still requires SharePoint tenant admin rights on the signed-in account; the coordinator surfaces a clear message when that is missing. Also keeps the prior StorageService change that avoids admin-gated folder.StorageMetrics (403 for delegated non-admin tokens). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using Serilog;
|
||||
using SharepointToolbox.Web.Core.Helpers;
|
||||
using SharepointToolbox.Web.Services.Session;
|
||||
|
||||
namespace SharepointToolbox.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped per Blazor circuit. Catches <see cref="SharePointAccessDeniedException"/> from any
|
||||
/// wrapped operation and, when AutoTakeOwnership is enabled, grants the current user
|
||||
/// site-collection admin on the failing site (via the tenant admin endpoint) before retrying.
|
||||
///
|
||||
/// Retry is safe because the wrapped operation closure re-issues its own CSOM loads on each
|
||||
/// attempt; the granted permission is server-side and takes effect for the existing delegated
|
||||
/// token without re-authentication. Each site is elevated at most once per circuit to prevent loops.
|
||||
/// </summary>
|
||||
public class ElevationCoordinator : IElevationCoordinator
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IOwnershipElevationService _ownership;
|
||||
private readonly IUserSessionService _session;
|
||||
private readonly HashSet<string> _elevatedSites = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ElevationCoordinator(
|
||||
ISessionManager sessionManager,
|
||||
IOwnershipElevationService ownership,
|
||||
IUserSessionService session)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_ownership = ownership;
|
||||
_session = session;
|
||||
}
|
||||
|
||||
public async Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken ct) =>
|
||||
await RunAsync<object?>(async c => { await operation(c); return null; }, ct);
|
||||
|
||||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> operation, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await operation(ct);
|
||||
}
|
||||
catch (SharePointAccessDeniedException ex)
|
||||
{
|
||||
if (!_session.Settings.AutoTakeOwnership)
|
||||
throw;
|
||||
|
||||
var siteUrl = ex.SiteUrl.TrimEnd('/');
|
||||
var key = siteUrl.ToLowerInvariant();
|
||||
|
||||
// Already elevated this site and still denied → elevation can't fix it. Surface original.
|
||||
if (_elevatedSites.Contains(key))
|
||||
throw;
|
||||
|
||||
// Elevation targets the tenant admin endpoint; denials there aren't site-ownership issues.
|
||||
if (siteUrl.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
||||
throw;
|
||||
|
||||
await ElevateAsync(siteUrl, ct);
|
||||
_elevatedSites.Add(key);
|
||||
|
||||
// Re-run once. The closure re-issues its loads; the now-granted admin right applies.
|
||||
return await operation(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ElevateAsync(string siteUrl, CancellationToken ct)
|
||||
{
|
||||
var profile = _session.CurrentProfile
|
||||
?? throw new InvalidOperationException("Cannot elevate ownership: no active profile.");
|
||||
|
||||
var adminProfile = new Core.Models.TenantProfile
|
||||
{
|
||||
Id = profile.Id,
|
||||
Name = profile.Name,
|
||||
TenantUrl = BuildAdminUrl(siteUrl),
|
||||
TenantId = profile.TenantId,
|
||||
ClientId = profile.ClientId,
|
||||
ClientLogo = profile.ClientLogo,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
||||
Log.Information("Auto-elevating site-collection admin ownership for {Site} via {Admin}",
|
||||
siteUrl, adminProfile.TenantUrl);
|
||||
// loginName empty → ElevateAsync resolves the current (delegated) user from the admin context.
|
||||
await _ownership.ElevateAsync(adminCtx, siteUrl, loginName: string.Empty, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Auto-elevate ownership failed for {siteUrl}. Granting site-collection admin requires " +
|
||||
$"SharePoint tenant administrator rights on the signed-in account. ({ex.Message})", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// https://abcube.sharepoint.com/sites/Foo → https://abcube-admin.sharepoint.com
|
||||
private static string BuildAdminUrl(string siteUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri))
|
||||
return siteUrl;
|
||||
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
return $"{uri.Scheme}://{adminHost}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user