Merge pull request 'Register created app as public client (fix connect AADSTS7000218)' (#1) from fix/auto-elevate-ownership into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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<OperationProgress>(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."; }
|
||||
|
||||
@@ -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<OperationProgress>(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."; }
|
||||
|
||||
@@ -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."; }
|
||||
|
||||
@@ -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<OperationProgress>(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."; }
|
||||
|
||||
@@ -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<OperationProgress>(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."; }
|
||||
|
||||
@@ -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<OperationProgress>(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."; }
|
||||
|
||||
@@ -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<OperationProgress>(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."; }
|
||||
|
||||
@@ -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<OperationProgress>(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();
|
||||
|
||||
@@ -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<OperationProgress>(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."; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ builder.Services.AddScoped<ITemplateService, TemplateService>();
|
||||
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
|
||||
builder.Services.AddScoped<ISharePointGroupResolver, SharePointGroupResolver>();
|
||||
builder.Services.AddScoped<IOwnershipElevationService, OwnershipElevationService>();
|
||||
builder.Services.AddScoped<IElevationCoordinator, ElevationCoordinator>();
|
||||
|
||||
// ── Export services (Scoped) ──────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<CsvExportService>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
/// </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);
|
||||
|
||||
// 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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace SharepointToolbox.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IElevationCoordinator
|
||||
{
|
||||
Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> operation, CancellationToken ct);
|
||||
Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken ct);
|
||||
}
|
||||
@@ -11,6 +11,5 @@ public interface IUserAccessAuditService
|
||||
IReadOnlyList<SiteInfo> sites,
|
||||
ScanOptions options,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct,
|
||||
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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}/<file>.
|
||||
// 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<Microsoft.SharePoint.Client.File> 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<StorageNode>() };
|
||||
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<StorageNode>() };
|
||||
}
|
||||
|
||||
private static async Task CollectSubfoldersAsync(ClientContext ctx, List list, string parentServerRelativeUrl, StorageNode parentNode, int currentDepth, int maxDepth, string siteTitle, string library, StorageNodeKind kind, IProgress<OperationProgress> progress, CancellationToken ct)
|
||||
|
||||
@@ -6,15 +6,17 @@ namespace SharepointToolbox.Web.Services;
|
||||
public class UserAccessAuditService : IUserAccessAuditService
|
||||
{
|
||||
private readonly IPermissionsService _permissionsService;
|
||||
private readonly IElevationCoordinator _elevation;
|
||||
|
||||
private static readonly HashSet<string> 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<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
|
||||
@@ -24,8 +26,7 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
IReadOnlyList<SiteInfo> sites,
|
||||
ScanOptions options,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct,
|
||||
Func<string, CancellationToken, Task<bool>>? 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<PermissionEntry> 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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user