using Microsoft.SharePoint.Client;
using Serilog;
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
{
///
/// Detects the SharePoint server error raised when a RoleAssignment member
/// refers to a user that no longer resolves (orphaned Azure AD account).
/// Message surfaces in the user's locale — match on language-agnostic tokens.
///
private static bool IsClaimsResolutionError(ServerException ex)
{
var msg = ex.Message ?? string.Empty;
return msg.Contains("Claims", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Revendications", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Org ID", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("ID org", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("OrgIdToClaims", StringComparison.OrdinalIgnoreCase);
}
// 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));
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped site collection admins for {Url} — orphaned user: {Error}",
ctx.Web.Url, ex.Message);
return Enumerable.Empty();
}
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)));
// Orphaned AD users in RoleAssignments cause the server to throw
// "Cannot convert Org ID user to Claims user" during claim resolution.
// That kills the whole batch — skip this object so the scan continues.
// Only swallow the claims-resolution signature; real access-denied errors
// must bubble up so callers (e.g. PermissionsViewModel auto-elevation)
// can react to them.
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped {Type} '{Title}' ({Url}) — orphaned user in permissions: {Error}",
objectType, title, url, ex.Message);
return Enumerable.Empty();
}
// 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;
}
}