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 |
|
|
true |
|
|
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.mdFrom 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
-
Update both constructors:
- Production constructor: add
SettingsService settingsService(required) andIOwnershipElevationService? ownershipService = null(optional, last). - Test constructor: add
SettingsService? settingsService = nullandIOwnershipElevationService? ownershipService = nullas optional params.
- Production constructor: add
-
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. -
Add a
DeriveAdminUrlinternal static helper method inPermissionsViewModel: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}"; } -
Modify
RunOperationAsyncscan loop (lines 221-237). Replace the directScanSiteAsynccall with a try/catch pattern. Catch BOTHServerUnauthorizedAccessExceptionandWebExceptionwith 403 status (see Pitfall 4 in RESEARCH.md). Usewhenfilter 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++; } -
Add the
IsAccessDeniedhelper (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; } -
Add the
IsAutoTakeOwnershipEnabledhelper (private async):private async Task<bool> IsAutoTakeOwnershipEnabled() { if (_settingsService == null) return false; var settings = await _settingsService.GetSettingsAsync(); return settings.AutoTakeOwnership; } -
Create
PermissionsViewModelOwnershipTests.cswith 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.
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).
-
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="🔓" FontSize="12" HorizontalAlignment="Center" Visibility="{Binding WasAutoElevated, Converter={StaticResource BoolToVisibilityConverter}}" /> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn>If
BoolToVisibilityConverteris not registered, use a DataTrigger style instead:<DataGridTemplateColumn Header="" Width="24" IsReadOnly="True"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock Text="⚠" 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> -
Add localization keys to
Strings.resx:permissions.elevated.tooltip= "This site was automatically elevated — ownership was taken to complete the scan"
-
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.
<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>