[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).
341 lines
13 KiB
C#
341 lines
13 KiB
C#
using Microsoft.SharePoint.Client;
|
|
using SharepointToolbox.Core.Helpers;
|
|
using SharepointToolbox.Core.Models;
|
|
|
|
namespace SharepointToolbox.Services;
|
|
|
|
/// <summary>
|
|
/// CSOM scan engine — faithful C# port of the PowerShell Generate-PnPSitePermissionRpt
|
|
/// and Get-PnPPermissions functions (PS reference lines 1361-1989).
|
|
/// </summary>
|
|
public class PermissionsService : IPermissionsService
|
|
{
|
|
// Port of PS lines 1914-1926: system lists excluded from permission reporting
|
|
private static readonly HashSet<string> 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<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
|
|
ClientContext ctx,
|
|
ScanOptions options,
|
|
IProgress<OperationProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var results = new List<PermissionEntry>();
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a single PermissionEntry for site collection administrators as a group.
|
|
/// Port of PS lines 1361-1400: Get site collection admins via SiteUsers filter.
|
|
/// </summary>
|
|
private async Task<IEnumerable<PermissionEntry>> GetSiteCollectionAdminsAsync(
|
|
ClientContext ctx,
|
|
IProgress<OperationProgress> 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<PermissionEntry>();
|
|
|
|
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")
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns permission entries for a Web object.
|
|
/// Port of PS Get-PnPPermissions for Web objects.
|
|
/// </summary>
|
|
private async Task<IEnumerable<PermissionEntry>> GetWebPermissionsAsync(
|
|
ClientContext ctx,
|
|
Web web,
|
|
ScanOptions options,
|
|
IProgress<OperationProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
return await ExtractPermissionsAsync(ctx, web, "Site", web.Title, web.Url, options, progress, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns permission entries for a List object.
|
|
/// Port of PS Get-PnPPermissions for List objects.
|
|
/// </summary>
|
|
private async Task<IEnumerable<PermissionEntry>> GetListPermissionsAsync(
|
|
ClientContext ctx,
|
|
List list,
|
|
ScanOptions options,
|
|
IProgress<OperationProgress> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates folders in a document library and returns permission entries per folder.
|
|
/// Uses SharePointPaginationHelper.GetAllItemsAsync — never raw list enumeration.
|
|
/// </summary>
|
|
private async Task<IEnumerable<PermissionEntry>> GetFolderPermissionsAsync(
|
|
ClientContext ctx,
|
|
List list,
|
|
ScanOptions options,
|
|
IProgress<OperationProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var results = new List<PermissionEntry>();
|
|
|
|
// CAML query for all folders, ordered by ID
|
|
var camlQuery = new CamlQuery
|
|
{
|
|
ViewXml = @"<View Scope='RecursiveAll'>
|
|
<Query><OrderBy><FieldRef Name='ID'/></OrderBy></Query>
|
|
<RowLimit>500</RowLimit>
|
|
</View>"
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core per-object permission extractor.
|
|
/// Batches CSOM Load + ExecuteQueryRetryAsync into one round-trip per object.
|
|
/// Port of PS Get-PnPPermissions inner logic.
|
|
/// </summary>
|
|
private async Task<IEnumerable<PermissionEntry>> ExtractPermissionsAsync(
|
|
ClientContext ctx,
|
|
SecurableObject obj,
|
|
string objectType,
|
|
string title,
|
|
string url,
|
|
ScanOptions options,
|
|
IProgress<OperationProgress> 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<PermissionEntry>();
|
|
|
|
var entries = new List<PermissionEntry>();
|
|
|
|
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;
|
|
}
|
|
}
|