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 HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase) { "Full Control", "Site Collection Administrator" }; private static readonly IReadOnlyDictionary> NoGroupMembers = new Dictionary>(); public UserAccessAuditService( IPermissionsService permissionsService, IElevationCoordinator elevation, ISharePointGroupResolver groupResolver) { _permissionsService = permissionsService; _elevation = elevation; _groupResolver = groupResolver; } public async Task AuditUsersAsync( ISessionManager sessionManager, TenantProfile currentProfile, IReadOnlyList targetUserLogins, IReadOnlyList sites, ScanOptions options, IProgress 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(), 0, 0, 0); var allEntries = new List(); // 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); } /// /// Resolves every SharePoint group referenced by 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. /// public static async Task>> ResolveGroupMembersAsync( ISharePointGroupResolver resolver, Microsoft.SharePoint.Client.ClientContext ctx, TenantProfile profile, IReadOnlyList 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; } } /// /// Projects raw permission entries for one site into per-user access entries, keeping only /// rows touching one of (substring match on login). Direct user /// grants match the principal's own login; SharePoint-group grants match against the group's /// expanded membership in (see ), /// 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. /// internal static IEnumerable TransformEntries( IReadOnlyList permEntries, HashSet targets, SiteInfo site, IReadOnlyDictionary>? 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; } }