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}"; } }