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