215 lines
12 KiB
C#
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;
|
|
}
|
|
}
|