Wire auto-elevate ownership across all SharePoint operations

The "Auto-elevate ownership when permission scan is denied" setting was
dead code: the toggle was persisted but never read, the audit flow never
passed its onAccessDenied callback, and EnrichException wrapped every CSOM
error (including ServerUnauthorizedAccessException) into a generic
InvalidOperationException so the access-denied catch could never match.

Centralize elevation instead of per-call-site callbacks:

- Throw typed SharePointAccessDeniedException from EnrichException on
  access-denied, preserving the failing site URL and enriched diagnostic.
- Add scoped IElevationCoordinator that catches it, and when AutoTakeOwnership
  is enabled takes site-collection admin via the tenant admin endpoint and
  retries the operation once. Per-site dedupe prevents loops; admin-host
  denials are not treated as ownership issues. Retry is safe because each
  wrapped operation closure re-issues its own CSOM loads.
- Wrap all site-scoped operations (Storage, Permissions, Duplicates, Search,
  VersionCleanup, FolderStructure, BulkMembers, FileTransfer, Templates) and
  the UserAccessAudit per-site scan in the coordinator.
- Drop the unused onAccessDenied parameter from IUserAccessAuditService.

Elevation still requires SharePoint tenant admin rights on the signed-in
account; the coordinator surfaces a clear message when that is missing.

Also keeps the prior StorageService change that avoids admin-gated
folder.StorageMetrics (403 for delegated non-admin tokens).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:16:12 +02:00
parent b7061867f1
commit 57f5239cfc
17 changed files with 306 additions and 44 deletions
+57
View File
@@ -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;
@@ -0,0 +1,18 @@
namespace SharepointToolbox.Web.Core.Helpers;
/// <summary>
/// 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.
/// </summary>
public sealed class SharePointAccessDeniedException : Exception
{
public string SiteUrl { get; }
public SharePointAccessDeniedException(string message, string siteUrl, Exception inner)
: base(message, inner)
{
SiteUrl = siteUrl;
}
}