Files
Sharepoint-Toolbox/.planning/phases/07-user-access-audit/07-02-PLAN.md
Dev 19e4c3852d docs(07): create phase plan - 8 plans across 5 waves
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:32:39 +02:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
07-user-access-audit 02 execute 2
07-01
SharepointToolbox/Services/UserAccessAuditService.cs
true
UACC-01
UACC-02
truths artifacts key_links
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
path provides contains
SharepointToolbox/Services/UserAccessAuditService.cs Implementation of IUserAccessAuditService class UserAccessAuditService
from to via pattern
SharepointToolbox/Services/UserAccessAuditService.cs SharepointToolbox/Services/IPermissionsService.cs Constructor injection + ScanSiteAsync call ScanSiteAsync
from to via pattern
SharepointToolbox/Services/UserAccessAuditService.cs SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs IsExternalUser for guest detection 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

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

@.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<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
        ISessionManager sessionManager,
        IReadOnlyList<string> targetUserLogins,
        IReadOnlyList<SiteInfo> sites,
        ScanOptions options,
        IProgress<OperationProgress> progress,
        CancellationToken ct);
}

From SharepointToolbox/Services/IPermissionsService.cs:

public interface IPermissionsService
{
    Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
        ClientContext ctx, ScanOptions options,
        IProgress<OperationProgress> progress, CancellationToken ct);
}

From SharepointToolbox/Core/Models/PermissionEntry.cs:

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:

public static bool IsExternalUser(string loginName) => loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);

From SharepointToolbox/Services/ISessionManager.cs (usage pattern):

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;

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

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

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