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..4b349be 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,83 @@ 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); + } + + // Access-denied reaches us in two shapes: a typed CSOM ServerException + // (ServerErrorTypeName = System.UnauthorizedAccessException), and — notably on + // Microsoft 365 Group / Teams-connected sites — a bare HTTP (403) FORBIDDEN + // WebException carrying "Access is denied ... 0x80070005 (E_ACCESSDENIED)". + // Both are ownership issues elevation can fix, so classify either as access-denied. + private static bool IsAccessDenied(Exception ex) + { + for (Exception? cur = ex; cur is not null; cur = cur.InnerException) + { + if (cur is ServerUnauthorizedAccessException) + return true; + if (cur is ServerException se && + string.Equals(se.ServerErrorTypeName, "System.UnauthorizedAccessException", StringComparison.Ordinal)) + return true; + if (cur is WebException we && we.Response is HttpWebResponse resp && + resp.StatusCode == HttpStatusCode.Forbidden) + return true; + if (cur.Message.Contains("0x80070005", StringComparison.OrdinalIgnoreCase) || + cur.Message.Contains("E_ACCESSDENIED", StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + 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/Auth/AppRegistrationService.cs b/Services/Auth/AppRegistrationService.cs index cb69e45..ee75fb2 100644 --- a/Services/Auth/AppRegistrationService.cs +++ b/Services/Auth/AppRegistrationService.cs @@ -47,7 +47,11 @@ public class AppRegistrationService : IAppRegistrationService displayName = $"SP Toolbox — {tenantName}", signInAudience = "AzureADMyOrg", isFallbackPublicClient = true, - web = new { redirectUris = new[] { redirectUri } }, + // Register the redirect under the PUBLIC client platform so the connect + // flow can redeem the auth code with PKCE only (no client secret). A + // redirect under `web` makes Entra treat the app as confidential and the + // token exchange fails with AADSTS7000218 (secret required). + publicClient = new { redirectUris = new[] { redirectUri } }, requiredResourceAccess = new[] { new diff --git a/Services/ElevationCoordinator.cs b/Services/ElevationCoordinator.cs new file mode 100644 index 0000000..71740ec --- /dev/null +++ b/Services/ElevationCoordinator.cs @@ -0,0 +1,164 @@ +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. +/// +/// Both the admin-endpoint grant and the post-grant operation are retried with backoff: the +/// tenant admin endpoint can transiently 403 on a cold token, and the site-collection admin grant +/// is eventually consistent (notably on Group/Teams-connected sites), taking a few seconds to apply. +/// +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); + + // Verify the grant actually took effect for this delegated token before retrying, + // so the logs distinguish "grant failed/no-op" from "scan still fails for another reason". + await VerifyAdminAsync(siteUrl, ct); + + // The site-collection admin grant is eventually consistent — on Group/Teams sites it + // can take a few seconds to propagate to the content endpoint. Retry with backoff. + for (int attempt = 1; ; attempt++) + { + try + { + return await operation(ct); + } + catch (SharePointAccessDeniedException) when (attempt < MaxBackoffAttempts) + { + var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt); + Log.Warning("Post-elevation scan still denied for {Site} (attempt {N}/{Max}); retrying in {Delay}s.", + siteUrl, attempt, MaxBackoffAttempts, delay.TotalSeconds); + await Task.Delay(delay, ct); + } + } + } + } + + private const int MaxBackoffAttempts = 4; + private const int BackoffBaseSeconds = 3; + + 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, + }; + + var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); + Log.Information("Auto-elevating site-collection admin ownership for {Site} via {Admin}", + siteUrl, adminProfile.TenantUrl); + + for (int attempt = 1; ; attempt++) + { + try + { + // loginName empty → ElevateAsync resolves the current (delegated) user from the admin context. + await _ownership.ElevateAsync(adminCtx, siteUrl, loginName: string.Empty, ct); + return; + } + // The admin endpoint can transiently 403 on a cold token / first call; it clears within + // seconds. A genuine lack of tenant-admin rights keeps failing and surfaces after retries. + catch (SharePointAccessDeniedException ex) when (attempt < MaxBackoffAttempts) + { + var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt); + Log.Warning("Admin endpoint denied for {Site} (attempt {N}/{Max}); retrying in {Delay}s. {Err}", + siteUrl, attempt, MaxBackoffAttempts, delay.TotalSeconds, ex.Message); + await Task.Delay(delay, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Error(ex, "Auto-elevate ownership failed for {Site}", siteUrl); + 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); + } + } + } + + // Reads the current user's site-admin flag on the target site right after elevation. + // Diagnostic only — never throws into the operation flow. + private async Task VerifyAdminAsync(string siteUrl, CancellationToken ct) + { + try + { + var ctx = await _sessionManager.GetOrCreateContextAsync(siteUrl, _session.CurrentProfile!, ct); + ctx.Load(ctx.Web.CurrentUser, u => u.LoginName, u => u.IsSiteAdmin); + await ctx.ExecuteQueryAsync(); + Log.Information("Post-elevation check {Site}: user={Login} IsSiteAdmin={IsAdmin}", + siteUrl, ctx.Web.CurrentUser.LoginName, ctx.Web.CurrentUser.IsSiteAdmin); + } + catch (Exception ex) + { + Log.Warning("Post-elevation check failed for {Site}: {Error}", siteUrl, ex.Message); + } + } + + // 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/OwnershipElevationService.cs b/Services/OwnershipElevationService.cs index 46c0bd2..7fb7be9 100644 --- a/Services/OwnershipElevationService.cs +++ b/Services/OwnershipElevationService.cs @@ -1,5 +1,7 @@ using Microsoft.Online.SharePoint.TenantAdministration; using Microsoft.SharePoint.Client; +using Serilog; +using SharepointToolbox.Web.Core.Helpers; using SharepointToolbox.Web.Services.Audit; namespace SharepointToolbox.Web.Services; @@ -15,12 +17,20 @@ public class OwnershipElevationService : IOwnershipElevationService if (string.IsNullOrWhiteSpace(loginName)) { tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName); - await tenantAdminCtx.ExecuteQueryAsync(); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(tenantAdminCtx, null, ct); loginName = tenantAdminCtx.Web.CurrentUser.LoginName; } + + Log.Information("SetSiteAdmin: granting {Login} site-collection admin on {Site} via admin endpoint {Admin}", + loginName, siteUrl, tenantAdminCtx.Url); + var tenant = new Tenant(tenantAdminCtx); tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true); - await tenantAdminCtx.ExecuteQueryAsync(); + // Route through the enricher so a denial on the admin endpoint surfaces the real reason + // (and gets logged) instead of a bare 403 the caller has to guess at. + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(tenantAdminCtx, null, ct); + + Log.Information("SetSiteAdmin call accepted for {Site} (login {Login})", siteUrl, loginName); await _audit.LogAsync("ElevateOwnership", tenantAdminCtx.Url, new[] { siteUrl }, $"Site admin granted to {loginName}"); } 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)); }