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:
2026-06-02 14:16:12 +02:00
parent b7061867f1
commit 57f5239cfc
17 changed files with 306 additions and 44 deletions
+106
View File
@@ -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}";
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Wraps a SharePoint operation so that, when the "Auto-elevate ownership when permission
/// scan is denied" setting is enabled, an access-denied failure triggers taking
/// site-collection admin ownership of the failing site and re-running the operation once.
/// When the setting is off (or elevation is impossible/unsuccessful) the original
/// access-denied error propagates unchanged.
/// </summary>
public interface IElevationCoordinator
{
Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> operation, CancellationToken ct);
Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken ct);
}
+1 -2
View File
@@ -11,6 +11,5 @@ public interface IUserAccessAuditService
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
CancellationToken ct);
}
+38 -6
View File
@@ -122,11 +122,39 @@ public class StorageService : IStorageService
string url = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
try
{
// Enumerate attachment files directly instead of reading folder.StorageMetrics (admin-gated → 403
// for delegated non-admin tokens). SP list attachments are flat: /Attachments/{itemId}/<file>.
// Pass 1: item folders + any files at the Attachments root. Pass 2: batch-load every item folder's
// files in a single round trip. Bounded to 2 server calls regardless of attachment count.
var folder = ctx.Web.GetFolderByServerRelativeUrl(url);
ctx.Load(folder, f => f.Exists, f => f.StorageMetrics, f => f.TimeLastModified, f => f.ServerRelativeUrl);
ctx.Load(folder, f => f.Exists);
ctx.Load(folder.Folders, fs => fs.Include(f => f.ServerRelativeUrl));
ctx.Load(folder.Files, fs => fs.Include(f => f.Length, f => f.TimeLastModified));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0) return null;
return new StorageNode { Name = $"[Attachments] {list.Title}", Url = ctx.Url.TrimEnd('/') + url, SiteTitle = siteTitle, Library = list.Title, Kind = StorageNodeKind.ListAttachments, TotalSizeBytes = folder.StorageMetrics.TotalSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, TotalFileCount = folder.StorageMetrics.TotalFileCount, LastModified = folder.StorageMetrics.LastModified > DateTime.MinValue ? folder.StorageMetrics.LastModified : (DateTime?)null };
if (!folder.Exists) return null;
long size = 0; int count = 0; DateTime? last = null;
void Accumulate(IEnumerable<Microsoft.SharePoint.Client.File> files)
{
foreach (var f in files)
{
size += f.Length; count++;
if (f.TimeLastModified > DateTime.MinValue && (last is null || f.TimeLastModified > last)) last = f.TimeLastModified;
}
}
Accumulate(folder.Files);
var itemFolders = folder.Folders.ToList();
if (itemFolders.Count > 0)
{
foreach (var itemFolder in itemFolders)
ctx.Load(itemFolder.Files, fs => fs.Include(f => f.Length, f => f.TimeLastModified));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var itemFolder in itemFolders) Accumulate(itemFolder.Files);
}
if (count == 0) return null;
return new StorageNode { Name = $"[Attachments] {list.Title}", Url = ctx.Url.TrimEnd('/') + url, SiteTitle = siteTitle, Library = list.Title, Kind = StorageNodeKind.ListAttachments, TotalSizeBytes = size, FileStreamSizeBytes = size, TotalFileCount = count, LastModified = last };
}
catch { return null; }
}
@@ -289,10 +317,14 @@ public class StorageService : IStorageService
{
ct.ThrowIfCancellationRequested();
var folder = ctx.Web.GetFolderByServerRelativeUrl(serverRelativeUrl);
ctx.Load(folder, f => f.StorageMetrics, f => f.TimeLastModified, f => f.ServerRelativeUrl, f => f.Name);
// NOTE: deliberately NOT loading folder.StorageMetrics — that property is site-collection-admin
// gated in SharePoint Online and returns (403) FORBIDDEN for delegated non-admin tokens. Its values
// are discarded anyway: callers run ResetNodeCounts + BackfillLibFromFilesAsync, which recompute all
// sizes/counts from per-file CAML enumeration. Only TimeLastModified is kept.
ctx.Load(folder, f => f.TimeLastModified, f => f.ServerRelativeUrl, f => f.Name);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue ? folder.StorageMetrics.LastModified : folder.TimeLastModified > DateTime.MinValue ? folder.TimeLastModified : (DateTime?)null;
return new StorageNode { Name = name, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, SiteTitle = siteTitle, Library = library, Kind = kind, TotalSizeBytes = folder.StorageMetrics.TotalSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, TotalFileCount = folder.StorageMetrics.TotalFileCount, LastModified = lastMod, IndentLevel = indentLevel, Children = new List<StorageNode>() };
DateTime? lastMod = folder.TimeLastModified > DateTime.MinValue ? folder.TimeLastModified : (DateTime?)null;
return new StorageNode { Name = name, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, SiteTitle = siteTitle, Library = library, Kind = kind, TotalSizeBytes = 0, FileStreamSizeBytes = 0, TotalFileCount = 0, LastModified = lastMod, IndentLevel = indentLevel, Children = new List<StorageNode>() };
}
private static async Task CollectSubfoldersAsync(ClientContext ctx, List list, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, StorageNodeKind kind, IProgress<OperationProgress> progress, CancellationToken ct)
+10 -15
View File
@@ -6,15 +6,17 @@ namespace SharepointToolbox.Web.Services;
public class UserAccessAuditService : IUserAccessAuditService
{
private readonly IPermissionsService _permissionsService;
private readonly IElevationCoordinator _elevation;
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
{
"Full Control", "Site Collection Administrator"
};
public UserAccessAuditService(IPermissionsService permissionsService)
public UserAccessAuditService(IPermissionsService permissionsService, IElevationCoordinator elevation)
{
_permissionsService = permissionsService;
_elevation = elevation;
}
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
@@ -24,8 +26,7 @@ public class UserAccessAuditService : IUserAccessAuditService
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
CancellationToken ct)
{
var targets = targetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
@@ -50,19 +51,13 @@ public class UserAccessAuditService : IUserAccessAuditService
Name = site.Title
};
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
IReadOnlyList<PermissionEntry> permEntries;
try
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
var permEntries = await _elevation.RunAsync(async c =>
{
permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (onAccessDenied != null)
{
var elevated = await onAccessDenied(site.Url, ct);
if (!elevated) throw;
var retryCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
permEntries = await _permissionsService.ScanSiteAsync(retryCtx, options, progress, ct);
}
var ctx = await sessionManager.GetOrCreateContextAsync(profile, c);
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
}, ct);
allEntries.AddRange(TransformEntries(permEntries, targets, site));
}