---
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"
---

<objective>
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
</objective>

<execution_context>
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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

<interfaces>
<!-- From 07-01: Models and interfaces this plan implements -->
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<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
        ISessionManager sessionManager,
        IReadOnlyList<string> targetUserLogins,
        IReadOnlyList<SiteInfo> sites,
        ScanOptions options,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}
```

<!-- Existing services this depends on -->
From SharepointToolbox/Services/IPermissionsService.cs:
```csharp
public interface IPermissionsService
{
    Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
        ClientContext ctx, ScanOptions options,
        IProgress<OperationProgress> 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);
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Implement UserAccessAuditService</name>
  <files>SharepointToolbox/Services/UserAccessAuditService.cs</files>
  <action>
    Create `SharepointToolbox/Services/UserAccessAuditService.cs`:

    ```csharp
    using SharepointToolbox.Core.Helpers;
    using SharepointToolbox.Core.Models;

    namespace SharepointToolbox.Services;

    /// <summary>
    /// Scans permissions across multiple sites via PermissionsService,
    /// then filters and transforms results into user-centric UserAccessEntry records.
    /// </summary>
    public class UserAccessAuditService : IUserAccessAuditService
    {
        private readonly IPermissionsService _permissionsService;

        private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
        {
            "Full Control",
            "Site Collection Administrator"
        };

        public UserAccessAuditService(IPermissionsService permissionsService)
        {
            _permissionsService = permissionsService;
        }

        public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
            ISessionManager sessionManager,
            IReadOnlyList<string> targetUserLogins,
            IReadOnlyList<SiteInfo> sites,
            ScanOptions options,
            IProgress<OperationProgress> 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<UserAccessEntry>();

            var allEntries = new List<UserAccessEntry>();

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

        /// <summary>
        /// Transforms PermissionEntry list into UserAccessEntry list,
        /// filtering to only entries that match target user logins.
        /// </summary>
        private static IEnumerable<UserAccessEntry> TransformEntries(
            IReadOnlyList<PermissionEntry> permEntries,
            HashSet<string> 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));
                    }
                }
            }
        }

        /// <summary>
        /// Classifies a PermissionEntry into Direct, Group, or Inherited access type.
        /// </summary>
        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)
  </action>
  <verify>
    <automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
  </verify>
  <done>UserAccessAuditService.cs compiles, implements IUserAccessAuditService, scans via IPermissionsService, filters by user login, classifies access types, flags high-privilege and external users.</done>
</task>

</tasks>

<verification>
- `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"
</verification>

<success_criteria>
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.
</success_criteria>

<output>
After completion, create `.planning/phases/07-user-access-audit/07-02-SUMMARY.md`
</output>
