docs(18): create phase plan for auto-take-ownership

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-09 14:15:15 +02:00
parent 997086cf07
commit dbb59d119b
3 changed files with 631 additions and 3 deletions

View File

@@ -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 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 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) - [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 19: App Registration & Removal** — Automated Entra app registration with guided fallback and clean removal
## Phase Details ## 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) 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 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) 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 ### 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 **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 | | 15. Consolidation Data Model | v2.3 | 2/2 | Complete | 2026-04-09 |
| 16. Report Consolidation Toggle | 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 | — | | 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 | — | | 19. App Registration & Removal | v2.3 | 0/? | Not started | — |

View File

@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-auto-take-ownership/18-RESEARCH.md
<interfaces>
<!-- Existing contracts the executor needs -->
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<AppSettings> 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
<!-- Existing: StackPanel with Language, Data folder, MSP Logo sections -->
<!-- ADD: Auto-Take Ownership section with CheckBox after MSP Logo, before StatusMessage -->
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Models + SettingsService + OwnershipElevationService + tests</name>
<files>
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
</files>
<behavior>
- 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
</behavior>
<action>
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<IOwnershipElevationService, OwnershipElevationService>();` (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.
</action>
<verify>
<automated>dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~OwnershipElevation|FullyQualifiedName~SettingsViewModelOwnership"</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: SettingsViewModel property + SettingsView XAML + localization</name>
<files>
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs,
SharepointToolbox/Views/Tabs/SettingsView.xaml,
SharepointToolbox/Localization/Strings.resx,
SharepointToolbox/Localization/Strings.fr.resx
</files>
<action>
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
<Separator Margin="0,12" />
<!-- Auto-Take Ownership -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.title]}" />
<CheckBox IsChecked="{Binding AutoTakeOwnership}"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.auto]}"
Margin="0,4,0,0" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.description]}"
Foreground="#666666" FontSize="11" TextWrapping="Wrap" Margin="20,4,0,0" />
```
</action>
<verify>
<automated>dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build</automated>
</verify>
<done>Settings tab shows "Site Ownership" section with checkbox bound to AutoTakeOwnership, defaults unchecked, French locale keys present, LocaleCompletenessTests pass.</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/18-auto-take-ownership/18-01-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- From Plan 01 outputs -->
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
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Scan-loop elevation logic + PermissionsViewModel wiring + tests</name>
<files>
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs,
SharepointToolbox/App.xaml.cs,
SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs
</files>
<behavior>
- 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)
</behavior>
<action>
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<PermissionEntry> 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<bool> 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)`.
</action>
<verify>
<automated>dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~PermissionsViewModelOwnership"</automated>
</verify>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: DataGrid visual differentiation + localization for elevated rows</name>
<files>
SharepointToolbox/Views/Tabs/PermissionsView.xaml,
SharepointToolbox/Localization/Strings.resx,
SharepointToolbox/Localization/Strings.fr.resx
</files>
<action>
1. In `PermissionsView.xaml`, add a DataTrigger for `WasAutoElevated` inside the `DataGrid.RowStyle` (after the existing RiskLevel triggers, around line 234):
```xml
<DataTrigger Binding="{Binding WasAutoElevated}" Value="True">
<Setter Property="Background" Value="#FFF9E6" />
<Setter Property="ToolTip" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[permissions.elevated.tooltip]}" />
</DataTrigger>
```
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
<DataGridTemplateColumn Header="" Width="24" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="&#x1F513;" FontSize="12" HorizontalAlignment="Center"
Visibility="{Binding WasAutoElevated, Converter={StaticResource BoolToVisibilityConverter}}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
```
If `BoolToVisibilityConverter` is not registered, use a DataTrigger style instead:
```xml
<DataGridTemplateColumn Header="" Width="24" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="&#x26A0;" FontSize="12" HorizontalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding WasAutoElevated}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
```
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"
</action>
<verify>
<automated>dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build</automated>
</verify>
<done>DataGrid rows with WasAutoElevated=true show amber background + warning icon column. Tooltip explains the elevation. EN + FR localization keys present. LocaleCompletenessTests pass.</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/18-auto-take-ownership/18-02-SUMMARY.md`
</output>