< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.ExportMyDataCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ExportMyDataCommandHandler.cs
Line coverage
98%
Covered lines: 177
Uncovered lines: 3
Coverable lines: 180
Total lines: 260
Line coverage: 98.3%
Branch coverage
64%
Covered branches: 18
Total branches: 28
Branch coverage: 64.2%
Method coverage
85%
Covered methods: 6
Fully covered methods: 5
Total methods: 7
Method coverage: 85.7%
Full method coverage: 71.4%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%44100%
ExportPersonAsync()71.42%141498.78%
Public()0%620%
ExportGoogleUserAsync()50%22100%
AuditEntriesAsync()100%22100%
Processing(...)50%22100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/ExportMyDataCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11using Application = ArturRios.Heimdall.Domain.Entities.Application;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="ExportMyDataCommand" /> (UC-41): assembles everything held about the
 17///     authenticated caller into one document. All failures are returned as errors on the
 18///     <see cref="DataOutput{T}" /> rather than thrown.
 19/// </summary>
 20/// <remarks>
 21///     <para>
 22///         <b>The subject is the token's, never the request's.</b> Same shape as UC-42: no path
 23///         parameter and no body field names an identity, so there is no way to ask for somebody
 24///         else's data. The rule is enforced by the absence of the parameter rather than by a check.
 25///     </para>
 26///     <para>
 27///         <b>A deleted identity can still export.</b> The lookups omit the <c>!IsDeleted</c> filter
 28///         that most handlers apply. Somebody suspended pending erasure has more reason to want a
 29///         copy than anyone, and Art. 15 is not conditional on the account being in good standing.
 30///         An anonymised record is a different matter — there is nothing left that relates to a
 31///         person — but it also cannot authenticate, so it never reaches here.
 32///     </para>
 33///     <para>
 34///         <b>What is deliberately absent.</b> Audit entries whose attribution has been cleared
 35///         under NFR-21 do not appear, and cannot: once cleared, nothing connects them to this
 36///         person. Saying so on the document would itself be a re-identification, so the export
 37///         reports what it can still find and the retention section explains why older entries stop
 38///         being findable.
 39///     </para>
 40/// </remarks>
 2441public class ExportMyDataCommandHandler(
 2442    IAsyncReadOnlyRepository<Person, long> personReader,
 2443    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 2444    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2445    IAsyncReadOnlyRepository<Application, long> applicationReader,
 2446    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 2447    IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader,
 2448    IAsyncReadOnlyRepository<AuditLog, long> auditReader)
 49    : ICommandHandlerAsync<ExportMyDataCommand, DataExportCommandOutput>
 50{
 51    public async Task<DataOutput<DataExportCommandOutput?>> HandleAsync(ExportMyDataCommand command, CancellationToken c
 52    {
 2453        var output = DataOutput<DataExportCommandOutput?>.New;
 54
 2455        var person = await personReader.Query()
 2456            .Include(x => x.ScopeMembership)
 2457            .Include(x => x.ScopeOwnerships)
 2458            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 59
 2460        if (person is not null)
 61        {
 2062            return output
 2063                .WithData(await ExportPersonAsync(person))
 2064                .WithMessage(ErasureMessages.DataExported);
 65        }
 66
 467        var googleUser = await googleUserReader.Query()
 468            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 69
 470        return googleUser is not null
 471            ? output.WithData(await ExportGoogleUserAsync(googleUser)).WithMessage(ErasureMessages.DataExported)
 472            : output.WithError(ErasureMessages.NotEligible);
 2473    }
 74
 75    private async Task<DataExportCommandOutput> ExportPersonAsync(Person person)
 76    {
 2077        var twoFactor = await twoFactorReader.Query()
 2078            .FirstOrDefaultAsync(x => x.PersonId == person.Id);
 79
 2080        var unusedRecoveryCodes = twoFactor is null
 2081            ? 0
 2082            : await recoveryCodeReader.Query()
 2083                .CountAsync(x => x.TwoFactorAuthId == twoFactor.Id && !x.Used);
 84
 2085        var applications = await applicationReader.Query()
 2086            .Where(x => x.OwnerId == person.Id)
 2087            .Select(x => new DataExportApplication
 2088            {
 2089                Id = x.PublicId,
 2090                Name = x.Name,
 2091                ScopeId = x.Scope.PublicId,
 2092                IsDeleted = x.IsDeleted
 2093            })
 2094            .ToListAsync();
 95
 96        // Internal scope ids are translated to public ones: NFR-15 applies to an export as much as
 97        // to any other response.
 2098        var scopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList();
 99
 20100        if (person.ScopeMembership is not null)
 101        {
 0102            scopeIds.Add(person.ScopeMembership.ScopeId);
 103        }
 104
 20105        var publicScopeIds = await scopeReader.Query()
 20106            .Where(scope => scopeIds.Contains(scope.Id))
 20107            .Select(scope => new { scope.Id, scope.PublicId })
 20108            .ToListAsync();
 109
 110        Guid? Public(long internalId) =>
 0111            publicScopeIds.FirstOrDefault(scope => scope.Id == internalId)?.PublicId;
 112
 20113        var (auditEntries, truncated) = await AuditEntriesAsync(person.PublicId);
 114
 20115        return new DataExportCommandOutput
 20116        {
 20117            ExportedAt = DateTime.UtcNow,
 20118            Subject = new DataExportSubject
 20119            {
 20120                Id = person.PublicId,
 20121                Kind = "Person",
 20122                Name = person.Name,
 20123                Email = person.Email,
 20124                EmailVerified = person.EmailVerified,
 20125                Role = (int)person.RoleId,
 20126                IsDeleted = person.IsDeleted,
 20127                CreatedAt = person.CreatedAt,
 20128                UpdatedAt = person.UpdatedAt
 20129            },
 20130            Security = new DataExportSecurity
 20131            {
 20132                PasswordSet = person.PasswordHash.Length > 0,
 20133                TwoFactorActive = twoFactor?.IsActive ?? false,
 20134                TwoFactorAppEnabled = twoFactor?.AppEnabled ?? false,
 20135                TwoFactorEmailEnabled = twoFactor?.EmailEnabled ?? false,
 20136                UnusedRecoveryCodes = unusedRecoveryCodes,
 20137                FailedLoginAttempts = person.FailedLoginAttempts,
 20138                LockedOutUntil = person.LockedOutUntil,
 20139                FailedSecondFactorAttempts = person.FailedSecondFactorAttempts,
 20140                SecondFactorLockedOutUntil = person.SecondFactorLockedOutUntil,
 20141                SecondFactorLockouts = person.SecondFactorLockouts,
 20142                FailedReauthenticationAttempts = person.FailedReauthenticationAttempts,
 20143                ReauthenticationWindowStartedAt = person.ReauthenticationWindowStartedAt,
 20144                ReauthenticationPausedUntil = person.ReauthenticationPausedUntil,
 20145                AccountEmailsSent = person.AccountEmailsSent,
 20146                AccountEmailWindowEndsAt = person.AccountEmailWindowEndsAt,
 20147                AccountEmailLastSentAt = person.AccountEmailLastSentAt
 20148            },
 20149            Processing = Processing(person.LegalBasis, person.PrivacyNoticeVersion, person.BasisRecordedAt),
 20150            Erasure = new DataExportErasure
 20151            {
 20152                IsDeleted = person.IsDeleted,
 20153                DeletedAt = person.DeletedAt,
 20154                DeletionKind = person.DeletionKind,
 20155                AnonymisedAt = person.AnonymisedAt,
 20156                ErasureRequestedAt = person.ErasureRequestedAt,
 20157                ErasureDueAt = person.ErasureDueAt,
 20158                ErasureBlockedReason = person.ErasureBlockedReason
 20159            },
 20160            ScopeMemberships = person.ScopeMembership is null
 20161                ? []
 20162                : [Public(person.ScopeMembership.ScopeId) ?? Guid.Empty],
 20163            ScopeOwnerships = person.ScopeOwnerships
 0164                .Select(ownership => Public(ownership.ScopeId) ?? Guid.Empty)
 20165                .ToList(),
 20166            Applications = applications,
 20167            AuditEntries = auditEntries,
 20168            AuditEntriesTruncated = truncated,
 20169            Withheld = DataExportDisclosure.Withheld,
 20170            Recipients = DataExportDisclosure.Recipients,
 20171            Sources = DataExportDisclosure.Sources,
 20172            Retention = DataExportDisclosure.Retention
 20173        };
 20174    }
 175
 176    private async Task<DataExportCommandOutput> ExportGoogleUserAsync(GoogleUser googleUser)
 177    {
 3178        var scope = await scopeReader.Query()
 3179            .FirstOrDefaultAsync(x => x.Id == googleUser.ScopeId);
 180
 3181        var (auditEntries, truncated) = await AuditEntriesAsync(googleUser.PublicId);
 182
 3183        return new DataExportCommandOutput
 3184        {
 3185            ExportedAt = DateTime.UtcNow,
 3186            Subject = new DataExportSubject
 3187            {
 3188                Id = googleUser.PublicId,
 3189                Kind = "GoogleUser",
 3190                Name = googleUser.Name,
 3191                Email = googleUser.Email,
 3192                EmailVerified = googleUser.EmailVerified,
 3193                // FR-GO-04 makes a Google User permanently User-equivalent, so there is no stored
 3194                // role to report — reporting one would imply a choice that does not exist.
 3195                Role = null,
 3196                GoogleId = googleUser.GoogleId,
 3197                ProfilePictureUrl = googleUser.ProfilePictureUrl,
 3198                IsDeleted = googleUser.IsDeleted,
 3199                CreatedAt = googleUser.CreatedAt,
 3200                UpdatedAt = googleUser.UpdatedAt
 3201            },
 3202            // A Google User has no password and cannot enable two-factor (FR-2F-01), so every field
 3203            // here is false by construction rather than by happening to be unset.
 3204            Security = new DataExportSecurity { PasswordSet = false },
 3205            Processing = Processing(
 3206                googleUser.LegalBasis, googleUser.PrivacyNoticeVersion, googleUser.BasisRecordedAt),
 3207            Erasure = new DataExportErasure
 3208            {
 3209                IsDeleted = googleUser.IsDeleted,
 3210                DeletedAt = googleUser.DeletedAt,
 3211                DeletionKind = googleUser.DeletionKind,
 3212                AnonymisedAt = googleUser.AnonymisedAt,
 3213                ErasureRequestedAt = googleUser.ErasureRequestedAt,
 3214                ErasureDueAt = googleUser.ErasureDueAt,
 3215                ErasureBlockedReason = googleUser.ErasureBlockedReason
 3216            },
 3217            ScopeMemberships = scope is null ? [] : [scope.PublicId],
 3218            AuditEntries = auditEntries,
 3219            AuditEntriesTruncated = truncated,
 3220            Withheld = DataExportDisclosure.Withheld,
 3221            Recipients = DataExportDisclosure.Recipients,
 3222            Sources = DataExportDisclosure.Sources,
 3223            Retention = DataExportDisclosure.Retention
 3224        };
 3225    }
 226
 227    private async Task<(List<DataExportAuditEntry> Entries, bool Truncated)> AuditEntriesAsync(Guid subjectId)
 228    {
 229        // One more than the cap, so reaching it is detectable without a second count query.
 23230        var entries = await auditReader.Query()
 23231            .Where(entry => entry.ActorPersonId == subjectId)
 23232            .OrderByDescending(entry => entry.CreatedAt)
 23233            .Take(DataExportDisclosure.MaximumAuditEntries + 1)
 23234            .Select(entry => new DataExportAuditEntry
 23235            {
 23236                Id = entry.PublicId,
 23237                Action = entry.Action,
 23238                TargetId = entry.TargetId,
 23239                Succeeded = entry.Succeeded,
 23240                FailureReason = entry.FailureReason,
 23241                CreatedAt = entry.CreatedAt
 23242            })
 23243            .ToListAsync();
 244
 23245        var truncated = entries.Count > DataExportDisclosure.MaximumAuditEntries;
 246
 23247        return (truncated ? entries.Take(DataExportDisclosure.MaximumAuditEntries).ToList() : entries, truncated);
 23248    }
 249
 250    private static DataExportProcessing Processing(int basis, string? noticeVersion, DateTime? recordedAt) =>
 23251        new()
 23252        {
 23253            LegalBasis = basis,
 23254            LegalBasisName = Enum.IsDefined((LegalBases)basis)
 23255                ? ((LegalBases)basis).ToString()
 23256                : nameof(LegalBases.Unrecorded),
 23257            PrivacyNoticeVersion = noticeVersion,
 23258            RecordedAt = recordedAt
 23259        };
 260}