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