- Add _settingsService and _ownershipService fields to PermissionsViewModel - Add SettingsService? and IOwnershipElevationService? to both constructors - Add DeriveAdminUrl internal static helper for admin URL derivation - Add IsAccessDenied helper catching ServerUnauthorizedAccessException + WebException 403 - Add IsAutoTakeOwnershipEnabled async helper reading toggle from SettingsService - Refactor RunOperationAsync with try/catch elevation pattern (read toggle before loop) - Tag elevated entries with WasAutoElevated=true via record with expression - Add PermissionsViewModelOwnershipTests (8 tests): toggle OFF propagates, toggle ON elevates+retries, no elevation on success, WasAutoElevated tagging, elevation throw propagates, DeriveAdminUrl theory
462 lines
18 KiB
C#
462 lines
18 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using SharepointToolbox.Core.Messages;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Services;
|
|
using SharepointToolbox.Core.Helpers;
|
|
using SharepointToolbox.Services.Export;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the Permissions tab.
|
|
/// Orchestrates permission scanning across one or multiple SharePoint sites
|
|
/// and exports results to CSV or HTML.
|
|
/// </summary>
|
|
public partial class PermissionsViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly IPermissionsService _permissionsService;
|
|
private readonly ISiteListService _siteListService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly CsvExportService? _csvExportService;
|
|
private readonly HtmlExportService? _htmlExportService;
|
|
private readonly IBrandingService? _brandingService;
|
|
private readonly ISharePointGroupResolver? _groupResolver;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
private readonly SettingsService? _settingsService;
|
|
private readonly IOwnershipElevationService? _ownershipService;
|
|
|
|
// ── Observable properties ───────────────────────────────────────────────
|
|
|
|
[ObservableProperty]
|
|
private bool _includeInherited;
|
|
|
|
[ObservableProperty]
|
|
private bool _scanFolders = true;
|
|
|
|
/// <summary>Placeholder for the Export Options toggle — no-op in PermissionsViewModel (reserved for future use).</summary>
|
|
[ObservableProperty]
|
|
private bool _mergePermissions;
|
|
|
|
[ObservableProperty]
|
|
private bool _includeSubsites;
|
|
|
|
[ObservableProperty]
|
|
private int _folderDepth = 1;
|
|
|
|
/// <summary>
|
|
/// When true, sets FolderDepth to 999 (scan all levels).
|
|
/// </summary>
|
|
public bool IsMaxDepth
|
|
{
|
|
get => FolderDepth >= 999;
|
|
set
|
|
{
|
|
if (value)
|
|
FolderDepth = 999;
|
|
else if (FolderDepth >= 999)
|
|
FolderDepth = 1;
|
|
OnPropertyChanged();
|
|
}
|
|
}
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<PermissionEntry> _results = new();
|
|
|
|
/// <summary>
|
|
/// When true, displays simplified plain-language labels instead of raw SharePoint role names.
|
|
/// Toggling does not re-run the scan.
|
|
/// </summary>
|
|
[ObservableProperty]
|
|
private bool _isSimplifiedMode;
|
|
|
|
/// <summary>
|
|
/// When true, shows individual item-level rows (detailed view).
|
|
/// When false, shows only summary rows grouped by risk level (simple view).
|
|
/// Only meaningful when IsSimplifiedMode is true.
|
|
/// </summary>
|
|
[ObservableProperty]
|
|
private bool _isDetailView = true;
|
|
|
|
/// <summary>
|
|
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
|
|
/// </summary>
|
|
private IReadOnlyList<SimplifiedPermissionEntry> _simplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
|
|
public IReadOnlyList<SimplifiedPermissionEntry> SimplifiedResults
|
|
{
|
|
get => _simplifiedResults;
|
|
private set => SetProperty(ref _simplifiedResults, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Summary counts grouped by risk level. Rebuilt when SimplifiedResults changes.
|
|
/// </summary>
|
|
private IReadOnlyList<PermissionSummary> _summaries = Array.Empty<PermissionSummary>();
|
|
public IReadOnlyList<PermissionSummary> Summaries
|
|
{
|
|
get => _summaries;
|
|
private set => SetProperty(ref _summaries, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The collection the DataGrid actually binds to. Returns:
|
|
/// - Results (raw) when simplified mode is OFF
|
|
/// - SimplifiedResults when simplified mode is ON and detail view is ON
|
|
/// - (View handles summary display separately via Summaries property)
|
|
/// </summary>
|
|
public object ActiveItemsSource => IsSimplifiedMode
|
|
? (object)SimplifiedResults
|
|
: Results;
|
|
|
|
partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth));
|
|
|
|
// ── Commands ────────────────────────────────────────────────────────────
|
|
|
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
|
|
|
// ── Current tenant profile (received via WeakReferenceMessenger) ────────
|
|
|
|
internal TenantProfile? _currentProfile;
|
|
|
|
// ── Constructors ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Full constructor — used by DI and production code.
|
|
/// </summary>
|
|
public PermissionsViewModel(
|
|
IPermissionsService permissionsService,
|
|
ISiteListService siteListService,
|
|
ISessionManager sessionManager,
|
|
CsvExportService csvExportService,
|
|
HtmlExportService htmlExportService,
|
|
IBrandingService brandingService,
|
|
ILogger<FeatureViewModelBase> logger,
|
|
ISharePointGroupResolver? groupResolver = null,
|
|
SettingsService? settingsService = null,
|
|
IOwnershipElevationService? ownershipService = null)
|
|
: base(logger)
|
|
{
|
|
_permissionsService = permissionsService;
|
|
_siteListService = siteListService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = csvExportService;
|
|
_htmlExportService = htmlExportService;
|
|
_brandingService = brandingService;
|
|
_groupResolver = groupResolver;
|
|
_logger = logger;
|
|
_settingsService = settingsService;
|
|
_ownershipService = ownershipService;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test constructor — omits export services (not needed for unit tests).
|
|
/// </summary>
|
|
internal PermissionsViewModel(
|
|
IPermissionsService permissionsService,
|
|
ISiteListService siteListService,
|
|
ISessionManager sessionManager,
|
|
ILogger<FeatureViewModelBase> logger,
|
|
IBrandingService? brandingService = null,
|
|
SettingsService? settingsService = null,
|
|
IOwnershipElevationService? ownershipService = null)
|
|
: base(logger)
|
|
{
|
|
_permissionsService = permissionsService;
|
|
_siteListService = siteListService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = null;
|
|
_htmlExportService = null;
|
|
_brandingService = brandingService;
|
|
_logger = logger;
|
|
_settingsService = settingsService;
|
|
_ownershipService = ownershipService;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
}
|
|
|
|
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
|
|
|
partial void OnIsSimplifiedModeChanged(bool value)
|
|
{
|
|
if (value && Results.Count > 0)
|
|
RebuildSimplifiedData();
|
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
|
}
|
|
|
|
partial void OnIsDetailViewChanged(bool value)
|
|
{
|
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recomputes SimplifiedResults and Summaries from the current Results collection.
|
|
/// Called when Results changes or when simplified mode is toggled on.
|
|
/// </summary>
|
|
private void RebuildSimplifiedData()
|
|
{
|
|
SimplifiedResults = SimplifiedPermissionEntry.WrapAll(Results);
|
|
Summaries = PermissionSummaryBuilder.Build(SimplifiedResults);
|
|
}
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
|
if (urls.Count == 0)
|
|
{
|
|
StatusMessage = "Select at least one site from the toolbar.";
|
|
return;
|
|
}
|
|
|
|
var nonEmpty = urls;
|
|
|
|
var allEntries = new List<PermissionEntry>();
|
|
var scanOptions = new ScanOptions(
|
|
IncludeInherited: IncludeInherited,
|
|
ScanFolders: ScanFolders,
|
|
FolderDepth: FolderDepth,
|
|
IncludeSubsites: IncludeSubsites);
|
|
|
|
// Read toggle once before the loop (avoids async in exception filter)
|
|
var autoOwnership = await IsAutoTakeOwnershipEnabled();
|
|
|
|
int i = 0;
|
|
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 && autoOwnership)
|
|
{
|
|
_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);
|
|
await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, 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++;
|
|
}
|
|
|
|
// Update Results on the UI thread (no-op if no Dispatcher in tests)
|
|
var dispatcher = Application.Current?.Dispatcher;
|
|
if (dispatcher != null)
|
|
{
|
|
await dispatcher.InvokeAsync(() =>
|
|
{
|
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
|
if (IsSimplifiedMode)
|
|
RebuildSimplifiedData();
|
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
|
});
|
|
}
|
|
else
|
|
{
|
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
|
if (IsSimplifiedMode)
|
|
RebuildSimplifiedData();
|
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
|
}
|
|
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ── Auto-ownership helpers ───────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Derives the tenant admin URL from a standard tenant URL.
|
|
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
|
|
/// </summary>
|
|
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}";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async Task<bool> IsAutoTakeOwnershipEnabled()
|
|
{
|
|
if (_settingsService == null) return false;
|
|
var settings = await _settingsService.GetSettingsAsync();
|
|
return settings.AutoTakeOwnership;
|
|
}
|
|
|
|
// ── Tenant switching ─────────────────────────────────────────────────────
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
Results = new ObservableCollection<PermissionEntry>();
|
|
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
|
|
Summaries = Array.Empty<PermissionSummary>();
|
|
OnPropertyChanged(nameof(ActiveItemsSource));
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ── Internal helpers ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>Sets the current tenant profile (for test injection).</summary>
|
|
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
|
|
|
/// <summary>Exposes RunOperationAsync for unit tests (internal + InternalsVisibleTo).</summary>
|
|
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
=> RunOperationAsync(ct, progress);
|
|
|
|
// ── Command implementations ───────────────────────────────────────────────
|
|
|
|
private bool CanExport() => Results.Count > 0;
|
|
|
|
private async Task ExportCsvAsync()
|
|
{
|
|
if (_csvExportService == null || Results.Count == 0) return;
|
|
var dialog = new SaveFileDialog
|
|
{
|
|
Title = "Export permissions to CSV",
|
|
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
|
|
DefaultExt = "csv",
|
|
FileName = "permissions"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
|
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
|
|
else
|
|
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
|
OpenFile(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Export failed: {ex.Message}";
|
|
_logger.LogError(ex, "CSV export failed.");
|
|
}
|
|
}
|
|
|
|
private async Task ExportHtmlAsync()
|
|
{
|
|
if (_htmlExportService == null || Results.Count == 0) return;
|
|
var dialog = new SaveFileDialog
|
|
{
|
|
Title = "Export permissions to HTML",
|
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
|
DefaultExt = "html",
|
|
FileName = "permissions"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
ReportBranding? branding = null;
|
|
if (_brandingService is not null)
|
|
{
|
|
var mspLogo = await _brandingService.GetMspLogoAsync();
|
|
var clientLogo = _currentProfile?.ClientLogo;
|
|
branding = new ReportBranding(mspLogo, clientLogo);
|
|
}
|
|
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
|
|
if (_groupResolver != null && Results.Count > 0)
|
|
{
|
|
var groupNames = Results
|
|
.Where(r => r.PrincipalType == "SharePointGroup")
|
|
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
|
.Select(n => n.Trim())
|
|
.Where(n => n.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
if (groupNames.Count > 0 && _currentProfile != null)
|
|
{
|
|
try
|
|
{
|
|
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
|
_currentProfile, CancellationToken.None);
|
|
groupMembers = await _groupResolver.ResolveGroupsAsync(
|
|
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
|
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
|
|
else
|
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
|
|
OpenFile(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Export failed: {ex.Message}";
|
|
_logger.LogError(ex, "HTML export failed.");
|
|
}
|
|
}
|
|
|
|
private static void OpenFile(string filePath)
|
|
{
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
|
|
}
|
|
catch
|
|
{
|
|
// Non-critical: file was written successfully, just can't auto-open
|
|
}
|
|
}
|
|
}
|