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;
+ }
+}