Register created app as public client (fix connect AADSTS7000218) #1

Merged
kawa merged 5 commits from fix/auto-elevate-ownership into main 2026-06-02 14:43:33 +02:00
19 changed files with 400 additions and 47 deletions
+6 -2
View File
@@ -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."; }
+6 -2
View File
@@ -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."; }
+8 -3
View File
@@ -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."; }
+6 -2
View File
@@ -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."; }
+6 -2
View File
@@ -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."; }
+6 -2
View File
@@ -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."; }
+6 -2
View File
@@ -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."; }
+6 -2
View File
@@ -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();
+11 -4
View File
@@ -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."; }
+76
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,8 +31,82 @@ 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)
{
@@ -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;
}
}
+1
View File
@@ -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>();
+5 -1
View File
@@ -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
+164
View File
@@ -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}";
}
}
+14
View File
@@ -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);
}
+1 -2
View File
@@ -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);
}
+12 -2
View File
@@ -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}");
}
+38 -6
View File
@@ -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)
+10 -15
View File
@@ -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));
}