--- phase: 07-user-access-audit plan: 02 type: execute wave: 2 depends_on: ["07-01"] files_modified: - SharepointToolbox/Services/UserAccessAuditService.cs autonomous: true requirements: - UACC-01 - UACC-02 must_haves: truths: - "UserAccessAuditService scans permissions via PermissionsService.ScanSiteAsync and filters by target user logins" - "Each PermissionEntry is correctly classified as Direct, Group, or Inherited AccessType" - "High-privilege entries (Full Control, Site Collection Administrator) are flagged" - "External users (#EXT#) are detected via PermissionEntryHelper.IsExternalUser" - "Multi-user semicolon-delimited PermissionEntry rows are correctly split into per-user UserAccessEntry rows" artifacts: - path: "SharepointToolbox/Services/UserAccessAuditService.cs" provides: "Implementation of IUserAccessAuditService" contains: "class UserAccessAuditService" key_links: - from: "SharepointToolbox/Services/UserAccessAuditService.cs" to: "SharepointToolbox/Services/IPermissionsService.cs" via: "Constructor injection + ScanSiteAsync call" pattern: "ScanSiteAsync" - from: "SharepointToolbox/Services/UserAccessAuditService.cs" to: "SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs" via: "IsExternalUser for guest detection" pattern: "IsExternalUser" --- Implement UserAccessAuditService that scans sites via PermissionsService and transforms the results into user-centric UserAccessEntry records with access type classification. Purpose: Core business logic — takes raw PermissionEntry results and produces the user-centric audit view that the UI and exports consume. Output: UserAccessAuditService.cs @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/07-user-access-audit/07-CONTEXT.md @.planning/phases/07-user-access-audit/07-01-SUMMARY.md From SharepointToolbox/Core/Models/UserAccessEntry.cs: ```csharp public enum AccessType { Direct, Group, Inherited } public record UserAccessEntry( string UserDisplayName, string UserLogin, string SiteUrl, string SiteTitle, string ObjectType, string ObjectTitle, string ObjectUrl, string PermissionLevel, AccessType AccessType, string GrantedThrough, bool IsHighPrivilege, bool IsExternalUser); ``` From SharepointToolbox/Services/IUserAccessAuditService.cs: ```csharp public interface IUserAccessAuditService { Task> AuditUsersAsync( ISessionManager sessionManager, IReadOnlyList targetUserLogins, IReadOnlyList sites, ScanOptions options, IProgress progress, CancellationToken ct); } ``` From SharepointToolbox/Services/IPermissionsService.cs: ```csharp public interface IPermissionsService { Task> ScanSiteAsync( ClientContext ctx, ScanOptions options, IProgress progress, CancellationToken ct); } ``` From SharepointToolbox/Core/Models/PermissionEntry.cs: ```csharp public record PermissionEntry( string ObjectType, string Title, string Url, bool HasUniquePermissions, string Users, string UserLogins, string PermissionLevels, string GrantedThrough, string PrincipalType); ``` From SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs: ```csharp public static bool IsExternalUser(string loginName) => loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); ``` From SharepointToolbox/Services/ISessionManager.cs (usage pattern): ```csharp var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct); ``` Task 1: Implement UserAccessAuditService SharepointToolbox/Services/UserAccessAuditService.cs Create `SharepointToolbox/Services/UserAccessAuditService.cs`: ```csharp 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; } } ``` Key design decisions: - Reuses PermissionsService.ScanSiteAsync entirely (no CSOM calls) -- filters results post-scan - User matching uses case-insensitive "contains" to handle both plain emails and SharePoint claim format - Each PermissionEntry row with semicolon-delimited users is split into individual UserAccessEntry rows - Each semicolon-delimited permission level becomes a separate row (fully denormalized for grid display) - AccessType classification: !HasUniquePermissions = Inherited, GrantedThrough contains "SharePoint Group:" = Group, else Direct - SessionManager profile construction follows PermissionsViewModel pattern (TenantUrl = site URL) cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5 UserAccessAuditService.cs compiles, implements IUserAccessAuditService, scans via IPermissionsService, filters by user login, classifies access types, flags high-privilege and external users. - `dotnet build SharepointToolbox/SharepointToolbox.csproj` succeeds with 0 errors - UserAccessAuditService implements IUserAccessAuditService interface - TransformEntries correctly splits semicolon-delimited logins/names/levels - ClassifyAccessType maps HasUniquePermissions + GrantedThrough to AccessType enum - HighPrivilegeLevels includes "Full Control" and "Site Collection Administrator" The audit engine is implemented: given a list of user logins and sites, it produces a flat list of UserAccessEntry records with correct access type classification, high-privilege detection, and external user flagging. Ready for ViewModel consumption in 07-04. After completion, create `.planning/phases/07-user-access-audit/07-02-SUMMARY.md`