Added new feature : display the file/folder and link of a SharingLink object in the permissions reports.
This commit is contained in:
@@ -14,7 +14,7 @@ public class CsvExportService
|
||||
private static string BuildHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -39,7 +39,10 @@ public class CsvExportService
|
||||
UserLogins = g.First().UserLogins,
|
||||
PrincipalType = g.First().PrincipalType,
|
||||
Permissions = g.Key.PermissionLevels,
|
||||
GrantedThrough = g.Key.GrantedThrough
|
||||
GrantedThrough = g.Key.GrantedThrough,
|
||||
TargetLabel = g.First().TargetLabel ?? string.Empty,
|
||||
TargetUrl = g.First().TargetUrl ?? string.Empty,
|
||||
SharingLinkType = g.First().SharingLinkType ?? string.Empty
|
||||
});
|
||||
|
||||
foreach (var row in merged)
|
||||
@@ -47,7 +50,8 @@ public class CsvExportService
|
||||
{
|
||||
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
|
||||
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
|
||||
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough)
|
||||
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough),
|
||||
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
|
||||
}));
|
||||
|
||||
return sb.ToString();
|
||||
@@ -68,7 +72,7 @@ public class CsvExportService
|
||||
private static string BuildSimplifiedHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
|
||||
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,7 +99,10 @@ public class CsvExportService
|
||||
Permissions = g.Key.PermissionLevels,
|
||||
SimplifiedLabels = g.First().SimplifiedLabels,
|
||||
RiskLevel = g.First().RiskLevel.ToString(),
|
||||
GrantedThrough = g.Key.GrantedThrough
|
||||
GrantedThrough = g.Key.GrantedThrough,
|
||||
TargetLabel = g.First().TargetLabel ?? string.Empty,
|
||||
TargetUrl = g.First().TargetUrl ?? string.Empty,
|
||||
SharingLinkType = g.First().SharingLinkType ?? string.Empty
|
||||
});
|
||||
|
||||
foreach (var row in merged)
|
||||
@@ -104,7 +111,8 @@ public class CsvExportService
|
||||
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
|
||||
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
|
||||
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.SimplifiedLabels),
|
||||
Csv(row.RiskLevel), Csv(row.GrantedThrough)
|
||||
Csv(row.RiskLevel), Csv(row.GrantedThrough),
|
||||
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
|
||||
}));
|
||||
|
||||
return sb.ToString();
|
||||
|
||||
@@ -27,7 +27,8 @@ public class HtmlExportService
|
||||
public string BuildHtml(
|
||||
IReadOnlyList<PermissionEntry> entries,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
|
||||
@@ -57,7 +58,9 @@ public class HtmlExportService
|
||||
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
|
||||
colSpan: 7, grpMemIdx: ref grpMemIdx);
|
||||
colSpan: 7, grpMemIdx: ref grpMemIdx,
|
||||
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
||||
hideSystemGroupRaw: hideSystemGroupRaw);
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
@@ -66,7 +69,7 @@ public class HtmlExportService
|
||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||
sb.AppendLine($" <td>{pills}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
}
|
||||
@@ -87,7 +90,8 @@ public class HtmlExportService
|
||||
public string BuildHtml(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var summaries = PermissionSummaryBuilder.Build(entries);
|
||||
@@ -132,7 +136,9 @@ public class HtmlExportService
|
||||
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
||||
colSpan: 9, grpMemIdx: ref grpMemIdx);
|
||||
colSpan: 9, grpMemIdx: ref grpMemIdx,
|
||||
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
||||
hideSystemGroupRaw: hideSystemGroupRaw);
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
@@ -143,7 +149,7 @@ public class HtmlExportService
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
}
|
||||
@@ -161,9 +167,10 @@ public class HtmlExportService
|
||||
string filePath,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
|
||||
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||
}
|
||||
|
||||
@@ -173,9 +180,10 @@ public class HtmlExportService
|
||||
string filePath,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var html = BuildHtml(entries, branding, groupMembers);
|
||||
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
|
||||
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
|
||||
}
|
||||
|
||||
@@ -191,11 +199,12 @@ public class HtmlExportService
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
if (splitMode != ReportSplitMode.BySite)
|
||||
{
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,7 +212,7 @@ public class HtmlExportService
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partitions
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
|
||||
.ToList();
|
||||
var title = TranslationSource.Instance["report.title.permissions"];
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||
@@ -215,11 +224,11 @@ public class HtmlExportService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</summary>
|
||||
/// <summary>Simplified-entry split variant.</summary>
|
||||
public async Task WriteAsync(
|
||||
IReadOnlyList<SimplifiedPermissionEntry> entries,
|
||||
string basePath,
|
||||
@@ -227,11 +236,12 @@ public class HtmlExportService
|
||||
HtmlSplitLayout layout,
|
||||
CancellationToken ct,
|
||||
ReportBranding? branding = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
if (splitMode != ReportSplitMode.BySite)
|
||||
{
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers);
|
||||
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,7 +249,7 @@ public class HtmlExportService
|
||||
if (layout == HtmlSplitLayout.SingleTabbed)
|
||||
{
|
||||
var parts = partitions
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
|
||||
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
|
||||
.ToList();
|
||||
var title = TranslationSource.Instance["report.title.permissions_simplified"];
|
||||
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
|
||||
@@ -251,7 +261,7 @@ public class HtmlExportService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers);
|
||||
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
|
||||
@@ -137,7 +138,10 @@ document.addEventListener('click', function(ev) {
|
||||
string? principalType,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
|
||||
int colSpan,
|
||||
ref int grpMemIdx)
|
||||
ref int grpMemIdx,
|
||||
string? targetLabel = null,
|
||||
string? sharingLinkType = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
@@ -151,14 +155,40 @@ document.addEventListener('click', function(ev) {
|
||||
var name = i < names.Length ? names[i].Trim() : login;
|
||||
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
bool isExpandable = principalType == "SharePointGroup"
|
||||
// When the principal is a resolved system group and the user wants the raw
|
||||
// name hidden, replace the pill's visible text with the link-type badge
|
||||
// (sharing links) and/or the target label. Falls back to the raw name when
|
||||
// resolution failed (no targetLabel).
|
||||
var classification = principalType == "SharePointGroup"
|
||||
? PermissionEntryHelper.Classify(name)
|
||||
: new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
|
||||
bool isResolvedSystemGroup = hideSystemGroupRaw
|
||||
&& classification.Kind != SystemGroupKind.None
|
||||
&& classification.Kind != SystemGroupKind.LimitedAccessBare
|
||||
&& !string.IsNullOrEmpty(targetLabel);
|
||||
|
||||
bool hasResolvedMembers = principalType == "SharePointGroup"
|
||||
&& groupMembers != null
|
||||
&& groupMembers.TryGetValue(name, out _);
|
||||
|
||||
if (isExpandable && groupMembers != null && groupMembers.TryGetValue(name, out var resolved))
|
||||
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
|
||||
{
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pills.Append($"<span class=\"user-pill group-expandable\" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</span>");
|
||||
pills.Append("<span class=\"user-pill group-expandable\"");
|
||||
if (isResolvedSystemGroup)
|
||||
pills.Append(" data-system-group=\"1\"");
|
||||
pills.Append($" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">");
|
||||
if (isResolvedSystemGroup)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
pills.Append(HtmlEncode(targetLabel!));
|
||||
}
|
||||
else
|
||||
{
|
||||
pills.Append(HtmlEncode(name));
|
||||
}
|
||||
pills.Append(" ▼</span>");
|
||||
|
||||
string memberContent;
|
||||
if (resolved.Count > 0)
|
||||
@@ -173,6 +203,14 @@ document.addEventListener('click', function(ev) {
|
||||
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\" style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else if (isResolvedSystemGroup)
|
||||
{
|
||||
pills.Append("<span class=\"user-pill\" data-system-group=\"1\">");
|
||||
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
pills.Append(HtmlEncode(targetLabel!));
|
||||
pills.Append("</span>");
|
||||
}
|
||||
else
|
||||
{
|
||||
var cls = isExt ? "user-pill external-user" : "user-pill";
|
||||
@@ -183,6 +221,80 @@ document.addEventListener('click', function(ev) {
|
||||
return (pills.ToString(), subRows.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the Granted Through cell. When the entry carries a resolved system-group
|
||||
/// target (Limited Access For Web/List or SharingLinks), a clickable link to the
|
||||
/// targeted resource is appended on a second line. For sharing links the link type
|
||||
/// (OrganizationEdit / AnonymousView / …) is surfaced alongside the target.
|
||||
///
|
||||
/// When <paramref name="hideSystemGroupRaw"/> is true and a target was resolved, the
|
||||
/// raw "SharePoint Group: SharingLinks.{guid}…" / "Limited Access System Group For
|
||||
/// Web|List {guid}" prefix is suppressed and only the link-type badge + clickable
|
||||
/// target are shown — keeps the report readable without losing information.
|
||||
/// </summary>
|
||||
internal static string BuildGrantedThroughCell(
|
||||
string grantedThrough,
|
||||
string? targetUrl,
|
||||
string? targetLabel,
|
||||
string? sharingLinkType,
|
||||
bool hideSystemGroupRaw = false)
|
||||
{
|
||||
var hasTarget = !string.IsNullOrEmpty(targetUrl) && !string.IsNullOrEmpty(targetLabel);
|
||||
var hasLinkType = !string.IsNullOrEmpty(sharingLinkType);
|
||||
var suppressRaw = hideSystemGroupRaw && hasTarget;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (!suppressRaw)
|
||||
sb.Append(HtmlEncode(grantedThrough));
|
||||
|
||||
if (!hasTarget && !hasLinkType)
|
||||
return sb.ToString();
|
||||
|
||||
if (suppressRaw)
|
||||
{
|
||||
// Inline layout — no leading raw text to wrap under.
|
||||
if (hasLinkType)
|
||||
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
if (hasTarget)
|
||||
{
|
||||
sb.Append("<a href=\"");
|
||||
sb.Append(HtmlEncode(targetUrl!));
|
||||
sb.Append("\" target=\"_blank\">");
|
||||
sb.Append(HtmlEncode(targetLabel!));
|
||||
sb.Append("</a>");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
sb.Append("<div style=\"margin-top:4px;font-size:.75rem;color:#555\">");
|
||||
if (hasLinkType)
|
||||
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
if (hasTarget)
|
||||
{
|
||||
sb.Append("→ <a href=\"");
|
||||
sb.Append(HtmlEncode(targetUrl!));
|
||||
sb.Append("\" target=\"_blank\">");
|
||||
sb.Append(HtmlEncode(targetLabel!));
|
||||
sb.Append("</a>");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the colored badge for a SharePoint sharing-link type. Translates the
|
||||
/// raw <c>linkType</c> code (e.g. <c>OrganizationEdit</c>) into a human label
|
||||
/// (e.g. <c>Org link · Edit</c>) and tints by risk tier; raw code surfaces as a
|
||||
/// <c>title</c> tooltip so operators can still trace it back to the source.
|
||||
/// </summary>
|
||||
internal static string BuildSharingLinkBadge(string rawLinkType)
|
||||
{
|
||||
var (label, risk) = SharingLinkLabels.Describe(rawLinkType);
|
||||
var (bg, fg) = SharingLinkLabels.Colors(risk);
|
||||
return $"<span class=\"badge\" style=\"background:{bg};color:{fg};margin-right:6px\" " +
|
||||
$"title=\"{HtmlEncode(rawLinkType)}\">{HtmlEncode(label)}</span>";
|
||||
}
|
||||
|
||||
/// <summary>Returns the CSS class for the object-type badge.</summary>
|
||||
internal static string ObjectTypeCss(string t) => t switch
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ public class UserAccessCsvExportService
|
||||
private static string BuildDataHeader()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
|
||||
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,7 +51,10 @@ public class UserAccessCsvExportService
|
||||
Csv(entry.ObjectUrl),
|
||||
Csv(entry.PermissionLevel),
|
||||
Csv(entry.AccessType.ToString()),
|
||||
Csv(entry.GrantedThrough)
|
||||
Csv(entry.GrantedThrough),
|
||||
Csv(entry.TargetLabel ?? string.Empty),
|
||||
Csv(entry.TargetUrl ?? string.Empty),
|
||||
Csv(entry.SharingLinkType ?? string.Empty)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -179,7 +182,7 @@ public class UserAccessCsvExportService
|
||||
sb.AppendLine();
|
||||
|
||||
// Header
|
||||
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
|
||||
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"");
|
||||
|
||||
// Data rows
|
||||
foreach (var entry in consolidated)
|
||||
@@ -187,13 +190,16 @@ public class UserAccessCsvExportService
|
||||
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
|
||||
sb.AppendLine(string.Join(",", new[]
|
||||
{
|
||||
$"\"{entry.UserDisplayName}\"",
|
||||
$"\"{entry.UserLogin}\"",
|
||||
$"\"{entry.PermissionLevel}\"",
|
||||
$"\"{entry.AccessType}\"",
|
||||
$"\"{entry.GrantedThrough}\"",
|
||||
$"\"{locations}\"",
|
||||
$"\"{entry.LocationCount}\""
|
||||
Csv(entry.UserDisplayName),
|
||||
Csv(entry.UserLogin),
|
||||
Csv(entry.PermissionLevel),
|
||||
Csv(entry.AccessType.ToString()),
|
||||
Csv(entry.GrantedThrough),
|
||||
Csv(entry.TargetLabel ?? string.Empty),
|
||||
Csv(entry.TargetUrl ?? string.Empty),
|
||||
Csv(entry.SharingLinkType ?? string.Empty),
|
||||
Csv(locations),
|
||||
Csv(entry.LocationCount.ToString())
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -226,7 +232,10 @@ public class UserAccessCsvExportService
|
||||
Csv(entry.ObjectUrl),
|
||||
Csv(entry.PermissionLevel),
|
||||
Csv(entry.AccessType.ToString()),
|
||||
Csv(entry.GrantedThrough)
|
||||
Csv(entry.GrantedThrough),
|
||||
Csv(entry.TargetLabel ?? string.Empty),
|
||||
Csv(entry.TargetUrl ?? string.Empty),
|
||||
Csv(entry.SharingLinkType ?? string.Empty)
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ a:hover { text-decoration: underline; }
|
||||
sb.AppendLine($" <td>{objectCell}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ a:hover { text-decoration: underline; }
|
||||
sb.AppendLine($" <td>{objectCell}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
}
|
||||
@@ -579,7 +579,7 @@ a:hover { text-decoration: underline; }
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
|
||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
|
||||
|
||||
if (entry.LocationCount == 1)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.SharePoint.Client;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the targeted resource (List, Web, File, Folder) for a SharePoint system
|
||||
/// group whose name encodes a GUID — Limited Access System Group For Web/List and
|
||||
/// SharingLinks.{itemId}.{type}.{shareId}. Returns null when the target is missing
|
||||
/// or unreachable (deleted item, access denied, etc.) — callers fall back to the
|
||||
/// raw group name.
|
||||
/// </summary>
|
||||
public interface ISystemGroupTargetResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the target for a classified system group. Cached per (ctx site, guid).
|
||||
/// </summary>
|
||||
Task<SystemGroupTarget?> ResolveAsync(
|
||||
ClientContext ctx,
|
||||
SystemGroupClassification classification,
|
||||
CancellationToken ct);
|
||||
}
|
||||
@@ -11,6 +11,16 @@ namespace SharepointToolbox.Services;
|
||||
/// </summary>
|
||||
public class PermissionsService : IPermissionsService
|
||||
{
|
||||
private readonly ISystemGroupTargetResolver? _systemGroupResolver;
|
||||
|
||||
public PermissionsService() : this(null) { }
|
||||
|
||||
public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver)
|
||||
{
|
||||
_systemGroupResolver = systemGroupResolver;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Detects the SharePoint server error raised when a RoleAssignment member
|
||||
/// refers to a user that no longer resolves (orphaned Azure AD account).
|
||||
@@ -336,9 +346,18 @@ public class PermissionsService : IPermissionsService
|
||||
|
||||
var member = ra.Member;
|
||||
var loginName = member.LoginName ?? string.Empty;
|
||||
var memberTitle = member.Title ?? string.Empty;
|
||||
|
||||
// Skip sharing links groups and limited access system groups
|
||||
if (PermissionEntryHelper.IsSharingLinksGroup(loginName))
|
||||
// Classify the member name. The bare "Limited Access System Group" is
|
||||
// pure noise; drop it. The For-Web/For-List and SharingLinks variants
|
||||
// are kept and enriched below with a resolved target URL.
|
||||
var classification = PermissionEntryHelper.Classify(memberTitle);
|
||||
if (classification.Kind == SystemGroupKind.None
|
||||
&& PermissionEntryHelper.IsBareLimitedAccessSystemGroup(loginName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (classification.Kind == SystemGroupKind.LimitedAccessBare)
|
||||
continue;
|
||||
|
||||
// Collect and filter permission levels
|
||||
@@ -362,19 +381,42 @@ public class PermissionsService : IPermissionsService
|
||||
|
||||
// Determine how the permission was granted
|
||||
string grantedThrough = principalType == "SharePointGroup"
|
||||
? $"SharePoint Group: {member.Title}"
|
||||
? $"SharePoint Group: {memberTitle}"
|
||||
: "Direct Permissions";
|
||||
|
||||
// Resolve system-group target (Limited Access For Web/List, SharingLinks)
|
||||
string? targetUrl = null;
|
||||
string? targetLabel = null;
|
||||
string? 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)
|
||||
{
|
||||
// Target lookup failed (deleted item / no access) — still surface link type.
|
||||
sharingLinkType = classification.LinkType;
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new PermissionEntry(
|
||||
ObjectType: objectType,
|
||||
Title: title,
|
||||
Url: url,
|
||||
HasUniquePermissions: obj.HasUniqueRoleAssignments,
|
||||
Users: member.Title ?? string.Empty,
|
||||
Users: memberTitle,
|
||||
UserLogins: loginName,
|
||||
PermissionLevels: permLevels,
|
||||
GrantedThrough: grantedThrough,
|
||||
PrincipalType: principalType));
|
||||
PrincipalType: principalType,
|
||||
TargetUrl: targetUrl,
|
||||
TargetLabel: targetLabel,
|
||||
SharingLinkType: sharingLinkType));
|
||||
}
|
||||
|
||||
return entries;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using Microsoft.SharePoint.Client;
|
||||
using Microsoft.SharePoint.Client.Search.Query;
|
||||
using Serilog;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CSOM-backed resolver that converts the GUID/identifier embedded in a SharePoint
|
||||
/// system group name into a clickable target. Results are cached per
|
||||
/// (site URL, guid) to avoid repeated round-trips when the same Limited Access
|
||||
/// system group recurs on many objects.
|
||||
///
|
||||
/// Never throws — on any failure (not found, access denied, claims error), returns
|
||||
/// <c>null</c> and the caller renders the raw group name.
|
||||
/// </summary>
|
||||
public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
||||
{
|
||||
private readonly Dictionary<string, SystemGroupTarget?> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<SystemGroupTarget?> ResolveAsync(
|
||||
ClientContext ctx,
|
||||
SystemGroupClassification classification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var key = BuildCacheKey(ctx.Url, classification);
|
||||
if (key is not null && _cache.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
|
||||
SystemGroupTarget? result = null;
|
||||
try
|
||||
{
|
||||
result = classification.Kind switch
|
||||
{
|
||||
SystemGroupKind.LimitedAccessWeb => await ResolveWebAsync(ctx, classification.WebId!.Value, ct),
|
||||
SystemGroupKind.LimitedAccessList => await ResolveListAsync(ctx, classification.ListId!.Value, ct),
|
||||
SystemGroupKind.SharingLink => await ResolveItemAsync(ctx, classification.ItemUniqueId!.Value, classification.LinkType, ct),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}",
|
||||
classification.Kind, ctx.Url, ex.Message);
|
||||
}
|
||||
|
||||
if (key is not null)
|
||||
_cache[key] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? BuildCacheKey(string siteUrl, SystemGroupClassification c) => c.Kind switch
|
||||
{
|
||||
SystemGroupKind.LimitedAccessWeb => $"{siteUrl}|web|{c.WebId}",
|
||||
SystemGroupKind.LimitedAccessList => $"{siteUrl}|list|{c.ListId}",
|
||||
SystemGroupKind.SharingLink => $"{siteUrl}|item|{c.ItemUniqueId}",
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static async Task<SystemGroupTarget?> ResolveWebAsync(
|
||||
ClientContext ctx, Guid webId, CancellationToken ct)
|
||||
{
|
||||
var web = ctx.Site.OpenWebById(webId);
|
||||
ctx.Load(web, w => w.Title, w => w.Url);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||
return new SystemGroupTarget(SystemGroupKind.LimitedAccessWeb, web.Title, web.Url);
|
||||
}
|
||||
|
||||
private static async Task<SystemGroupTarget?> ResolveListAsync(
|
||||
ClientContext ctx, Guid listId, CancellationToken ct)
|
||||
{
|
||||
var list = ctx.Web.Lists.GetById(listId);
|
||||
ctx.Load(list, l => l.Title, l => l.DefaultViewUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||
|
||||
var url = BuildAbsoluteUrl(ctx.Url, list.DefaultViewUrl);
|
||||
return new SystemGroupTarget(SystemGroupKind.LimitedAccessList, list.Title, url);
|
||||
}
|
||||
|
||||
private static async Task<SystemGroupTarget?> ResolveItemAsync(
|
||||
ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
|
||||
{
|
||||
// 1. Try as file on current web (most sharing links target files).
|
||||
try
|
||||
{
|
||||
var file = ctx.Web.GetFileById(itemUniqueId);
|
||||
ctx.Load(file, f => f.Name, f => f.ServerRelativeUrl, f => f.Exists);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||
|
||||
if (file.Exists)
|
||||
{
|
||||
var url = BuildAbsoluteUrl(ctx.Url, file.ServerRelativeUrl);
|
||||
return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType);
|
||||
}
|
||||
}
|
||||
catch (ServerException) { /* fall through */ }
|
||||
|
||||
// 2. Try as folder on current web.
|
||||
try
|
||||
{
|
||||
var folder = ctx.Web.GetFolderById(itemUniqueId);
|
||||
ctx.Load(folder, f => f.Name, f => f.ServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||
|
||||
var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl);
|
||||
return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType);
|
||||
}
|
||||
catch (ServerException) { /* fall through */ }
|
||||
|
||||
// 3. Search-index fallback — covers items moved to a different subsite or
|
||||
// deleted recently (the index may lag the deletion by minutes/hours).
|
||||
// Search is permission-trimmed, so this only returns hits the caller
|
||||
// can still see; results carry a "(via index)" hint in the label.
|
||||
return await TryResolveViaSearchAsync(ctx, itemUniqueId, linkType, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries the SharePoint search index for an item by its UniqueId. Returns
|
||||
/// a target with the last-indexed path and a label prefix flagging that the
|
||||
/// hit came from the index (so admins know the live lookup failed).
|
||||
/// </summary>
|
||||
private static async Task<SystemGroupTarget?> TryResolveViaSearchAsync(
|
||||
ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
// KQL: UniqueId managed property. Braces around the GUID are how the
|
||||
// SP search engine matches the canonical form for content unique ids.
|
||||
var kq = new KeywordQuery(ctx)
|
||||
{
|
||||
QueryText = $"UniqueId:{{{itemUniqueId}}}",
|
||||
RowLimit = 1,
|
||||
TrimDuplicates = false
|
||||
};
|
||||
kq.SelectProperties.Add("Path");
|
||||
kq.SelectProperties.Add("Title");
|
||||
kq.SelectProperties.Add("FileExtension");
|
||||
|
||||
var executor = new SearchExecutor(ctx);
|
||||
var result = executor.ExecuteQuery(kq);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||
|
||||
var table = result.Value
|
||||
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
||||
if (table is null || table.RowCount == 0)
|
||||
return null;
|
||||
|
||||
var row = ToDict(table.ResultRows.First());
|
||||
var path = row.TryGetValue("Path", out var p) ? p?.ToString() : null;
|
||||
var title = row.TryGetValue("Title", out var t) ? t?.ToString() : null;
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return null;
|
||||
|
||||
// Derive a leaf name if Title is empty (folders often have no Title).
|
||||
var leaf = !string.IsNullOrWhiteSpace(title)
|
||||
? title!
|
||||
: Uri.UnescapeDataString(path.TrimEnd('/').Split('/').Last());
|
||||
|
||||
return new SystemGroupTarget(
|
||||
SystemGroupKind.SharingLink,
|
||||
$"{leaf} (via index)",
|
||||
path,
|
||||
linkType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}",
|
||||
itemUniqueId, ctx.Url, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSOM SearchExecutor returns ResultRows as either generic Dictionary or legacy
|
||||
/// IDictionary depending on CSOM version — normalise to a single shape.
|
||||
/// </summary>
|
||||
private static IDictionary<string, object> ToDict(object rawRow)
|
||||
{
|
||||
if (rawRow is IDictionary<string, object> generic)
|
||||
return generic;
|
||||
var dict = new Dictionary<string, object>();
|
||||
if (rawRow is System.Collections.IDictionary legacy)
|
||||
{
|
||||
foreach (System.Collections.DictionaryEntry e in legacy)
|
||||
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static string BuildAbsoluteUrl(string contextUrl, string? serverRelative)
|
||||
{
|
||||
if (string.IsNullOrEmpty(serverRelative))
|
||||
return contextUrl;
|
||||
if (Uri.TryCreate(serverRelative, UriKind.Absolute, out _))
|
||||
return serverRelative;
|
||||
var uri = new Uri(contextUrl);
|
||||
return $"{uri.Scheme}://{uri.Host}{serverRelative}";
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,10 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
AccessType: accessType,
|
||||
GrantedThrough: entry.GrantedThrough,
|
||||
IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel),
|
||||
IsExternalUser: PermissionEntryHelper.IsExternalUser(login));
|
||||
IsExternalUser: PermissionEntryHelper.IsExternalUser(login),
|
||||
TargetUrl: entry.TargetUrl,
|
||||
TargetLabel: entry.TargetLabel,
|
||||
SharingLinkType: entry.SharingLinkType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user