Files
SharepointToolbox-Web/Services/PermissionsService.cs
T
2026-06-02 10:56:03 +02:00

215 lines
12 KiB
C#

using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SpWeb = Microsoft.SharePoint.Client.Web;
namespace SharepointToolbox.Web.Services;
public class PermissionsService : IPermissionsService
{
private readonly ISystemGroupTargetResolver? _systemGroupResolver;
public PermissionsService() : this(null) { }
public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver) { _systemGroupResolver = systemGroupResolver; }
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("OrgIdToClaims", StringComparison.OrdinalIgnoreCase);
}
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>();
progress.Report(OperationProgress.Indeterminate("Scanning site collection admins…"));
results.AddRange(await GetSiteCollectionAdminsAsync(ctx, progress, ct));
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));
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));
if (options.ScanFolders && list.BaseType == BaseType.DocumentLibrary)
results.AddRange(await GetFolderPermissionsAsync(ctx, list, options, progress, ct));
}
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);
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;
}
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));
try { await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); }
catch (ServerException ex) when (IsClaimsResolutionError(ex)) { Log.Warning("Skipped admins for {Url}: {Error}", ctx.Web.Url, ex.Message); return Enumerable.Empty<PermissionEntry>(); }
var admins = ctx.Web.SiteUsers.Where(u => u.IsSiteAdmin).ToList();
if (admins.Count == 0) return Enumerable.Empty<PermissionEntry>();
return new[] { new PermissionEntry("Site Collection", ctx.Web.Title, ctx.Web.Url, true,
string.Join(";", admins.Select(u => u.Title)), string.Join(";", admins.Select(u => u.LoginName)),
"Site Collection Administrator", "Direct Permissions", "User") };
}
private Task<IEnumerable<PermissionEntry>> GetWebPermissionsAsync(
ClientContext ctx, SpWeb web, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct) =>
ExtractPermissionsAsync(ctx, web, "Site", web.Title, web.Url, options, progress, ct);
private async Task<IEnumerable<PermissionEntry>> GetListPermissionsAsync(
ClientContext ctx, List list, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
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);
}
private async Task<IEnumerable<PermissionEntry>> GetFolderPermissionsAsync(
ClientContext ctx, List list, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var results = new List<PermissionEntry>();
var camlQuery = new CamlQuery { ViewXml = @"<View Scope='RecursiveAll'><Query><OrderBy><FieldRef Name='ID'/></OrderBy></Query><RowLimit>500</RowLimit></View>" };
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;
if (options.FolderDepth != 999)
{
var depth = fileRef.TrimEnd('/').Split('/').Length - rootDepth;
if (depth > options.FolderDepth) continue;
}
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 folderEntries = await ExtractPermissionsAsync(ctx, item, "Folder", folder.Name,
$"{uri.Scheme}://{uri.Host}{folder.ServerRelativeUrl}", options, progress, ct);
results.AddRange(folderEntries);
}
return results;
}
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();
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)));
try { await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); }
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped {Type} '{Title}' — orphaned user: {Error}", objectType, title, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
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;
var memberTitle = member.Title ?? string.Empty;
var classification = PermissionEntryHelper.Classify(memberTitle);
if (PermissionEntryHelper.IsBareLimitedAccessSystemGroup(loginName)) continue;
if (classification.Kind == SystemGroupKind.LimitedAccessBare) continue;
var filteredLevels = PermissionEntryHelper.FilterPermissionLevels(ra.RoleDefinitionBindings.Select(rdb => rdb.Name));
if (filteredLevels.Count == 0) continue;
var permLevels = string.Join(";", filteredLevels);
string principalType = PermissionEntryHelper.IsExternalUser(loginName) ? "External User"
: member.PrincipalType == Microsoft.SharePoint.Client.Utilities.PrincipalType.SharePointGroup ? "SharePointGroup"
: "User";
string grantedThrough = principalType == "SharePointGroup" ? $"SharePoint Group: {memberTitle}" : "Direct Permissions";
string? targetUrl = null, targetLabel = null, sharingLinkType = null;
if (_systemGroupResolver is not null && classification.Kind != SystemGroupKind.None)
{
var target = await _systemGroupResolver.ResolveAsync(ctx, classification, ct);
if (target is not null) { targetUrl = target.Url; targetLabel = target.Label; sharingLinkType = target.LinkType; }
else if (classification.Kind == SystemGroupKind.SharingLink) sharingLinkType = classification.LinkType;
}
entries.Add(new PermissionEntry(objectType, title, url, obj.HasUniqueRoleAssignments,
memberTitle, loginName, permLevels, grantedThrough, principalType,
TargetUrl: targetUrl, TargetLabel: targetLabel, SharingLinkType: sharingLinkType));
}
return entries;
}
}