diff --git a/Components/Pages/BulkMembers.razor b/Components/Pages/BulkMembers.razor index 932ed2f..74d9c51 100644 --- a/Components/Pages/BulkMembers.razor +++ b/Components/Pages/BulkMembers.razor @@ -3,6 +3,7 @@ @inject IUserSessionService Session @inject IUserContextAccessor UserContext @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject IBulkMemberService BulkSvc @inject ICsvValidationService CsvValidation @inject BulkResultCsvExportService ExportSvc @@ -97,8 +98,11 @@ var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); - _summary = await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, _cts.Token); + _summary = await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, c); + }, _cts.Token); _status = $"Complete: {_summary.SuccessCount} added, {_summary.FailedCount} failed."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Components/Pages/Duplicates.razor b/Components/Pages/Duplicates.razor index cfd1566..7eff966 100644 --- a/Components/Pages/Duplicates.razor +++ b/Components/Pages/Duplicates.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @inject IUserSessionService Session @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject IDuplicatesService DupSvc @inject DuplicatesCsvExportService CsvExport @inject DuplicatesHtmlExportService HtmlExport @@ -95,9 +96,12 @@ var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull()); - _results = (await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, _cts.Token)).ToList(); + _results = (await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, c); + }, _cts.Token)).ToList(); _status = $"Found {_results.Count} duplicate groups."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Components/Pages/FileTransfer.razor b/Components/Pages/FileTransfer.razor index 82c8b24..b332222 100644 --- a/Components/Pages/FileTransfer.razor +++ b/Components/Pages/FileTransfer.razor @@ -3,6 +3,7 @@ @inject IUserSessionService Session @inject IUserContextAccessor UserContext @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject IFileTransferService TransferSvc @rendermode InteractiveServer @@ -119,8 +120,6 @@ { var srcUrl = string.IsNullOrWhiteSpace(_srcSiteUrl) ? Session.CurrentProfile!.TenantUrl : _srcSiteUrl.Trim(); var dstUrl = string.IsNullOrWhiteSpace(_dstSiteUrl) ? Session.CurrentProfile!.TenantUrl : _dstSiteUrl.Trim(); - var srcCtx = await SessionMgr.GetOrCreateContextAsync(srcUrl, Session.CurrentProfile!, _cts.Token); - var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, _cts.Token); var job = new TransferJob { SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder, @@ -129,7 +128,13 @@ ConflictPolicy = _conflict == "Overwrite" ? ConflictPolicy.Overwrite : _conflict == "Rename" ? ConflictPolicy.Rename : ConflictPolicy.Skip, IncludeSourceFolder = _includeSourceFolder }; - _summary = await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, _cts.Token); + _summary = await Elevation.RunAsync(async c => + { + // Closure rebuilds both contexts each attempt so an elevated retry re-issues cleanly. + var srcCtx = await SessionMgr.GetOrCreateContextAsync(srcUrl, Session.CurrentProfile!, c); + var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, c); + return await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, c); + }, _cts.Token); _status = $"Complete: {_summary.SuccessCount} transferred."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Components/Pages/FolderStructure.razor b/Components/Pages/FolderStructure.razor index e637d73..d5e2219 100644 --- a/Components/Pages/FolderStructure.razor +++ b/Components/Pages/FolderStructure.razor @@ -3,6 +3,7 @@ @inject IUserSessionService Session @inject IUserContextAccessor UserContext @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject IFolderStructureService FolderSvc @inject ICsvValidationService CsvValidation @rendermode InteractiveServer @@ -78,8 +79,11 @@ var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); - _summary = await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, _cts.Token); + _summary = await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, c); + }, _cts.Token); _status = $"Complete: {_summary.SuccessCount} folders created."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Components/Pages/Permissions.razor b/Components/Pages/Permissions.razor index ec0820d..168f8f0 100644 --- a/Components/Pages/Permissions.razor +++ b/Components/Pages/Permissions.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @inject IUserSessionService Session @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject IPermissionsService PermSvc @inject CsvExportService CsvExport @inject HtmlExportService HtmlExport @@ -109,9 +110,12 @@ var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites); - _results = (await PermSvc.ScanSiteAsync(ctx, opts, progress, _cts.Token)).ToList(); + _results = (await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await PermSvc.ScanSiteAsync(ctx, opts, progress, c); + }, _cts.Token)).ToList(); _status = $"Scan complete: {_results.Count} entries found."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Components/Pages/Search.razor b/Components/Pages/Search.razor index f80ebaa..e856345 100644 --- a/Components/Pages/Search.razor +++ b/Components/Pages/Search.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @inject IUserSessionService Session @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject ISearchService SearchSvc @inject SearchCsvExportService CsvExport @inject SearchHtmlExportService HtmlExport @@ -107,10 +108,13 @@ var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, siteUrl); - _results = (await SearchSvc.SearchFilesAsync(ctx, opts, progress, _cts.Token)).ToList(); + _results = (await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await SearchSvc.SearchFilesAsync(ctx, opts, progress, c); + }, _cts.Token)).ToList(); _status = $"Found {_results.Count} files."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Components/Pages/Storage.razor b/Components/Pages/Storage.razor index b7f272f..61674b1 100644 --- a/Components/Pages/Storage.razor +++ b/Components/Pages/Storage.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @inject IUserSessionService Session @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject IStorageService StorageSvc @inject StorageCsvExportService CsvExport @inject StorageHtmlExportService HtmlExport @@ -93,9 +94,12 @@ var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin); - _results = (await StorageSvc.CollectStorageAsync(ctx, opts, progress, _cts.Token)).ToList(); + _results = (await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await StorageSvc.CollectStorageAsync(ctx, opts, progress, c); + }, _cts.Token)).ToList(); _status = $"Complete: {_results.Count} nodes."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Components/Pages/Templates.razor b/Components/Pages/Templates.razor index 0c488d8..06ee1b3 100644 --- a/Components/Pages/Templates.razor +++ b/Components/Pages/Templates.razor @@ -3,6 +3,7 @@ @inject IUserSessionService Session @inject IUserContextAccessor UserContext @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject ITemplateService TemplateSvc @inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo @rendermode InteractiveServer @@ -108,9 +109,12 @@ var progress = new Progress(p => { _status = p.Message; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); var opts = new SiteTemplateOptions { CaptureLibraries = _capLibraries, CaptureFolders = _capFolders, CapturePermissionGroups = _capGroups }; - var template = await TemplateSvc.CaptureTemplateAsync(ctx, opts, progress, _cts.Token); + var template = await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await TemplateSvc.CaptureTemplateAsync(ctx, opts, progress, c); + }, _cts.Token); template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName; await TemplateRepo.SaveAsync(template); _templates = (await TemplateRepo.GetAllAsync()).ToList(); diff --git a/Components/Pages/VersionCleanup.razor b/Components/Pages/VersionCleanup.razor index 3f6b126..fe4efab 100644 --- a/Components/Pages/VersionCleanup.razor +++ b/Components/Pages/VersionCleanup.razor @@ -3,6 +3,7 @@ @inject IUserSessionService Session @inject IUserContextAccessor UserContext @inject ISessionManager SessionMgr +@inject IElevationCoordinator Elevation @inject IVersionCleanupService VersionSvc @inject VersionCleanupHtmlExportService HtmlExport @inject WebExportService WebExport @@ -109,8 +110,11 @@ try { var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, CancellationToken.None); - _libraries = (await VersionSvc.ListLibraryTitlesAsync(ctx, CancellationToken.None)).ToList(); + _libraries = (await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await VersionSvc.ListLibraryTitlesAsync(ctx, c); + }, CancellationToken.None)).ToList(); } catch (Exception ex) { _error = ex.Message; } finally { _loading = false; } @@ -126,9 +130,12 @@ var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token); var opts = new VersionCleanupOptions(_selectedLibs, _keepLast, _keepFirst); - _results = (await VersionSvc.DeleteOldVersionsAsync(ctx, opts, progress, _cts.Token)).ToList(); + _results = (await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); + return await VersionSvc.DeleteOldVersionsAsync(ctx, opts, progress, c); + }, _cts.Token)).ToList(); _status = $"Complete: {_results.Sum(r => r.VersionsDeleted)} versions deleted."; } catch (OperationCanceledException) { _status = "Cancelled."; } diff --git a/Core/Helpers/ExecuteQueryRetryHelper.cs b/Core/Helpers/ExecuteQueryRetryHelper.cs index f9ec257..332924e 100644 --- a/Core/Helpers/ExecuteQueryRetryHelper.cs +++ b/Core/Helpers/ExecuteQueryRetryHelper.cs @@ -1,4 +1,6 @@ +using System.Net; using Microsoft.SharePoint.Client; +using Serilog; using SharepointToolbox.Web.Core.Models; namespace SharepointToolbox.Web.Core.Helpers; @@ -29,9 +31,64 @@ public static class ExecuteQueryRetryHelper $"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…")); await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); } + catch (Exception ex) + { + throw EnrichException(ctx, ex); + } } } + // CSOM surfaces a 403 as a bare "The remote server returned an error: (403) FORBIDDEN." WebException, + // which hides the actual SharePoint reason. Pull the server's response body / correlation id so the + // root cause (token scope, tenant policy, access-denied vs endpoint-blocked) is visible. + private static Exception EnrichException(ClientContext ctx, Exception ex) + { + var detail = new System.Text.StringBuilder(); + detail.Append(ex.Message); + detail.Append($" [site={ctx.Url}]"); + + if (ex is ServerException se) + { + detail.Append($" [serverErrorType={se.ServerErrorTypeName}; value={se.ServerErrorValue}; " + + $"correlationId={se.ServerErrorTraceCorrelationId}; details={se.ServerErrorDetails}]"); + } + + // Walk inner exceptions for a WebException carrying the raw HTTP response body. + for (Exception? cur = ex; cur is not null; cur = cur.InnerException) + { + if (cur is WebException we && we.Response is HttpWebResponse resp) + { + detail.Append($" [httpStatus={(int)resp.StatusCode} {resp.StatusCode}]"); + try + { + using var stream = resp.GetResponseStream(); + using var reader = new StreamReader(stream); + var body = reader.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(body)) + detail.Append($" [responseBody={body.Trim()}]"); + } + catch { /* body already consumed or unavailable */ } + break; + } + } + + var enriched = detail.ToString(); + Log.Error("CSOM ExecuteQuery failed: {Detail}", enriched); + + // Preserve access-denied as a typed exception so the elevation coordinator can + // detect it, take site-collection admin ownership, and retry. Everything else + // stays a generic InvalidOperationException carrying the enriched diagnostic. + if (IsAccessDenied(ex)) + return new SharePointAccessDeniedException(enriched, ctx.Url, ex); + + return new InvalidOperationException(enriched, ex); + } + + private static bool IsAccessDenied(Exception ex) => + ex is ServerUnauthorizedAccessException || + (ex is ServerException se && + string.Equals(se.ServerErrorTypeName, "System.UnauthorizedAccessException", StringComparison.Ordinal)); + internal static bool IsThrottleException(Exception ex) { var msg = ex.Message; diff --git a/Core/Helpers/SharePointAccessDeniedException.cs b/Core/Helpers/SharePointAccessDeniedException.cs new file mode 100644 index 0000000..13170b1 --- /dev/null +++ b/Core/Helpers/SharePointAccessDeniedException.cs @@ -0,0 +1,18 @@ +namespace SharepointToolbox.Web.Core.Helpers; + +/// +/// Thrown when a CSOM operation fails with a SharePoint "access denied" +/// (System.UnauthorizedAccessException / ServerUnauthorizedAccessException). +/// Carries the failing site URL so the elevation coordinator can take site-collection +/// admin ownership and retry. Message is the enriched diagnostic from EnrichException. +/// +public sealed class SharePointAccessDeniedException : Exception +{ + public string SiteUrl { get; } + + public SharePointAccessDeniedException(string message, string siteUrl, Exception inner) + : base(message, inner) + { + SiteUrl = siteUrl; + } +} diff --git a/Program.cs b/Program.cs index b491d7c..06ddc85 100644 --- a/Program.cs +++ b/Program.cs @@ -152,6 +152,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // ── Export services (Scoped) ────────────────────────────────────────────────── builder.Services.AddScoped(); diff --git a/Services/ElevationCoordinator.cs b/Services/ElevationCoordinator.cs new file mode 100644 index 0000000..db7b7c2 --- /dev/null +++ b/Services/ElevationCoordinator.cs @@ -0,0 +1,106 @@ +using Serilog; +using SharepointToolbox.Web.Core.Helpers; +using SharepointToolbox.Web.Services.Session; + +namespace SharepointToolbox.Web.Services; + +/// +/// Scoped per Blazor circuit. Catches 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. +/// +public class ElevationCoordinator : IElevationCoordinator +{ + private readonly ISessionManager _sessionManager; + private readonly IOwnershipElevationService _ownership; + private readonly IUserSessionService _session; + private readonly HashSet _elevatedSites = new(StringComparer.OrdinalIgnoreCase); + + public ElevationCoordinator( + ISessionManager sessionManager, + IOwnershipElevationService ownership, + IUserSessionService session) + { + _sessionManager = sessionManager; + _ownership = ownership; + _session = session; + } + + public async Task RunAsync(Func operation, CancellationToken ct) => + await RunAsync(async c => { await operation(c); return null; }, ct); + + public async Task RunAsync(Func> 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}"; + } +} diff --git a/Services/IElevationCoordinator.cs b/Services/IElevationCoordinator.cs new file mode 100644 index 0000000..068452c --- /dev/null +++ b/Services/IElevationCoordinator.cs @@ -0,0 +1,14 @@ +namespace SharepointToolbox.Web.Services; + +/// +/// 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. +/// +public interface IElevationCoordinator +{ + Task RunAsync(Func> operation, CancellationToken ct); + Task RunAsync(Func operation, CancellationToken ct); +} diff --git a/Services/IUserAccessAuditService.cs b/Services/IUserAccessAuditService.cs index 8475412..6fafa7f 100644 --- a/Services/IUserAccessAuditService.cs +++ b/Services/IUserAccessAuditService.cs @@ -11,6 +11,5 @@ public interface IUserAccessAuditService IReadOnlyList sites, ScanOptions options, IProgress progress, - CancellationToken ct, - Func>? onAccessDenied = null); + CancellationToken ct); } diff --git a/Services/StorageService.cs b/Services/StorageService.cs index a8826e5..0d13c8b 100644 --- a/Services/StorageService.cs +++ b/Services/StorageService.cs @@ -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}/. + // 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 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() }; + 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() }; } private static async Task CollectSubfoldersAsync(ClientContext ctx, List list, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, StorageNodeKind kind, IProgress progress, CancellationToken ct) diff --git a/Services/UserAccessAuditService.cs b/Services/UserAccessAuditService.cs index 46ec3c3..690f297 100644 --- a/Services/UserAccessAuditService.cs +++ b/Services/UserAccessAuditService.cs @@ -6,15 +6,17 @@ namespace SharepointToolbox.Web.Services; public class UserAccessAuditService : IUserAccessAuditService { private readonly IPermissionsService _permissionsService; + private readonly IElevationCoordinator _elevation; private static readonly HashSet 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> AuditUsersAsync( @@ -24,8 +26,7 @@ public class UserAccessAuditService : IUserAccessAuditService IReadOnlyList sites, ScanOptions options, IProgress progress, - CancellationToken ct, - Func>? 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 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)); }