diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 4681655..768263c 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -46,7 +46,7 @@
- [x] **Phase 15: Consolidation Data Model** (2 plans) — PermissionConsolidator service and merged-row model; zero API calls, pure data shapes (completed 2026-04-09)
- [x] **Phase 16: Report Consolidation Toggle** (2 plans) — Export settings toggle wired to PermissionConsolidator; first user-visible consolidation behavior (completed 2026-04-09)
- [x] **Phase 17: Group Expansion in HTML Reports** (2 plans) — Clickable group expansion in HTML exports with transitive membership resolution (completed 2026-04-09)
-- [ ] **Phase 18: Auto-Take Ownership** — Global toggle and automatic site collection admin elevation on access denied
+- [ ] **Phase 18: Auto-Take Ownership** (2 plans) — Global toggle and automatic site collection admin elevation on access denied
- [ ] **Phase 19: App Registration & Removal** — Automated Entra app registration with guided fallback and clean removal
## Phase Details
@@ -102,7 +102,10 @@ Plans:
2. When the toggle is OFF, access-denied sites produce the same error behavior as before v2.3 (no regression)
3. When the toggle is ON and a scan hits access denied on a site, the app automatically calls `Tenant.SetSiteAdmin` to elevate ownership and retries the site without interrupting the scan
4. The scan result for an auto-elevated site is visually distinguishable from a normally-scanned site (e.g., a flag or icon in the results)
-**Plans**: TBD
+**Plans:** 2 plans
+Plans:
+- [ ] 18-01-PLAN.md — Settings toggle + OwnershipElevationService + PermissionEntry.WasAutoElevated flag
+- [ ] 18-02-PLAN.md — Scan-loop elevation logic + DataGrid visual differentiation
### Phase 19: App Registration & Removal
**Goal**: Users can register and remove the Toolbox's Azure AD application on a target tenant directly from the profile dialog, with a guided fallback when permissions are insufficient
@@ -126,5 +129,5 @@ Plans:
| 15. Consolidation Data Model | v2.3 | 2/2 | Complete | 2026-04-09 |
| 16. Report Consolidation Toggle | v2.3 | 2/2 | Complete | 2026-04-09 |
| 17. Group Expansion in HTML Reports | 2/2 | Complete | 2026-04-09 | — |
-| 18. Auto-Take Ownership | v2.3 | 0/? | Not started | — |
+| 18. Auto-Take Ownership | v2.3 | 0/2 | Planned | — |
| 19. App Registration & Removal | v2.3 | 0/? | Not started | — |
diff --git a/.planning/phases/18-auto-take-ownership/18-01-PLAN.md b/.planning/phases/18-auto-take-ownership/18-01-PLAN.md
new file mode 100644
index 0000000..3883843
--- /dev/null
+++ b/.planning/phases/18-auto-take-ownership/18-01-PLAN.md
@@ -0,0 +1,296 @@
+---
+phase: 18-auto-take-ownership
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - SharepointToolbox/Core/Models/AppSettings.cs
+ - SharepointToolbox/Core/Models/PermissionEntry.cs
+ - SharepointToolbox/Services/SettingsService.cs
+ - SharepointToolbox/Services/IOwnershipElevationService.cs
+ - SharepointToolbox/Services/OwnershipElevationService.cs
+ - SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
+ - SharepointToolbox/Views/Tabs/SettingsView.xaml
+ - SharepointToolbox/Localization/Strings.resx
+ - SharepointToolbox/Localization/Strings.fr.resx
+ - SharepointToolbox/App.xaml.cs
+ - SharepointToolbox.Tests/Services/OwnershipElevationServiceTests.cs
+ - SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs
+autonomous: true
+requirements:
+ - OWN-01
+must_haves:
+ truths:
+ - "AutoTakeOwnership defaults to false in AppSettings"
+ - "Setting round-trips through SettingsRepository JSON persistence"
+ - "SettingsViewModel exposes AutoTakeOwnership and persists on toggle"
+ - "Settings UI shows an auto-take-ownership checkbox, OFF by default"
+ - "PermissionEntry.WasAutoElevated exists with default false, zero callsite breakage"
+ - "IOwnershipElevationService contract exists for Tenant.SetSiteAdmin wrapping"
+ artifacts:
+ - path: "SharepointToolbox/Core/Models/AppSettings.cs"
+ provides: "AutoTakeOwnership bool property defaulting to false"
+ contains: "AutoTakeOwnership"
+ - path: "SharepointToolbox/Core/Models/PermissionEntry.cs"
+ provides: "WasAutoElevated flag on PermissionEntry record"
+ contains: "WasAutoElevated"
+ - path: "SharepointToolbox/Services/IOwnershipElevationService.cs"
+ provides: "Elevation service interface"
+ contains: "interface IOwnershipElevationService"
+ - path: "SharepointToolbox/Services/OwnershipElevationService.cs"
+ provides: "Tenant.SetSiteAdmin wrapper"
+ contains: "class OwnershipElevationService"
+ - path: "SharepointToolbox/Services/SettingsService.cs"
+ provides: "SetAutoTakeOwnershipAsync method"
+ contains: "SetAutoTakeOwnershipAsync"
+ - path: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
+ provides: "AutoTakeOwnership observable property"
+ contains: "AutoTakeOwnership"
+ - path: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
+ provides: "CheckBox for auto-take-ownership toggle"
+ contains: "AutoTakeOwnership"
+ key_links:
+ - from: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
+ to: "SharepointToolbox/Services/SettingsService.cs"
+ via: "SetAutoTakeOwnershipAsync call on property change"
+ pattern: "SetAutoTakeOwnershipAsync"
+ - from: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
+ to: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
+ via: "CheckBox IsChecked binding to AutoTakeOwnership"
+ pattern: "AutoTakeOwnership"
+---
+
+
+Add the auto-take-ownership settings toggle (OWN-01), the PermissionEntry.WasAutoElevated flag, and the IOwnershipElevationService contract+implementation that wraps Tenant.SetSiteAdmin.
+
+Purpose: Establish the data model changes and settings persistence so Plan 02 can wire the scan-loop elevation logic.
+Output: AppSettings extended, SettingsViewModel wired, SettingsView checkbox visible, OwnershipElevationService ready for injection.
+
+
+
+@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
+@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/18-auto-take-ownership/18-RESEARCH.md
+
+
+
+
+From SharepointToolbox/Core/Models/AppSettings.cs:
+```csharp
+public class AppSettings
+{
+ public string DataFolder { get; set; } = string.Empty;
+ public string Lang { get; set; } = "en";
+ // ADD: public bool AutoTakeOwnership { get; set; } = false;
+}
+```
+
+From SharepointToolbox/Core/Models/PermissionEntry.cs:
+```csharp
+public record PermissionEntry(
+ string ObjectType,
+ string Title,
+ string Url,
+ bool HasUniquePermissions,
+ string Users,
+ string UserLogins,
+ string PermissionLevels,
+ string GrantedThrough,
+ string PrincipalType
+ // ADD: bool WasAutoElevated = false -- MUST be last, with default
+);
+```
+
+From SharepointToolbox/Services/SettingsService.cs:
+```csharp
+public class SettingsService
+{
+ public Task GetSettingsAsync();
+ public async Task SetLanguageAsync(string cultureCode);
+ public async Task SetDataFolderAsync(string path);
+ // ADD: public async Task SetAutoTakeOwnershipAsync(bool enabled);
+}
+```
+
+From SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs:
+```csharp
+public partial class SettingsViewModel : FeatureViewModelBase
+{
+ // Constructor: SettingsService settingsService, IBrandingService brandingService, ILogger logger
+ // ADD: AutoTakeOwnership property following DataFolder/SelectedLanguage pattern
+ // ADD: Load in LoadAsync(), persist on set via _settingsService.SetAutoTakeOwnershipAsync
+}
+```
+
+From SharepointToolbox/Views/Tabs/SettingsView.xaml:
+```xml
+
+
+```
+
+
+
+
+
+
+ Task 1: Models + SettingsService + OwnershipElevationService + tests
+
+ SharepointToolbox/Core/Models/AppSettings.cs,
+ SharepointToolbox/Core/Models/PermissionEntry.cs,
+ SharepointToolbox/Services/SettingsService.cs,
+ SharepointToolbox/Services/IOwnershipElevationService.cs,
+ SharepointToolbox/Services/OwnershipElevationService.cs,
+ SharepointToolbox.Tests/Services/OwnershipElevationServiceTests.cs,
+ SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs
+
+
+ - Test: AppSettings.AutoTakeOwnership defaults to false
+ - Test: AppSettings with AutoTakeOwnership=true round-trips through JSON serialization
+ - Test: SettingsService.SetAutoTakeOwnershipAsync persists the value (load -> set -> load -> verify)
+ - Test: PermissionEntry with no WasAutoElevated arg defaults to false (backward compat)
+ - Test: PermissionEntry with WasAutoElevated=true returns true
+ - Test: PermissionEntry `with { WasAutoElevated = true }` produces correct copy
+ - Test: OwnershipElevationService implements IOwnershipElevationService (type check)
+ - Test: SettingsViewModel.AutoTakeOwnership loads false from default settings
+ - Test: SettingsViewModel.AutoTakeOwnership set to true calls SetAutoTakeOwnershipAsync
+
+
+1. Add `public bool AutoTakeOwnership { get; set; } = false;` to `AppSettings.cs`.
+
+2. Append `bool WasAutoElevated = false` as the LAST positional parameter in the `PermissionEntry` record. Must be last with default to avoid breaking existing callsites.
+
+3. Add `SetAutoTakeOwnershipAsync(bool enabled)` to `SettingsService.cs` following the exact pattern of `SetLanguageAsync`:
+ ```csharp
+ public async Task SetAutoTakeOwnershipAsync(bool enabled)
+ {
+ var settings = await _repository.LoadAsync();
+ settings.AutoTakeOwnership = enabled;
+ await _repository.SaveAsync(settings);
+ }
+ ```
+
+4. Create `Services/IOwnershipElevationService.cs`:
+ ```csharp
+ public interface IOwnershipElevationService
+ {
+ Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct);
+ }
+ ```
+
+5. Create `Services/OwnershipElevationService.cs`:
+ ```csharp
+ using Microsoft.Online.SharePoint.TenantAdministration;
+ public class OwnershipElevationService : IOwnershipElevationService
+ {
+ public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct)
+ {
+ var tenant = new Tenant(tenantAdminCtx);
+ tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
+ await tenantAdminCtx.ExecuteQueryAsync();
+ }
+ }
+ ```
+
+6. Register in `App.xaml.cs` DI: `services.AddTransient();` (place near the ISharePointGroupResolver registration).
+
+7. Create test files:
+ - `OwnershipElevationServiceTests.cs`: Type-check test that `OwnershipElevationService` implements `IOwnershipElevationService`. No CSOM mock needed for the interface contract test.
+ - `SettingsViewModelOwnershipTests.cs`: Test that SettingsViewModel loads AutoTakeOwnership from settings and that setting it calls SetAutoTakeOwnershipAsync. Use a mock/fake SettingsService (follow existing test patterns in the test project).
+
+8. Run `dotnet build SharepointToolbox.sln` to confirm zero breakage from PermissionEntry change. All existing callsites use positional args without specifying WasAutoElevated, so the default kicks in.
+
+
+ dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~OwnershipElevation|FullyQualifiedName~SettingsViewModelOwnership"
+
+ AppSettings has AutoTakeOwnership (default false), PermissionEntry has WasAutoElevated (default false), SettingsService has SetAutoTakeOwnershipAsync, IOwnershipElevationService + OwnershipElevationService exist and are DI-registered, all tests pass, full solution builds with zero errors.
+
+
+
+ Task 2: SettingsViewModel property + SettingsView XAML + localization
+
+ SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs,
+ SharepointToolbox/Views/Tabs/SettingsView.xaml,
+ SharepointToolbox/Localization/Strings.resx,
+ SharepointToolbox/Localization/Strings.fr.resx
+
+
+1. In `SettingsViewModel.cs`, add the `AutoTakeOwnership` property following the exact DataFolder pattern:
+ ```csharp
+ private bool _autoTakeOwnership;
+ public bool AutoTakeOwnership
+ {
+ get => _autoTakeOwnership;
+ set
+ {
+ if (_autoTakeOwnership == value) return;
+ _autoTakeOwnership = value;
+ OnPropertyChanged();
+ _ = _settingsService.SetAutoTakeOwnershipAsync(value);
+ }
+ }
+ ```
+
+2. In `LoadAsync()`, after loading `_dataFolder`, add:
+ ```csharp
+ _autoTakeOwnership = settings.AutoTakeOwnership;
+ OnPropertyChanged(nameof(AutoTakeOwnership));
+ ```
+
+3. Add localization keys to `Strings.resx`:
+ - `settings.ownership.title` = "Site Ownership"
+ - `settings.ownership.auto` = "Automatically take site collection admin ownership on access denied"
+ - `settings.ownership.description` = "When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions."
+
+4. Add matching French translations to `Strings.fr.resx`:
+ - `settings.ownership.title` = "Propri\u00e9t\u00e9 du site"
+ - `settings.ownership.auto` = "Prendre automatiquement la propri\u00e9t\u00e9 d'administrateur de collection de sites en cas de refus d'acc\u00e8s"
+ - `settings.ownership.description` = "Lorsqu'activ\u00e9, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'acc\u00e8s. N\u00e9cessite les permissions d'administrateur de tenant."
+
+5. In `SettingsView.xaml`, add a new section AFTER the MSP Logo section (after the logo StackPanel and before the StatusMessage TextBlock):
+ ```xml
+
+
+
+
+
+
+ ```
+
+
+ dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build
+
+ Settings tab shows "Site Ownership" section with checkbox bound to AutoTakeOwnership, defaults unchecked, French locale keys present, LocaleCompletenessTests pass.
+
+
+
+
+
+1. `dotnet build SharepointToolbox.sln` — zero errors, zero warnings
+2. `dotnet test SharepointToolbox.Tests --no-build` — full suite green (no regressions from PermissionEntry change)
+3. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~OwnershipElevation|FullyQualifiedName~SettingsViewModelOwnership" --no-build` — new tests pass
+4. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build` — locale keys complete
+
+
+
+- AppSettings.AutoTakeOwnership exists and defaults to false
+- PermissionEntry.WasAutoElevated exists with default false, all existing tests still pass
+- SettingsService.SetAutoTakeOwnershipAsync persists the toggle
+- IOwnershipElevationService + OwnershipElevationService registered in DI
+- SettingsViewModel loads and persists AutoTakeOwnership
+- SettingsView.xaml shows checkbox for auto-take-ownership
+- All EN + FR localization keys present
+- Full test suite green
+
+
+
diff --git a/.planning/phases/18-auto-take-ownership/18-02-PLAN.md b/.planning/phases/18-auto-take-ownership/18-02-PLAN.md
new file mode 100644
index 0000000..049e1aa
--- /dev/null
+++ b/.planning/phases/18-auto-take-ownership/18-02-PLAN.md
@@ -0,0 +1,329 @@
+---
+phase: 18-auto-take-ownership
+plan: 02
+type: execute
+wave: 2
+depends_on: ["18-01"]
+files_modified:
+ - SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
+ - SharepointToolbox/Views/Tabs/PermissionsView.xaml
+ - SharepointToolbox/Localization/Strings.resx
+ - SharepointToolbox/Localization/Strings.fr.resx
+ - SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs
+autonomous: true
+requirements:
+ - OWN-02
+must_haves:
+ truths:
+ - "When toggle OFF, access-denied exceptions propagate normally (no elevation attempt)"
+ - "When toggle ON and scan hits access denied, app calls ElevateAsync once then retries ScanSiteAsync"
+ - "Successful scans never call ElevateAsync"
+ - "Auto-elevated entries have WasAutoElevated=true in the results"
+ - "Auto-elevated rows are visually distinct in the DataGrid (amber highlight)"
+ artifacts:
+ - path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
+ provides: "Scan-loop catch/elevate/retry logic"
+ contains: "ServerUnauthorizedAccessException"
+ - path: "SharepointToolbox/Views/Tabs/PermissionsView.xaml"
+ provides: "Visual differentiation for auto-elevated rows"
+ contains: "WasAutoElevated"
+ - path: "SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs"
+ provides: "Unit tests for elevation behavior"
+ contains: "PermissionsViewModelOwnershipTests"
+ key_links:
+ - from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
+ to: "SharepointToolbox/Services/IOwnershipElevationService.cs"
+ via: "ElevateAsync call inside catch block"
+ pattern: "ElevateAsync"
+ - from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
+ to: "SharepointToolbox/Services/SettingsService.cs"
+ via: "Reading AutoTakeOwnership toggle state"
+ pattern: "AutoTakeOwnership"
+ - from: "SharepointToolbox/Views/Tabs/PermissionsView.xaml"
+ to: "SharepointToolbox/Core/Models/PermissionEntry.cs"
+ via: "DataTrigger on WasAutoElevated"
+ pattern: "WasAutoElevated"
+---
+
+
+Wire the auto-elevation logic into the permission scan loop: catch ServerUnauthorizedAccessException, call Tenant.SetSiteAdmin via IOwnershipElevationService, retry the scan, and tag returned entries with WasAutoElevated=true. Add visual differentiation in the DataGrid.
+
+Purpose: Complete OWN-02 — scans no longer block on access-denied sites when the toggle is ON.
+Output: PermissionsViewModel catches access denied and auto-elevates, DataGrid shows amber highlight for elevated rows.
+
+
+
+@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
+@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/18-auto-take-ownership/18-RESEARCH.md
+@.planning/phases/18-auto-take-ownership/18-01-SUMMARY.md
+
+
+
+
+From SharepointToolbox/Services/IOwnershipElevationService.cs:
+```csharp
+public interface IOwnershipElevationService
+{
+ Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct);
+}
+```
+
+From SharepointToolbox/Core/Models/PermissionEntry.cs (after Plan 01):
+```csharp
+public record PermissionEntry(
+ string ObjectType, string Title, string Url,
+ bool HasUniquePermissions, string Users, string UserLogins,
+ string PermissionLevels, string GrantedThrough, string PrincipalType,
+ bool WasAutoElevated = false
+);
+```
+
+From SharepointToolbox/Core/Models/AppSettings.cs (after Plan 01):
+```csharp
+public class AppSettings
+{
+ public string DataFolder { get; set; } = string.Empty;
+ public string Lang { get; set; } = "en";
+ public bool AutoTakeOwnership { get; set; } = false;
+}
+```
+
+From SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs (existing scan loop):
+```csharp
+// Lines 202-237: RunOperationAsync — foreach (var url in nonEmpty)
+// Creates TenantProfile per URL, gets ctx via _sessionManager.GetOrCreateContextAsync
+// Calls _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct)
+// Adds entries to allEntries
+
+// Constructor (production): IPermissionsService, ISiteListService, ISessionManager,
+// CsvExportService, HtmlExportService, IBrandingService, ILogger, ISharePointGroupResolver?
+// Constructor (test): IPermissionsService, ISiteListService, ISessionManager, ILogger, IBrandingService?
+```
+
+From SharepointToolbox/App.xaml.cs (DI):
+```csharp
+// Line 119-124: PermissionsViewModel registered as AddTransient
+// IOwnershipElevationService registered by Plan 01
+```
+
+
+
+
+
+
+ Task 1: Scan-loop elevation logic + PermissionsViewModel wiring + tests
+
+ SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs,
+ SharepointToolbox/App.xaml.cs,
+ SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs
+
+
+ - Test: When AutoTakeOwnership=false and ScanSiteAsync throws ServerUnauthorizedAccessException, the exception propagates (not caught)
+ - Test: When AutoTakeOwnership=true and ScanSiteAsync throws ServerUnauthorizedAccessException, ElevateAsync is called once with correct args, then ScanSiteAsync retried
+ - Test: When ScanSiteAsync succeeds on first try, ElevateAsync is never called
+ - Test: After successful elevation+retry, returned PermissionEntry items have WasAutoElevated=true
+ - Test: If elevation itself throws, the exception propagates (no infinite retry)
+
+
+1. Add `IOwnershipElevationService? _ownershipService` and `SettingsService _settingsService` fields to `PermissionsViewModel`. Inject `IOwnershipElevationService?` as an optional last parameter in the production constructor (matching the `ISharePointGroupResolver?` pattern). Inject `SettingsService` as a required parameter.
+
+2. Update both constructors:
+ - Production constructor: add `SettingsService settingsService` (required) and `IOwnershipElevationService? ownershipService = null` (optional, last).
+ - Test constructor: add `SettingsService? settingsService = null` and `IOwnershipElevationService? ownershipService = null` as optional params.
+
+3. Update DI registration in `App.xaml.cs` — no change needed if using optional injection (DI resolves registered services automatically). But verify the constructor parameter order matches DI expectations. If needed, add explicit resolution.
+
+4. Add a `DeriveAdminUrl` internal static helper method in `PermissionsViewModel`:
+ ```csharp
+ 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}";
+ }
+ ```
+
+5. Modify `RunOperationAsync` scan loop (lines 221-237). Replace the direct `ScanSiteAsync` call with a try/catch pattern. Catch BOTH `ServerUnauthorizedAccessException` and `WebException` with 403 status (see Pitfall 4 in RESEARCH.md). Use `when` filter to check toggle state:
+
+ ```csharp
+ foreach (var url in nonEmpty)
+ {
+ ct.ThrowIfCancellationRequested();
+ progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
+
+ var profile = new TenantProfile
+ {
+ TenantUrl = url,
+ ClientId = _currentProfile?.ClientId ?? string.Empty,
+ Name = _currentProfile?.Name ?? string.Empty
+ };
+
+ 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 && await IsAutoTakeOwnershipEnabled())
+ {
+ _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);
+ // Get current user login from the site context
+ var siteProfile = profile;
+ var siteCtx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
+ siteCtx.Load(siteCtx.Web, w => w.CurrentUser);
+ await siteCtx.ExecuteQueryAsync();
+ var loginName = siteCtx.Web.CurrentUser.LoginName;
+
+ await _ownershipService.ElevateAsync(adminCtx, url, loginName, 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++;
+ }
+ ```
+
+6. Add the `IsAccessDenied` helper (private static):
+ ```csharp
+ 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;
+ }
+ ```
+
+7. Add the `IsAutoTakeOwnershipEnabled` helper (private async):
+ ```csharp
+ private async Task IsAutoTakeOwnershipEnabled()
+ {
+ if (_settingsService == null) return false;
+ var settings = await _settingsService.GetSettingsAsync();
+ return settings.AutoTakeOwnership;
+ }
+ ```
+
+8. Create `PermissionsViewModelOwnershipTests.cs` with mock IPermissionsService, ISessionManager, IOwnershipElevationService, and SettingsService. Test all 5 behaviors listed above. Use the internal test constructor. For the "throws ServerUnauthorizedAccessException" test, configure mock ScanSiteAsync to throw on first call then return entries on second call.
+
+IMPORTANT: The `when` clause with `await` requires C# 8+ async exception filters. If the compiler rejects `await` in `when`, refactor to check settings BEFORE the try block: `var autoOwn = await IsAutoTakeOwnershipEnabled();` then use `when (IsAccessDenied(ex) && _ownershipService != null && autoOwn)`.
+
+
+ dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~PermissionsViewModelOwnership"
+
+ PermissionsViewModel catches access-denied during scans, auto-elevates via IOwnershipElevationService when toggle is ON, retries the scan, and tags entries with WasAutoElevated=true. Toggle OFF = no change in behavior. All 5 test scenarios pass.
+
+
+
+ Task 2: DataGrid visual differentiation + localization for elevated rows
+
+ SharepointToolbox/Views/Tabs/PermissionsView.xaml,
+ SharepointToolbox/Localization/Strings.resx,
+ SharepointToolbox/Localization/Strings.fr.resx
+
+
+1. In `PermissionsView.xaml`, add a DataTrigger for `WasAutoElevated` inside the `DataGrid.RowStyle` (after the existing RiskLevel triggers, around line 234):
+ ```xml
+
+
+
+
+ ```
+
+ Note: WasAutoElevated is on `PermissionEntry` (raw mode). When simplified mode is active, `SimplifiedPermissionEntry` wraps `PermissionEntry` — check whether `SimplifiedPermissionEntry.WrapAll` preserves the `WasAutoElevated` flag. If `SimplifiedPermissionEntry` does not expose it, the trigger only applies in raw mode (acceptable for v2.3).
+
+2. Add a small indicator column in the DataGrid columns (before "Object Type"), showing a lock icon for elevated rows:
+ ```xml
+
+
+
+
+
+
+
+ ```
+
+ If `BoolToVisibilityConverter` is not registered, use a DataTrigger style instead:
+ ```xml
+
+
+
+
+
+
+
+
+
+
+
+ ```
+
+3. Add localization keys to `Strings.resx`:
+ - `permissions.elevated.tooltip` = "This site was automatically elevated — ownership was taken to complete the scan"
+
+4. Add French translation to `Strings.fr.resx`:
+ - `permissions.elevated.tooltip` = "Ce site a ete eleve automatiquement — la propriete a ete prise pour completer le scan"
+
+
+ dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build
+
+ DataGrid rows with WasAutoElevated=true show amber background + warning icon column. Tooltip explains the elevation. EN + FR localization keys present. LocaleCompletenessTests pass.
+
+
+
+
+
+1. `dotnet build SharepointToolbox.sln` — zero errors
+2. `dotnet test SharepointToolbox.Tests --no-build` — full suite green
+3. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~PermissionsViewModelOwnership" --no-build` — elevation tests pass
+4. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build` — locale keys complete
+
+
+
+- Access-denied during scan with toggle ON triggers auto-elevation + retry
+- Access-denied during scan with toggle OFF propagates normally
+- Successful scans never attempt elevation
+- Elevated entries tagged with WasAutoElevated=true
+- Elevated rows visually distinct in DataGrid (amber + icon)
+- Full test suite green with no regressions
+
+
+