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