From 9f2e2f9899139c71d1406b4a3f4be415aa184b37 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 13:53:45 +0200 Subject: [PATCH] fix(02-01): add export service stubs and fix PermissionsService compile errors [Rule 3 - Blocking] CsvExportService/HtmlExportService stubs added so export test files compile. [Rule 1 - Bug] PermissionsService: removed Principal.Email (not on Principal, only on User) and changed folder param from Folder to ListItem (SecurableObject). --- .../Services/Export/CsvExportService.cs | 18 + .../Services/Export/HtmlExportService.cs | 18 + .../Services/PermissionsService.cs | 340 ++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 SharepointToolbox/Services/Export/CsvExportService.cs create mode 100644 SharepointToolbox/Services/Export/HtmlExportService.cs create mode 100644 SharepointToolbox/Services/PermissionsService.cs diff --git a/SharepointToolbox/Services/Export/CsvExportService.cs b/SharepointToolbox/Services/Export/CsvExportService.cs new file mode 100644 index 0000000..f4397b2 --- /dev/null +++ b/SharepointToolbox/Services/Export/CsvExportService.cs @@ -0,0 +1,18 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +/// +/// Exports permission entries to CSV format. +/// Full implementation will be added in Plan 03. +/// +public class CsvExportService +{ + /// Builds a CSV string from the supplied permission entries. + public string BuildCsv(IReadOnlyList entries) => + throw new NotImplementedException("CsvExportService.BuildCsv — implemented in Plan 03"); + + /// Writes the CSV output to a file. + public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) => + throw new NotImplementedException("CsvExportService.WriteAsync — implemented in Plan 03"); +} diff --git a/SharepointToolbox/Services/Export/HtmlExportService.cs b/SharepointToolbox/Services/Export/HtmlExportService.cs new file mode 100644 index 0000000..c063e06 --- /dev/null +++ b/SharepointToolbox/Services/Export/HtmlExportService.cs @@ -0,0 +1,18 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services.Export; + +/// +/// Exports permission entries to HTML format. +/// Full implementation will be added in Plan 03. +/// +public class HtmlExportService +{ + /// Builds an HTML string from the supplied permission entries. + public string BuildHtml(IReadOnlyList entries) => + throw new NotImplementedException("HtmlExportService.BuildHtml — implemented in Plan 03"); + + /// Writes the HTML output to a file. + public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) => + throw new NotImplementedException("HtmlExportService.WriteAsync — implemented in Plan 03"); +} diff --git a/SharepointToolbox/Services/PermissionsService.cs b/SharepointToolbox/Services/PermissionsService.cs new file mode 100644 index 0000000..a45f755 --- /dev/null +++ b/SharepointToolbox/Services/PermissionsService.cs @@ -0,0 +1,340 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +/// +/// CSOM scan engine — faithful C# port of the PowerShell Generate-PnPSitePermissionRpt +/// and Get-PnPPermissions functions (PS reference lines 1361-1989). +/// +public class PermissionsService : IPermissionsService +{ + // Port of PS lines 1914-1926: system lists excluded from permission reporting + private static readonly HashSet ExcludedLists = new(StringComparer.OrdinalIgnoreCase) + { + "Access Requests", "App Packages", "appdata", "appfiles", "Apps in Testing", + "Cache Profiles", "Composed Looks", "Content and Structure Reports", + "Content type publishing error log", "Converted Forms", "Device Channels", + "Form Templates", "fpdatasources", "List Template Gallery", + "Long Running Operation Status", "Maintenance Log Library", "Images", + "site collection images", "Master Docs", "Master Page Gallery", "MicroFeed", + "NintexFormXml", "Quick Deploy Items", "Relationships List", "Reusable Content", + "Reporting Metadata", "Reporting Templates", "Search Config List", "Site Assets", + "Preservation Hold Library", "Site Pages", "Solution Gallery", "Style Library", + "Suggested Content Browser Locations", "Theme Gallery", "TaxonomyHiddenList", + "User Information List", "Web Part Gallery", "wfpub", "wfsvc", + "Workflow History", "Workflow Tasks", "Pages" + }; + + public async Task> ScanSiteAsync( + ClientContext ctx, + ScanOptions options, + IProgress progress, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var results = new List(); + + // 1. Site collection administrators + progress.Report(OperationProgress.Indeterminate("Scanning site collection admins…")); + results.AddRange(await GetSiteCollectionAdminsAsync(ctx, progress, ct)); + + // 2. Web-level permissions + ctx.Load(ctx.Web, + w => w.Title, + w => w.Url, + w => w.Lists.Include( + l => l.Title, l => l.DefaultViewUrl, l => l.Hidden, + l => l.BaseType, l => l.IsSystemList), + w => w.Webs.Include(sw => sw.Title, sw => sw.Url)); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + progress.Report(OperationProgress.Indeterminate($"Scanning web: {ctx.Web.Url}…")); + results.AddRange(await GetWebPermissionsAsync(ctx, ctx.Web, options, progress, ct)); + + // 3. Lists and libraries + foreach (var list in ctx.Web.Lists) + { + ct.ThrowIfCancellationRequested(); + if (list.Hidden || list.IsSystemList || ExcludedLists.Contains(list.Title)) + continue; + + progress.Report(OperationProgress.Indeterminate($"Scanning list: {list.Title}…")); + results.AddRange(await GetListPermissionsAsync(ctx, list, options, progress, ct)); + + // 4. Folders (if configured) + if (options.ScanFolders && list.BaseType == BaseType.DocumentLibrary) + results.AddRange(await GetFolderPermissionsAsync(ctx, list, options, progress, ct)); + } + + // 5. Subsites (if configured) + if (options.IncludeSubsites) + { + foreach (var subweb in ctx.Web.Webs) + { + ct.ThrowIfCancellationRequested(); + using var subCtx = ctx.Clone(subweb.Url); + subCtx.Load(subCtx.Web, + w => w.Title, + w => w.Url, + w => w.Lists.Include( + l => l.Title, l => l.DefaultViewUrl, l => l.Hidden, + l => l.BaseType, l => l.IsSystemList), + w => w.Webs.Include(sw => sw.Title, sw => sw.Url)); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(subCtx, progress, ct); + + progress.Report(OperationProgress.Indeterminate($"Scanning subsite: {subweb.Url}…")); + results.AddRange(await GetWebPermissionsAsync(subCtx, subCtx.Web, options, progress, ct)); + + foreach (var list in subCtx.Web.Lists) + { + ct.ThrowIfCancellationRequested(); + if (list.Hidden || list.IsSystemList || ExcludedLists.Contains(list.Title)) + continue; + + results.AddRange(await GetListPermissionsAsync(subCtx, list, options, progress, ct)); + + if (options.ScanFolders && list.BaseType == BaseType.DocumentLibrary) + results.AddRange(await GetFolderPermissionsAsync(subCtx, list, options, progress, ct)); + } + } + } + + return results; + } + + /// + /// Returns a single PermissionEntry for site collection administrators as a group. + /// Port of PS lines 1361-1400: Get site collection admins via SiteUsers filter. + /// + private async Task> GetSiteCollectionAdminsAsync( + ClientContext ctx, + IProgress progress, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + ctx.Load(ctx.Web, w => w.Url, w => w.Title); + ctx.Load(ctx.Web.SiteUsers, + users => users.Include( + u => u.Title, + u => u.LoginName, + u => u.IsSiteAdmin)); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + var admins = ctx.Web.SiteUsers + .Where(u => u.IsSiteAdmin) + .ToList(); + + if (admins.Count == 0) + return Enumerable.Empty(); + + var users = string.Join(";", admins.Select(u => u.Title)); + var logins = string.Join(";", admins.Select(u => u.LoginName)); + + return new[] + { + new PermissionEntry( + ObjectType: "Site Collection", + Title: ctx.Web.Title, + Url: ctx.Web.Url, + HasUniquePermissions: true, + Users: users, + UserLogins: logins, + PermissionLevels: "Site Collection Administrator", + GrantedThrough: "Direct Permissions", + PrincipalType: "User") + }; + } + + /// + /// Returns permission entries for a Web object. + /// Port of PS Get-PnPPermissions for Web objects. + /// + private async Task> GetWebPermissionsAsync( + ClientContext ctx, + Web web, + ScanOptions options, + IProgress progress, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return await ExtractPermissionsAsync(ctx, web, "Site", web.Title, web.Url, options, progress, ct); + } + + /// + /// Returns permission entries for a List object. + /// Port of PS Get-PnPPermissions for List objects. + /// + private async Task> GetListPermissionsAsync( + ClientContext ctx, + List list, + ScanOptions options, + IProgress progress, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + // Build the list URL from DefaultViewUrl + var listUrl = list.DefaultViewUrl; + if (!string.IsNullOrEmpty(listUrl)) + { + var uri = new Uri(ctx.Url); + listUrl = $"{uri.Scheme}://{uri.Host}{listUrl}"; + } + + return await ExtractPermissionsAsync(ctx, list, "List", list.Title, listUrl ?? ctx.Url, options, progress, ct); + } + + /// + /// Enumerates folders in a document library and returns permission entries per folder. + /// Uses SharePointPaginationHelper.GetAllItemsAsync — never raw list enumeration. + /// + private async Task> GetFolderPermissionsAsync( + ClientContext ctx, + List list, + ScanOptions options, + IProgress progress, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var results = new List(); + + // CAML query for all folders, ordered by ID + var camlQuery = new CamlQuery + { + ViewXml = @" + + 500 + " + }; + + // Calculate base path depth to enforce FolderDepth limit + ctx.Load(list, l => l.RootFolder.ServerRelativeUrl); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/'); + var rootDepth = rootUrl.Split('/').Length; + + await foreach (var item in SharePointPaginationHelper.GetAllItemsAsync(ctx, list, camlQuery, ct)) + { + ct.ThrowIfCancellationRequested(); + + if (item.FileSystemObjectType != FileSystemObjectType.Folder) + continue; + + var fileRef = item["FileRef"]?.ToString() ?? string.Empty; + if (string.IsNullOrEmpty(fileRef)) + continue; + + // Check depth constraint + if (options.FolderDepth != 999) + { + var folderDepth = fileRef.TrimEnd('/').Split('/').Length - rootDepth; + if (folderDepth > options.FolderDepth) + continue; + } + + // ListItem is a SecurableObject; use it directly for permission extraction. + // Load the backing folder for URL/name metadata only. + var folder = item.Folder; + ctx.Load(folder, f => f.ServerRelativeUrl, f => f.Name); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + var uri = new Uri(ctx.Url); + var folderUrl = $"{uri.Scheme}://{uri.Host}{folder.ServerRelativeUrl}"; + + var folderEntries = await ExtractPermissionsAsync( + ctx, item, "Folder", folder.Name, folderUrl, options, progress, ct); + results.AddRange(folderEntries); + } + + return results; + } + + /// + /// Core per-object permission extractor. + /// Batches CSOM Load + ExecuteQueryRetryAsync into one round-trip per object. + /// Port of PS Get-PnPPermissions inner logic. + /// + private async Task> ExtractPermissionsAsync( + ClientContext ctx, + SecurableObject obj, + string objectType, + string title, + string url, + ScanOptions options, + IProgress progress, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + // Batched load: one CSOM round-trip for all fields we need + ctx.Load(obj, + o => o.HasUniqueRoleAssignments, + o => o.RoleAssignments.Include( + ra => ra.Member.Title, + ra => ra.Member.LoginName, + ra => ra.Member.PrincipalType, + ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name))); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + // Skip inherited objects when IncludeInherited=false + if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments) + return Enumerable.Empty(); + + var entries = new List(); + + foreach (var ra in obj.RoleAssignments) + { + ct.ThrowIfCancellationRequested(); + + var member = ra.Member; + var loginName = member.LoginName ?? string.Empty; + + // Skip sharing links groups and limited access system groups + if (PermissionEntryHelper.IsSharingLinksGroup(loginName)) + continue; + + // Collect and filter permission levels + var rawLevels = ra.RoleDefinitionBindings.Select(rdb => rdb.Name); + var filteredLevels = PermissionEntryHelper.FilterPermissionLevels(rawLevels); + + // Drop the row entirely if all levels are removed (e.g., only had "Limited Access") + if (filteredLevels.Count == 0) + continue; + + var permLevels = string.Join(";", filteredLevels); + + // Determine principal type + string principalType; + if (PermissionEntryHelper.IsExternalUser(loginName)) + principalType = "External User"; + else if (member.PrincipalType == Microsoft.SharePoint.Client.Utilities.PrincipalType.SharePointGroup) + principalType = "SharePointGroup"; + else + principalType = "User"; + + // Determine how the permission was granted + string grantedThrough = principalType == "SharePointGroup" + ? $"SharePoint Group: {member.Title}" + : "Direct Permissions"; + + entries.Add(new PermissionEntry( + ObjectType: objectType, + Title: title, + Url: url, + HasUniquePermissions: obj.HasUniqueRoleAssignments, + Users: member.Title ?? string.Empty, + UserLogins: loginName, + PermissionLevels: permLevels, + GrantedThrough: grantedThrough, + PrincipalType: principalType)); + } + + return entries; + } +}