Files
SharepointToolbox-Web/Services/ElevationCoordinator.cs
T
kawa e4125c6643 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) <noreply@anthropic.com>
2026-06-02 14:35:48 +02:00

130 lines
5.4 KiB
C#

using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Scoped per Blazor circuit. Catches <see cref="SharePointAccessDeniedException"/> 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.
/// </summary>
public class ElevationCoordinator : IElevationCoordinator
{
private readonly ISessionManager _sessionManager;
private readonly IOwnershipElevationService _ownership;
private readonly IUserSessionService _session;
private readonly HashSet<string> _elevatedSites = new(StringComparer.OrdinalIgnoreCase);
public ElevationCoordinator(
ISessionManager sessionManager,
IOwnershipElevationService ownership,
IUserSessionService session)
{
_sessionManager = sessionManager;
_ownership = ownership;
_session = session;
}
public async Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken ct) =>
await RunAsync<object?>(async c => { await operation(c); return null; }, ct);
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> 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);
// 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)
{
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}";
}
}