From b7061867f167eee064852294f69c329dae553a60 Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 2 Jun 2026 12:04:09 +0200 Subject: [PATCH 1/5] Register created app as public client (fix connect AADSTS7000218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-client app registered its redirect URI under the `web` platform, so Entra treated it as a confidential client and the connect token exchange (PKCE, no secret) failed with AADSTS7000218 (client_secret required). Register the redirect under `publicClient` instead — matching the desktop reference (PublicClient.RedirectUris) — so the secretless PKCE code redemption is accepted. Co-Authored-By: Claude Opus 4.8 (1M context) --- Services/Auth/AppRegistrationService.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 -- 2.52.0 From 57f5239cfc2ef0e6bb2faac93ac81f86844d0f33 Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 2 Jun 2026 14:16:12 +0200 Subject: [PATCH 2/5] 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) --- Components/Pages/BulkMembers.razor | 8 +- Components/Pages/Duplicates.razor | 8 +- Components/Pages/FileTransfer.razor | 11 +- Components/Pages/FolderStructure.razor | 8 +- Components/Pages/Permissions.razor | 8 +- Components/Pages/Search.razor | 8 +- Components/Pages/Storage.razor | 8 +- Components/Pages/Templates.razor | 8 +- Components/Pages/VersionCleanup.razor | 15 ++- Core/Helpers/ExecuteQueryRetryHelper.cs | 57 ++++++++++ .../SharePointAccessDeniedException.cs | 18 +++ Program.cs | 1 + Services/ElevationCoordinator.cs | 106 ++++++++++++++++++ Services/IElevationCoordinator.cs | 14 +++ Services/IUserAccessAuditService.cs | 3 +- Services/StorageService.cs | 44 +++++++- Services/UserAccessAuditService.cs | 25 ++--- 17 files changed, 306 insertions(+), 44 deletions(-) create mode 100644 Core/Helpers/SharePointAccessDeniedException.cs create mode 100644 Services/ElevationCoordinator.cs create mode 100644 Services/IElevationCoordinator.cs 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)); } -- 2.52.0 From 1c36ea89d002f6210d0094ec833e3644a3fcf61e Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 2 Jun 2026 14:26:01 +0200 Subject: [PATCH 3/5] Classify bare HTTP 403 as access-denied for Group/Teams sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft 365 Group / Teams-connected sites surface access-denied on some CSOM calls as a raw "(403) FORBIDDEN" WebException carrying 0x80070005 (E_ACCESSDENIED), not as a typed ServerException with ServerErrorTypeName = System.UnauthorizedAccessException. IsAccessDenied only matched the typed shape, so those denials became generic InvalidOperationExceptions the elevation coordinator never caught — no auto-elevation ran and the operation failed even for a SharePoint admin. Walk the inner-exception chain and treat any of these as access-denied: the typed ServerException, a WebException with HTTP 403, or a message containing the E_ACCESSDENIED HRESULT. Per-site dedupe still caps elevation to one retry, so a 403 elevation cannot fix (policy/endpoint block) won't loop. Co-Authored-By: Claude Opus 4.8 (1M context) --- Core/Helpers/ExecuteQueryRetryHelper.cs | 27 +++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Core/Helpers/ExecuteQueryRetryHelper.cs b/Core/Helpers/ExecuteQueryRetryHelper.cs index 332924e..4b349be 100644 --- a/Core/Helpers/ExecuteQueryRetryHelper.cs +++ b/Core/Helpers/ExecuteQueryRetryHelper.cs @@ -84,10 +84,29 @@ public static class ExecuteQueryRetryHelper 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)); + // 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) { -- 2.52.0 From e4125c6643cde95b1585339c65e27720ee4a8c42 Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 2 Jun 2026 14:35:48 +0200 Subject: [PATCH 4/5] Instrument elevation path to diagnose ineffective grants A SharePoint admin reported the grant runs without a logged error yet the account never appears as site-collection admin on Group/Teams sites. The failure was invisible: ElevateAsync called ExecuteQueryAsync directly (no enrichment/logging) and the coordinator only surfaced elevate failures on the page, not to Serilog. - Route the admin-endpoint ExecuteQuery through ExecuteQueryRetryHelper so a denial there is enriched (serverErrorType/httpStatus) and logged. - Log the resolved login and SetSiteAdmin acceptance in OwnershipElevationService. - Log elevate failures to Serilog in the coordinator. - Add a post-elevation verify that reads CurrentUser.IsSiteAdmin on the target site so logs distinguish a failed/no-op grant from a scan failing for another reason. Diagnostic only; never throws into the operation flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- Services/ElevationCoordinator.cs | 23 +++++++++++++++++++++++ Services/OwnershipElevationService.cs | 14 ++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Services/ElevationCoordinator.cs b/Services/ElevationCoordinator.cs index db7b7c2..3224541 100644 --- a/Services/ElevationCoordinator.cs +++ b/Services/ElevationCoordinator.cs @@ -58,6 +58,10 @@ public class ElevationCoordinator : IElevationCoordinator 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); + // Re-run once. The closure re-issues its loads; the now-granted admin right applies. return await operation(ct); } @@ -88,12 +92,31 @@ public class ElevationCoordinator : IElevationCoordinator } 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) { 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}"); } -- 2.52.0 From 881f3a8bac74b73a10553eee6616936a9bea696f Mon Sep 17 00:00:00 2001 From: kawa Date: Tue, 2 Jun 2026 14:39:29 +0200 Subject: [PATCH 5/5] Add backoff-retry to elevation for transient admin 403 and grant lag Logs showed the failure was a transient 403 on the tenant admin endpoint (loading CurrentUser on -admin.sharepoint.com returned E_ACCESSDENIED on a cold token), and that re-running the operation a few seconds later succeeded. The site-collection admin grant is also eventually consistent on Group/Teams sites, taking a few seconds to reach the content endpoint. Retry both stages with backoff (3s, 6s, 9s; 4 attempts) instead of failing on the first denial: - ElevateAsync retries the admin-endpoint grant on transient access-denied; a genuine lack of tenant-admin rights still surfaces after retries exhaust. - After a successful grant, the post-elevation operation retries on continued access-denied to absorb grant-propagation lag. Co-Authored-By: Claude Opus 4.8 (1M context) --- Services/ElevationCoordinator.cs | 65 ++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/Services/ElevationCoordinator.cs b/Services/ElevationCoordinator.cs index 3224541..71740ec 100644 --- a/Services/ElevationCoordinator.cs +++ b/Services/ElevationCoordinator.cs @@ -12,6 +12,10 @@ namespace SharepointToolbox.Web.Services; /// 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 { @@ -62,11 +66,28 @@ public class ElevationCoordinator : IElevationCoordinator // so the logs distinguish "grant failed/no-op" from "scan still fails for another reason". await VerifyAdminAsync(siteUrl, ct); - // Re-run once. The closure re-issues its loads; the now-granted admin right applies. - return await operation(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 @@ -82,20 +103,34 @@ public class ElevationCoordinator : IElevationCoordinator 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); + + for (int attempt = 1; ; attempt++) { - 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) - { - 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); + 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); + } } } -- 2.52.0