From 44b238e07a5541742cc91f20a290f4506bee5ce1 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 7 Apr 2026 12:39:57 +0200 Subject: [PATCH] feat(07-02): implement UserAccessAuditService - Scans permissions via IPermissionsService.ScanSiteAsync per site - Filters PermissionEntry results to matching target user logins (case-insensitive contains) - Splits semicolon-delimited users/logins/levels into per-user UserAccessEntry rows - Classifies AccessType: Inherited (!HasUniquePermissions), Group (GrantedThrough), Direct - Flags IsHighPrivilege (Full Control, Site Collection Administrator) and IsExternalUser (#EXT#) --- .../Services/UserAccessAuditService.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 SharepointToolbox/Services/UserAccessAuditService.cs diff --git a/SharepointToolbox/Services/UserAccessAuditService.cs b/SharepointToolbox/Services/UserAccessAuditService.cs new file mode 100644 index 0000000..19f98d4 --- /dev/null +++ b/SharepointToolbox/Services/UserAccessAuditService.cs @@ -0,0 +1,148 @@ +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +/// +/// Scans permissions across multiple sites via PermissionsService, +/// then filters and transforms results into user-centric UserAccessEntry records. +/// +public class UserAccessAuditService : IUserAccessAuditService +{ + private readonly IPermissionsService _permissionsService; + + private static readonly HashSet HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase) + { + "Full Control", + "Site Collection Administrator" + }; + + public UserAccessAuditService(IPermissionsService permissionsService) + { + _permissionsService = permissionsService; + } + + public async Task> AuditUsersAsync( + ISessionManager sessionManager, + IReadOnlyList targetUserLogins, + IReadOnlyList sites, + ScanOptions options, + IProgress progress, + CancellationToken ct) + { + // Normalize target logins for case-insensitive matching. + // Users may be identified by email ("alice@contoso.com") or full claim + // ("i:0#.f|membership|alice@contoso.com"), so we match on "contains". + var targets = targetUserLogins + .Select(l => l.Trim().ToLowerInvariant()) + .Where(l => l.Length > 0) + .ToHashSet(); + + if (targets.Count == 0) + return Array.Empty(); + + var allEntries = new List(); + + 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 = new TenantProfile + { + TenantUrl = site.Url, + ClientId = string.Empty, // Will be set by SessionManager from cached session + Name = site.Title + }; + + var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct); + var permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct); + + var userEntries = TransformEntries(permEntries, targets, site); + allEntries.AddRange(userEntries); + } + + progress.Report(new OperationProgress(sites.Count, sites.Count, + $"Audit complete: {allEntries.Count} access entries found.")); + + return allEntries; + } + + /// + /// Transforms PermissionEntry list into UserAccessEntry list, + /// filtering to only entries that match target user logins. + /// + private static IEnumerable TransformEntries( + IReadOnlyList permEntries, + HashSet targets, + SiteInfo site) + { + foreach (var entry in permEntries) + { + // Split semicolon-delimited Users and UserLogins + var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); + var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); + + // Split semicolon-delimited PermissionLevels + var permLevels = entry.PermissionLevels.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; + + // Check if this login matches any target user. + // Match by "contains" because SharePoint claims may wrap the email: + // "i:0#.f|membership|alice@contoso.com" contains "alice@contoso.com" + bool isTarget = targets.Any(t => + loginLower.Contains(t) || t.Contains(loginLower)); + + if (!isTarget) continue; + + // Determine access type + var accessType = ClassifyAccessType(entry); + + // Emit one UserAccessEntry per permission level + foreach (var level in permLevels) + { + var trimmedLevel = level.Trim(); + if (string.IsNullOrEmpty(trimmedLevel)) continue; + + yield return new UserAccessEntry( + UserDisplayName: displayName, + UserLogin: login, + SiteUrl: site.Url, + SiteTitle: site.Title, + ObjectType: entry.ObjectType, + ObjectTitle: entry.Title, + ObjectUrl: entry.Url, + PermissionLevel: trimmedLevel, + AccessType: accessType, + GrantedThrough: entry.GrantedThrough, + IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel), + IsExternalUser: PermissionEntryHelper.IsExternalUser(login)); + } + } + } + } + + /// + /// Classifies a PermissionEntry into Direct, Group, or Inherited access type. + /// + private static AccessType ClassifyAccessType(PermissionEntry entry) + { + // Inherited: object does not have unique permissions + if (!entry.HasUniquePermissions) + return AccessType.Inherited; + + // Group: GrantedThrough starts with "SharePoint Group:" + if (entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase)) + return AccessType.Group; + + // Direct: unique permissions, granted directly + return AccessType.Direct; + } +}