Files
SharepointToolbox-Web/Services/UserAccessAuditService.cs
T
kawa 6d9c79ad5a Add scheduled reports + app-only cert auth; fix tenant-wide user-access audit
Feature work:
- Certificate (app-only) auth per profile: cert store, context/Graph client
  factories, automated app-registration provisioning (delegated + application
  permissions, admin consent), and a SessionManager seam that resolves the auth
  model per profile.
- Scheduled reports: repositories, hosted service/runner/coordinator, report
  pages, and email delivery (app-only Mail.Send).
- Tenant-wide user-access audit when no site is selected.

Audit fixes:
- Site enumeration: app-only discovery used Graph getAllSites (needs Graph
  Sites.Read.All the cert app lacks) and silently returned empty. Switched to
  the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth
  models now share one enumeration path.
- Group expansion: the scan records a SharePoint group as a single principal, so
  user-centric audits found nothing for group-granted access. Resolve group
  membership (shared by audit + scheduler) and attribute it to the target user.
- M365 group claims: the resolver only recognized AAD security groups
  (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim
  (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim.
- Provision Directory.Read.All as an application permission so M365/AAD group
  expansion works under the cert identity.

Also: ignore data/appcerts/ (encrypted certificate key material).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:55:28 +02:00

222 lines
10 KiB
C#

using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services;
public class UserAccessAuditService : IUserAccessAuditService
{
private readonly IPermissionsService _permissionsService;
private readonly IElevationCoordinator _elevation;
private readonly ISharePointGroupResolver _groupResolver;
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
{
"Full Control", "Site Collection Administrator"
};
private static readonly IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>> NoGroupMembers =
new Dictionary<string, IReadOnlyList<ResolvedMember>>();
public UserAccessAuditService(
IPermissionsService permissionsService,
IElevationCoordinator elevation,
ISharePointGroupResolver groupResolver)
{
_permissionsService = permissionsService;
_elevation = elevation;
_groupResolver = groupResolver;
}
public async Task<UserAccessAuditResult> AuditUsersAsync(
ISessionManager sessionManager,
TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins,
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var targets = targetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet();
if (targets.Count == 0) return new UserAccessAuditResult(Array.Empty<UserAccessEntry>(), 0, 0, 0);
var allEntries = new List<UserAccessEntry>();
// Per-site resilience: when auditing many sites (e.g. the whole tenant), one bad site
// must not abort the run. Access-denied is the expected "tech has no access here" case
// and is skipped quietly; any other error is skipped but counted as a failure so it can
// be surfaced. Cancellation always propagates.
int deniedSites = 0, failedSites = 0;
for (int i = 0; i < sites.Count; i++)
{
ct.ThrowIfCancellationRequested();
var site = sites[i];
progress.Report(new OperationProgress(i, sites.Count,
$"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
var profile = currentProfile.CloneForSite(site.Url);
profile.Name = site.Title;
try
{
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
var permEntries = await _elevation.RunAsync(async c =>
{
var ctx = await sessionManager.GetOrCreateContextAsync(profile, c);
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
}, ct);
// Most users get access through SharePoint group membership, not direct grants.
// The scan records the group as a single principal, so the group's members must be
// expanded (including nested AAD groups, via the resolver) for a user-centric audit
// to attribute that access to the target — without it the audit finds nothing.
var siteCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
var groupMembers = await ResolveGroupMembersAsync(_groupResolver, siteCtx, profile, permEntries, ct);
allEntries.AddRange(TransformEntries(permEntries, targets, site, groupMembers));
}
catch (OperationCanceledException)
{
throw;
}
catch (SharePointAccessDeniedException)
{
// No access to this site (and elevation could not / was not allowed to fix it).
// Expected when scanning the whole tenant under a delegated identity — skip.
deniedSites++;
}
catch (Exception)
{
// Transient/throttling/malformed-site error — skip and keep going.
failedSites++;
}
}
var summary = $"Audit complete: {allEntries.Count} access entries found.";
if (deniedSites > 0) summary += $" {deniedSites} site(s) skipped (no access).";
if (failedSites > 0) summary += $" {failedSites} site(s) failed.";
progress.Report(new OperationProgress(sites.Count, sites.Count, summary));
return new UserAccessAuditResult(allEntries, sites.Count, deniedSites, failedSites);
}
/// <summary>
/// Resolves every SharePoint group referenced by <paramref name="permEntries"/> to its
/// member set (expanding nested AAD groups via Graph), so group-granted access can be
/// attributed to the target user. Returns an empty map if there are no group principals
/// or resolution fails (the audit then falls back to direct grants only rather than abort).
/// Shared by the interactive audit and the background report scheduler.
/// </summary>
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupMembersAsync(
ISharePointGroupResolver resolver,
Microsoft.SharePoint.Client.ClientContext ctx,
TenantProfile profile,
IReadOnlyList<PermissionEntry> permEntries,
CancellationToken ct)
{
var groupNames = permEntries
.Where(e => string.Equals(e.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase))
.Select(e => e.Users) // group title; matches GrantedThrough "SharePoint Group: {title}"
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count == 0) return NoGroupMembers;
try
{
return await resolver.ResolveGroupsAsync(ctx, profile, groupNames, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Serilog.Log.Warning("User-access audit: SP group expansion failed on {Url}: {Error}", ctx.Url, ex.Message);
return NoGroupMembers;
}
}
/// <summary>
/// Projects raw permission entries for one site into per-user access entries, keeping only
/// rows touching one of <paramref name="targets"/> (substring match on login). Direct user
/// grants match the principal's own login; SharePoint-group grants match against the group's
/// expanded membership in <paramref name="groupMembers"/> (see <see cref="ResolveGroupMembersAsync"/>),
/// so access held through a group is attributed to its members. Exposed for the background
/// report scheduler, which reuses this projection under app-only auth.
/// </summary>
internal static IEnumerable<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
foreach (var entry in permEntries)
{
var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries);
UserAccessEntry Build(string displayName, string login, AccessType accessType, string level) =>
new(displayName, StripClaimsPrefix(login),
site.Url, site.Title,
entry.ObjectType, entry.Title, entry.Url,
level, accessType, entry.GrantedThrough,
HighPrivilegeLevels.Contains(level),
PermissionEntryHelper.IsExternalUser(login),
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
bool isGroup = string.Equals(entry.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase);
if (isGroup)
{
// Group principal: match the target against the group's expanded members. Without a
// resolved map (or an unknown group) there is no user to attribute access to — skip.
if (groupMembers is null
|| string.IsNullOrEmpty(entry.Users)
|| !groupMembers.TryGetValue(entry.Users, out var members))
continue;
var accessType = entry.HasUniquePermissions ? AccessType.Group : AccessType.Inherited;
foreach (var m in members)
{
var loginLower = m.Login.ToLowerInvariant();
if (string.IsNullOrEmpty(loginLower)) continue;
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
foreach (var level in permLevels)
{
var trimmed = level.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
yield return Build(m.DisplayName, m.Login, accessType, trimmed);
}
}
continue;
}
// Direct / external user grant (also the joined site-collection-admins entry).
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
for (int u = 0; u < logins.Length; u++)
{
var login = logins[u].Trim();
var loginLower = login.ToLowerInvariant();
var displayName = u < names.Length ? names[u].Trim() : login;
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
var accessType = entry.HasUniquePermissions ? AccessType.Direct : AccessType.Inherited;
foreach (var level in permLevels)
{
var trimmed = level.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
yield return Build(displayName, login, accessType, trimmed);
}
}
}
}
private static string StripClaimsPrefix(string login)
{
int pipe = login.LastIndexOf('|');
return pipe >= 0 ? login[(pipe + 1)..] : login;
}
}