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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user