From dbb59d119b69348b6ae9e6dfec2b13dd8efd5b2d Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 9 Apr 2026 14:15:15 +0200 Subject: [PATCH] docs(18): create phase plan for auto-take-ownership Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/ROADMAP.md | 9 +- .../18-auto-take-ownership/18-01-PLAN.md | 296 ++++++++++++++++ .../18-auto-take-ownership/18-02-PLAN.md | 329 ++++++++++++++++++ 3 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/18-auto-take-ownership/18-01-PLAN.md create mode 100644 .planning/phases/18-auto-take-ownership/18-02-PLAN.md 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 + + + +After completion, create `.planning/phases/18-auto-take-ownership/18-01-SUMMARY.md` + 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 + + + +After completion, create `.planning/phases/18-auto-take-ownership/18-02-SUMMARY.md` +