Files
Sharepoint-Toolbox/.planning/phases/18-auto-take-ownership/18-02-PLAN.md
Dev dbb59d119b docs(18): create phase plan for auto-take-ownership
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:15:15 +02:00

15 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
18-auto-take-ownership 02 execute 2
18-01
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
SharepointToolbox/Views/Tabs/PermissionsView.xaml
SharepointToolbox/Localization/Strings.resx
SharepointToolbox/Localization/Strings.fr.resx
SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs
true
OWN-02
truths artifacts key_links
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)
path provides contains
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs Scan-loop catch/elevate/retry logic ServerUnauthorizedAccessException
path provides contains
SharepointToolbox/Views/Tabs/PermissionsView.xaml Visual differentiation for auto-elevated rows WasAutoElevated
path provides contains
SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs Unit tests for elevation behavior PermissionsViewModelOwnershipTests
from to via pattern
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs SharepointToolbox/Services/IOwnershipElevationService.cs ElevateAsync call inside catch block ElevateAsync
from to via pattern
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs SharepointToolbox/Services/SettingsService.cs Reading AutoTakeOwnership toggle state AutoTakeOwnership
from to via pattern
SharepointToolbox/Views/Tabs/PermissionsView.xaml SharepointToolbox/Core/Models/PermissionEntry.cs DataTrigger on WasAutoElevated 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.

<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>

@.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:

public interface IOwnershipElevationService
{
    Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct);
}

From SharepointToolbox/Core/Models/PermissionEntry.cs (after Plan 01):

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):

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):

// 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):

// 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.
  1. 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.
  2. 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.

  3. Add a DeriveAdminUrl internal static helper method in PermissionsViewModel:

    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}";
    }
    
  4. 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:

    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++;
    }
    
  5. Add the IsAccessDenied helper (private static):

    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;
    }
    
  6. Add the IsAutoTakeOwnershipEnabled helper (private async):

    private async Task<bool> IsAutoTakeOwnershipEnabled()
    {
        if (_settingsService == null) return false;
        var settings = await _settingsService.GetSettingsAsync();
        return settings.AutoTakeOwnership;
    }
    
  7. 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).

  1. Add a small indicator column in the DataGrid columns (before "Object Type"), showing a lock icon for elevated rows:

    <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:

    <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>
    
  2. Add localization keys to Strings.resx:

    • permissions.elevated.tooltip = "This site was automatically elevated — ownership was taken to complete the scan"
  3. 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

<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>
After completion, create `.planning/phases/18-auto-take-ownership/18-02-SUMMARY.md`