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).
This commit is contained in:
18
SharepointToolbox/Services/Export/CsvExportService.cs
Normal file
18
SharepointToolbox/Services/Export/CsvExportService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exports permission entries to CSV format.
|
||||
/// Full implementation will be added in Plan 03.
|
||||
/// </summary>
|
||||
public class CsvExportService
|
||||
{
|
||||
/// <summary>Builds a CSV string from the supplied permission entries.</summary>
|
||||
public string BuildCsv(IReadOnlyList<PermissionEntry> entries) =>
|
||||
throw new NotImplementedException("CsvExportService.BuildCsv — implemented in Plan 03");
|
||||
|
||||
/// <summary>Writes the CSV output to a file.</summary>
|
||||
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct) =>
|
||||
throw new NotImplementedException("CsvExportService.WriteAsync — implemented in Plan 03");
|
||||
}
|
||||
18
SharepointToolbox/Services/Export/HtmlExportService.cs
Normal file
18
SharepointToolbox/Services/Export/HtmlExportService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Exports permission entries to HTML format.
|
||||
/// Full implementation will be added in Plan 03.
|
||||
/// </summary>
|
||||
public class HtmlExportService
|
||||
{
|
||||
/// <summary>Builds an HTML string from the supplied permission entries.</summary>
|
||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries) =>
|
||||
throw new NotImplementedException("HtmlExportService.BuildHtml — implemented in Plan 03");
|
||||
|
||||
/// <summary>Writes the HTML output to a file.</summary>
|
||||
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct) =>
|
||||
throw new NotImplementedException("HtmlExportService.WriteAsync — implemented in Plan 03");
|
||||
}
|
||||
340
SharepointToolbox/Services/PermissionsService.cs
Normal file
340
SharepointToolbox/Services/PermissionsService.cs
Normal file
@@ -0,0 +1,340 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user