diff --git a/SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs
new file mode 100644
index 0000000..9506122
--- /dev/null
+++ b/SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs
@@ -0,0 +1,280 @@
+using System.Collections.Generic;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.SharePoint.Client;
+using Moq;
+using SharepointToolbox.Core.Messages;
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Services;
+using SharepointToolbox.ViewModels;
+using SharepointToolbox.ViewModels.Tabs;
+
+namespace SharepointToolbox.Tests.ViewModels;
+
+///
+/// Tests for auto-elevation logic in PermissionsViewModel scan loop.
+/// OWN-02: catch access-denied, call ElevateAsync, retry scan, tag entries.
+///
+public class PermissionsViewModelOwnershipTests
+{
+ ///
+ /// Creates a ServerUnauthorizedAccessException via Activator (the reference assembly
+ /// exposes a different ctor signature than the runtime DLL — use runtime reflection).
+ ///
+ private static ServerUnauthorizedAccessException MakeAccessDeniedException()
+ {
+ var t = typeof(ServerUnauthorizedAccessException);
+ var ctor = t.GetConstructors(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];
+ return (ServerUnauthorizedAccessException)ctor.Invoke(new object?[] { "Access Denied", "", 0, "", "ServerUnauthorizedAccessException", null, "" });
+ }
+
+ private static readonly string SiteUrl = "https://tenant.sharepoint.com/sites/test";
+ private static readonly string TenantUrl = "https://tenant.sharepoint.com";
+
+ private static PermissionsViewModel CreateVm(
+ Mock permissionsSvc,
+ Mock sessionManager,
+ SettingsService? settingsService = null,
+ IOwnershipElevationService? ownershipService = null)
+ {
+ var siteListSvc = new Mock();
+ var logger = NullLogger.Instance;
+
+ var vm = new PermissionsViewModel(
+ permissionsSvc.Object,
+ siteListSvc.Object,
+ sessionManager.Object,
+ logger,
+ settingsService: settingsService,
+ ownershipService: ownershipService);
+
+ WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
+ new List { new(SiteUrl, "Test Site") }.AsReadOnly()));
+ vm.SetCurrentProfile(new TenantProfile
+ {
+ Name = "Test",
+ TenantUrl = TenantUrl,
+ ClientId = "client-id"
+ });
+
+ return vm;
+ }
+
+ ///
+ /// When AutoTakeOwnership=false and ScanSiteAsync throws ServerUnauthorizedAccessException,
+ /// the exception propagates.
+ ///
+ [Fact]
+ public async Task ScanLoop_ToggleOff_AccessDenied_ExceptionPropagates()
+ {
+ var permSvc = new Mock();
+ permSvc
+ .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()))
+ .ThrowsAsync(MakeAccessDeniedException());
+
+ var sessionMgr = new Mock();
+ sessionMgr
+ .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ClientContext)null!);
+
+ // No settingsService => toggle OFF (null treated as false)
+ var vm = CreateVm(permSvc, sessionMgr, settingsService: null, ownershipService: null);
+
+ await Assert.ThrowsAsync(
+ () => vm.TestRunOperationAsync(CancellationToken.None, new Progress()));
+ }
+
+ ///
+ /// When AutoTakeOwnership=true and scan throws access denied,
+ /// ElevateAsync is called once then ScanSiteAsync is retried.
+ ///
+ [Fact]
+ public async Task ScanLoop_ToggleOn_AccessDenied_ElevatesAndRetries()
+ {
+ var permSvc = new Mock();
+ var callCount = 0;
+ permSvc
+ .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(() =>
+ {
+ callCount++;
+ if (callCount == 1)
+ throw MakeAccessDeniedException();
+ return new List
+ {
+ new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
+ };
+ });
+
+ var sessionMgr = new Mock();
+ sessionMgr
+ .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ClientContext)null!);
+
+ var elevationSvc = new Mock();
+ elevationSvc
+ .Setup(e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
+
+ var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
+
+ await vm.TestRunOperationAsync(CancellationToken.None, new Progress());
+
+ // ElevateAsync called once
+ elevationSvc.Verify(
+ e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Once);
+
+ // ScanSiteAsync called twice (first fails, retry succeeds)
+ permSvc.Verify(
+ s => s.ScanSiteAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()),
+ Times.Exactly(2));
+ }
+
+ ///
+ /// When ScanSiteAsync succeeds on first try, ElevateAsync is never called.
+ ///
+ [Fact]
+ public async Task ScanLoop_ScanSucceeds_ElevateNeverCalled()
+ {
+ var permSvc = new Mock();
+ permSvc
+ .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
+ });
+
+ var sessionMgr = new Mock();
+ sessionMgr
+ .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ClientContext)null!);
+
+ var elevationSvc = new Mock();
+ var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
+
+ var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
+
+ await vm.TestRunOperationAsync(CancellationToken.None, new Progress());
+
+ elevationSvc.Verify(
+ e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// After successful elevation+retry, returned entries have WasAutoElevated=true.
+ ///
+ [Fact]
+ public async Task ScanLoop_AfterElevation_EntriesTaggedWasAutoElevated()
+ {
+ var permSvc = new Mock();
+ var callCount = 0;
+ permSvc
+ .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(() =>
+ {
+ callCount++;
+ if (callCount == 1)
+ throw MakeAccessDeniedException();
+ return new List
+ {
+ new("Site", "Test", SiteUrl, true, "User1", "u1@t.com", "Full Control", "Direct", "User")
+ };
+ });
+
+ var sessionMgr = new Mock();
+ sessionMgr
+ .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ClientContext)null!);
+
+ var elevationSvc = new Mock();
+ elevationSvc
+ .Setup(e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
+
+ var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
+
+ await vm.TestRunOperationAsync(CancellationToken.None, new Progress());
+
+ Assert.NotEmpty(vm.Results);
+ Assert.All(vm.Results, e => Assert.True(e.WasAutoElevated));
+ }
+
+ ///
+ /// If ElevateAsync itself throws, the exception propagates (no infinite retry).
+ ///
+ [Fact]
+ public async Task ScanLoop_ElevationThrows_ExceptionPropagates()
+ {
+ var permSvc = new Mock();
+ permSvc
+ .Setup(s => s.ScanSiteAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()))
+ .ThrowsAsync(MakeAccessDeniedException());
+
+ var sessionMgr = new Mock();
+ sessionMgr
+ .Setup(s => s.GetOrCreateContextAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ClientContext)null!);
+
+ var elevationSvc = new Mock();
+ elevationSvc
+ .Setup(e => e.ElevateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Elevation failed"));
+ // ScanSiteAsync always throws access denied (does NOT succeed after elevation throws)
+
+
+ var settingsSvc = FakeSettingsServiceWithAutoOwnership(true);
+
+ var vm = CreateVm(permSvc, sessionMgr, settingsService: settingsSvc, ownershipService: elevationSvc.Object);
+
+ await Assert.ThrowsAsync(
+ () => vm.TestRunOperationAsync(CancellationToken.None, new Progress()));
+
+ // ScanSiteAsync was called exactly once (no retry after elevation failure)
+ permSvc.Verify(
+ s => s.ScanSiteAsync(It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Validates DeriveAdminUrl logic for standard tenant URL.
+ ///
+ [Theory]
+ [InlineData("https://tenant.sharepoint.com", "https://tenant-admin.sharepoint.com")]
+ [InlineData("https://tenant.sharepoint.com/", "https://tenant-admin.sharepoint.com")]
+ [InlineData("https://tenant-admin.sharepoint.com", "https://tenant-admin.sharepoint.com")]
+ public void DeriveAdminUrl_ReturnsCorrectAdminUrl(string input, string expected)
+ {
+ var result = PermissionsViewModel.DeriveAdminUrl(input);
+ Assert.Equal(expected, result);
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static SettingsService FakeSettingsServiceWithAutoOwnership(bool enabled)
+ {
+ // Use in-memory temp file
+ var tempFile = System.IO.Path.GetTempFileName();
+ System.IO.File.Delete(tempFile);
+ var repo = new SharepointToolbox.Infrastructure.Persistence.SettingsRepository(tempFile);
+ var svc = new SettingsService(repo);
+ // Seed via SetAutoTakeOwnershipAsync synchronously
+ svc.SetAutoTakeOwnershipAsync(enabled).GetAwaiter().GetResult();
+ return svc;
+ }
+}
diff --git a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
index d2ec7c2..dba8661 100644
--- a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
+++ b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
@@ -29,6 +29,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
private readonly IBrandingService? _brandingService;
private readonly ISharePointGroupResolver? _groupResolver;
private readonly ILogger _logger;
+ private readonly SettingsService? _settingsService;
+ private readonly IOwnershipElevationService? _ownershipService;
// ── Observable properties ───────────────────────────────────────────────
@@ -136,7 +138,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
HtmlExportService htmlExportService,
IBrandingService brandingService,
ILogger logger,
- ISharePointGroupResolver? groupResolver = null)
+ ISharePointGroupResolver? groupResolver = null,
+ SettingsService? settingsService = null,
+ IOwnershipElevationService? ownershipService = null)
: base(logger)
{
_permissionsService = permissionsService;
@@ -147,6 +151,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
_brandingService = brandingService;
_groupResolver = groupResolver;
_logger = logger;
+ _settingsService = settingsService;
+ _ownershipService = ownershipService;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
@@ -160,7 +166,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
ISiteListService siteListService,
ISessionManager sessionManager,
ILogger logger,
- IBrandingService? brandingService = null)
+ IBrandingService? brandingService = null,
+ SettingsService? settingsService = null,
+ IOwnershipElevationService? ownershipService = null)
: base(logger)
{
_permissionsService = permissionsService;
@@ -170,6 +178,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
_htmlExportService = null;
_brandingService = brandingService;
_logger = logger;
+ _settingsService = settingsService;
+ _ownershipService = ownershipService;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
@@ -217,6 +227,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
FolderDepth: FolderDepth,
IncludeSubsites: IncludeSubsites);
+ // Read toggle once before the loop (avoids async in exception filter)
+ var autoOwnership = await IsAutoTakeOwnershipEnabled();
+
int i = 0;
foreach (var url in nonEmpty)
{
@@ -230,9 +243,38 @@ public partial class PermissionsViewModel : FeatureViewModelBase
Name = _currentProfile?.Name ?? string.Empty
};
- var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
- var siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct);
- allEntries.AddRange(siteEntries);
+ bool wasElevated = false;
+ IReadOnlyList siteEntries;
+
+ try
+ {
+ var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
+ siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct);
+ }
+ catch (Exception ex) when (IsAccessDenied(ex) && _ownershipService != null && autoOwnership)
+ {
+ _logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
+ var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? url);
+ var adminProfile = new TenantProfile
+ {
+ TenantUrl = adminUrl,
+ ClientId = _currentProfile?.ClientId ?? string.Empty,
+ Name = _currentProfile?.Name ?? string.Empty
+ };
+ var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
+ await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);
+
+ // Retry scan with fresh context
+ var retryCtx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
+ siteEntries = await _permissionsService.ScanSiteAsync(retryCtx, scanOptions, progress, ct);
+ wasElevated = true;
+ }
+
+ if (wasElevated)
+ allEntries.AddRange(siteEntries.Select(e => e with { WasAutoElevated = true }));
+ else
+ allEntries.AddRange(siteEntries);
+
i++;
}
@@ -260,6 +302,38 @@ public partial class PermissionsViewModel : FeatureViewModelBase
ExportHtmlCommand.NotifyCanExecuteChanged();
}
+ // ── Auto-ownership helpers ───────────────────────────────────────────────
+
+ ///
+ /// Derives the tenant admin URL from a standard tenant URL.
+ /// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
+ ///
+ internal static string DeriveAdminUrl(string tenantUrl)
+ {
+ var uri = new Uri(tenantUrl.TrimEnd('/'));
+ var host = uri.Host;
+ if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
+ return tenantUrl;
+ var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
+ StringComparison.OrdinalIgnoreCase);
+ return $"{uri.Scheme}://{adminHost}";
+ }
+
+ private static bool IsAccessDenied(Exception ex)
+ {
+ if (ex is Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) return true;
+ if (ex is System.Net.WebException webEx && webEx.Response is System.Net.HttpWebResponse resp
+ && resp.StatusCode == System.Net.HttpStatusCode.Forbidden) return true;
+ return false;
+ }
+
+ private async Task IsAutoTakeOwnershipEnabled()
+ {
+ if (_settingsService == null) return false;
+ var settings = await _settingsService.GetSettingsAsync();
+ return settings.AutoTakeOwnership;
+ }
+
// ── Tenant switching ─────────────────────────────────────────────────────
protected override void OnTenantSwitched(TenantProfile profile)