| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 8 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | using Application = ArturRios.Heimdall.Domain.Entities.Application; |
| | | 12 | | |
| | | 13 | | namespace 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> |
| | 24 | 41 | | public class ExportMyDataCommandHandler( |
| | 24 | 42 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 24 | 43 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 24 | 44 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 24 | 45 | | IAsyncReadOnlyRepository<Application, long> applicationReader, |
| | 24 | 46 | | IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader, |
| | 24 | 47 | | IAsyncReadOnlyRepository<TwoFactorRecoveryCode, long> recoveryCodeReader, |
| | 24 | 48 | | IAsyncReadOnlyRepository<AuditLog, long> auditReader) |
| | | 49 | | : ICommandHandlerAsync<ExportMyDataCommand, DataExportCommandOutput> |
| | | 50 | | { |
| | | 51 | | public async Task<DataOutput<DataExportCommandOutput?>> HandleAsync(ExportMyDataCommand command, CancellationToken c |
| | | 52 | | { |
| | 24 | 53 | | var output = DataOutput<DataExportCommandOutput?>.New; |
| | | 54 | | |
| | 24 | 55 | | var person = await personReader.Query() |
| | 24 | 56 | | .Include(x => x.ScopeMembership) |
| | 24 | 57 | | .Include(x => x.ScopeOwnerships) |
| | 24 | 58 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId); |
| | | 59 | | |
| | 24 | 60 | | if (person is not null) |
| | | 61 | | { |
| | 20 | 62 | | return output |
| | 20 | 63 | | .WithData(await ExportPersonAsync(person)) |
| | 20 | 64 | | .WithMessage(ErasureMessages.DataExported); |
| | | 65 | | } |
| | | 66 | | |
| | 4 | 67 | | var googleUser = await googleUserReader.Query() |
| | 4 | 68 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId); |
| | | 69 | | |
| | 4 | 70 | | return googleUser is not null |
| | 4 | 71 | | ? output.WithData(await ExportGoogleUserAsync(googleUser)).WithMessage(ErasureMessages.DataExported) |
| | 4 | 72 | | : output.WithError(ErasureMessages.NotEligible); |
| | 24 | 73 | | } |
| | | 74 | | |
| | | 75 | | private async Task<DataExportCommandOutput> ExportPersonAsync(Person person) |
| | | 76 | | { |
| | 20 | 77 | | var twoFactor = await twoFactorReader.Query() |
| | 20 | 78 | | .FirstOrDefaultAsync(x => x.PersonId == person.Id); |
| | | 79 | | |
| | 20 | 80 | | var unusedRecoveryCodes = twoFactor is null |
| | 20 | 81 | | ? 0 |
| | 20 | 82 | | : await recoveryCodeReader.Query() |
| | 20 | 83 | | .CountAsync(x => x.TwoFactorAuthId == twoFactor.Id && !x.Used); |
| | | 84 | | |
| | 20 | 85 | | var applications = await applicationReader.Query() |
| | 20 | 86 | | .Where(x => x.OwnerId == person.Id) |
| | 20 | 87 | | .Select(x => new DataExportApplication |
| | 20 | 88 | | { |
| | 20 | 89 | | Id = x.PublicId, |
| | 20 | 90 | | Name = x.Name, |
| | 20 | 91 | | ScopeId = x.Scope.PublicId, |
| | 20 | 92 | | IsDeleted = x.IsDeleted |
| | 20 | 93 | | }) |
| | 20 | 94 | | .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. |
| | 20 | 98 | | var scopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList(); |
| | | 99 | | |
| | 20 | 100 | | if (person.ScopeMembership is not null) |
| | | 101 | | { |
| | 0 | 102 | | scopeIds.Add(person.ScopeMembership.ScopeId); |
| | | 103 | | } |
| | | 104 | | |
| | 20 | 105 | | var publicScopeIds = await scopeReader.Query() |
| | 20 | 106 | | .Where(scope => scopeIds.Contains(scope.Id)) |
| | 20 | 107 | | .Select(scope => new { scope.Id, scope.PublicId }) |
| | 20 | 108 | | .ToListAsync(); |
| | | 109 | | |
| | | 110 | | Guid? Public(long internalId) => |
| | 0 | 111 | | publicScopeIds.FirstOrDefault(scope => scope.Id == internalId)?.PublicId; |
| | | 112 | | |
| | 20 | 113 | | var (auditEntries, truncated) = await AuditEntriesAsync(person.PublicId); |
| | | 114 | | |
| | 20 | 115 | | return new DataExportCommandOutput |
| | 20 | 116 | | { |
| | 20 | 117 | | ExportedAt = DateTime.UtcNow, |
| | 20 | 118 | | Subject = new DataExportSubject |
| | 20 | 119 | | { |
| | 20 | 120 | | Id = person.PublicId, |
| | 20 | 121 | | Kind = "Person", |
| | 20 | 122 | | Name = person.Name, |
| | 20 | 123 | | Email = person.Email, |
| | 20 | 124 | | EmailVerified = person.EmailVerified, |
| | 20 | 125 | | Role = (int)person.RoleId, |
| | 20 | 126 | | IsDeleted = person.IsDeleted, |
| | 20 | 127 | | CreatedAt = person.CreatedAt, |
| | 20 | 128 | | UpdatedAt = person.UpdatedAt |
| | 20 | 129 | | }, |
| | 20 | 130 | | Security = new DataExportSecurity |
| | 20 | 131 | | { |
| | 20 | 132 | | PasswordSet = person.PasswordHash.Length > 0, |
| | 20 | 133 | | TwoFactorActive = twoFactor?.IsActive ?? false, |
| | 20 | 134 | | TwoFactorAppEnabled = twoFactor?.AppEnabled ?? false, |
| | 20 | 135 | | TwoFactorEmailEnabled = twoFactor?.EmailEnabled ?? false, |
| | 20 | 136 | | UnusedRecoveryCodes = unusedRecoveryCodes, |
| | 20 | 137 | | FailedLoginAttempts = person.FailedLoginAttempts, |
| | 20 | 138 | | LockedOutUntil = person.LockedOutUntil, |
| | 20 | 139 | | FailedSecondFactorAttempts = person.FailedSecondFactorAttempts, |
| | 20 | 140 | | SecondFactorLockedOutUntil = person.SecondFactorLockedOutUntil, |
| | 20 | 141 | | SecondFactorLockouts = person.SecondFactorLockouts, |
| | 20 | 142 | | FailedReauthenticationAttempts = person.FailedReauthenticationAttempts, |
| | 20 | 143 | | ReauthenticationWindowStartedAt = person.ReauthenticationWindowStartedAt, |
| | 20 | 144 | | ReauthenticationPausedUntil = person.ReauthenticationPausedUntil, |
| | 20 | 145 | | AccountEmailsSent = person.AccountEmailsSent, |
| | 20 | 146 | | AccountEmailWindowEndsAt = person.AccountEmailWindowEndsAt, |
| | 20 | 147 | | AccountEmailLastSentAt = person.AccountEmailLastSentAt |
| | 20 | 148 | | }, |
| | 20 | 149 | | Processing = Processing(person.LegalBasis, person.PrivacyNoticeVersion, person.BasisRecordedAt), |
| | 20 | 150 | | Erasure = new DataExportErasure |
| | 20 | 151 | | { |
| | 20 | 152 | | IsDeleted = person.IsDeleted, |
| | 20 | 153 | | DeletedAt = person.DeletedAt, |
| | 20 | 154 | | DeletionKind = person.DeletionKind, |
| | 20 | 155 | | AnonymisedAt = person.AnonymisedAt, |
| | 20 | 156 | | ErasureRequestedAt = person.ErasureRequestedAt, |
| | 20 | 157 | | ErasureDueAt = person.ErasureDueAt, |
| | 20 | 158 | | ErasureBlockedReason = person.ErasureBlockedReason |
| | 20 | 159 | | }, |
| | 20 | 160 | | ScopeMemberships = person.ScopeMembership is null |
| | 20 | 161 | | ? [] |
| | 20 | 162 | | : [Public(person.ScopeMembership.ScopeId) ?? Guid.Empty], |
| | 20 | 163 | | ScopeOwnerships = person.ScopeOwnerships |
| | 0 | 164 | | .Select(ownership => Public(ownership.ScopeId) ?? Guid.Empty) |
| | 20 | 165 | | .ToList(), |
| | 20 | 166 | | Applications = applications, |
| | 20 | 167 | | AuditEntries = auditEntries, |
| | 20 | 168 | | AuditEntriesTruncated = truncated, |
| | 20 | 169 | | Withheld = DataExportDisclosure.Withheld, |
| | 20 | 170 | | Recipients = DataExportDisclosure.Recipients, |
| | 20 | 171 | | Sources = DataExportDisclosure.Sources, |
| | 20 | 172 | | Retention = DataExportDisclosure.Retention |
| | 20 | 173 | | }; |
| | 20 | 174 | | } |
| | | 175 | | |
| | | 176 | | private async Task<DataExportCommandOutput> ExportGoogleUserAsync(GoogleUser googleUser) |
| | | 177 | | { |
| | 3 | 178 | | var scope = await scopeReader.Query() |
| | 3 | 179 | | .FirstOrDefaultAsync(x => x.Id == googleUser.ScopeId); |
| | | 180 | | |
| | 3 | 181 | | var (auditEntries, truncated) = await AuditEntriesAsync(googleUser.PublicId); |
| | | 182 | | |
| | 3 | 183 | | return new DataExportCommandOutput |
| | 3 | 184 | | { |
| | 3 | 185 | | ExportedAt = DateTime.UtcNow, |
| | 3 | 186 | | Subject = new DataExportSubject |
| | 3 | 187 | | { |
| | 3 | 188 | | Id = googleUser.PublicId, |
| | 3 | 189 | | Kind = "GoogleUser", |
| | 3 | 190 | | Name = googleUser.Name, |
| | 3 | 191 | | Email = googleUser.Email, |
| | 3 | 192 | | EmailVerified = googleUser.EmailVerified, |
| | 3 | 193 | | // FR-GO-04 makes a Google User permanently User-equivalent, so there is no stored |
| | 3 | 194 | | // role to report — reporting one would imply a choice that does not exist. |
| | 3 | 195 | | Role = null, |
| | 3 | 196 | | GoogleId = googleUser.GoogleId, |
| | 3 | 197 | | ProfilePictureUrl = googleUser.ProfilePictureUrl, |
| | 3 | 198 | | IsDeleted = googleUser.IsDeleted, |
| | 3 | 199 | | CreatedAt = googleUser.CreatedAt, |
| | 3 | 200 | | UpdatedAt = googleUser.UpdatedAt |
| | 3 | 201 | | }, |
| | 3 | 202 | | // A Google User has no password and cannot enable two-factor (FR-2F-01), so every field |
| | 3 | 203 | | // here is false by construction rather than by happening to be unset. |
| | 3 | 204 | | Security = new DataExportSecurity { PasswordSet = false }, |
| | 3 | 205 | | Processing = Processing( |
| | 3 | 206 | | googleUser.LegalBasis, googleUser.PrivacyNoticeVersion, googleUser.BasisRecordedAt), |
| | 3 | 207 | | Erasure = new DataExportErasure |
| | 3 | 208 | | { |
| | 3 | 209 | | IsDeleted = googleUser.IsDeleted, |
| | 3 | 210 | | DeletedAt = googleUser.DeletedAt, |
| | 3 | 211 | | DeletionKind = googleUser.DeletionKind, |
| | 3 | 212 | | AnonymisedAt = googleUser.AnonymisedAt, |
| | 3 | 213 | | ErasureRequestedAt = googleUser.ErasureRequestedAt, |
| | 3 | 214 | | ErasureDueAt = googleUser.ErasureDueAt, |
| | 3 | 215 | | ErasureBlockedReason = googleUser.ErasureBlockedReason |
| | 3 | 216 | | }, |
| | 3 | 217 | | ScopeMemberships = scope is null ? [] : [scope.PublicId], |
| | 3 | 218 | | AuditEntries = auditEntries, |
| | 3 | 219 | | AuditEntriesTruncated = truncated, |
| | 3 | 220 | | Withheld = DataExportDisclosure.Withheld, |
| | 3 | 221 | | Recipients = DataExportDisclosure.Recipients, |
| | 3 | 222 | | Sources = DataExportDisclosure.Sources, |
| | 3 | 223 | | Retention = DataExportDisclosure.Retention |
| | 3 | 224 | | }; |
| | 3 | 225 | | } |
| | | 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. |
| | 23 | 230 | | var entries = await auditReader.Query() |
| | 23 | 231 | | .Where(entry => entry.ActorPersonId == subjectId) |
| | 23 | 232 | | .OrderByDescending(entry => entry.CreatedAt) |
| | 23 | 233 | | .Take(DataExportDisclosure.MaximumAuditEntries + 1) |
| | 23 | 234 | | .Select(entry => new DataExportAuditEntry |
| | 23 | 235 | | { |
| | 23 | 236 | | Id = entry.PublicId, |
| | 23 | 237 | | Action = entry.Action, |
| | 23 | 238 | | TargetId = entry.TargetId, |
| | 23 | 239 | | Succeeded = entry.Succeeded, |
| | 23 | 240 | | FailureReason = entry.FailureReason, |
| | 23 | 241 | | CreatedAt = entry.CreatedAt |
| | 23 | 242 | | }) |
| | 23 | 243 | | .ToListAsync(); |
| | | 244 | | |
| | 23 | 245 | | var truncated = entries.Count > DataExportDisclosure.MaximumAuditEntries; |
| | | 246 | | |
| | 23 | 247 | | return (truncated ? entries.Take(DataExportDisclosure.MaximumAuditEntries).ToList() : entries, truncated); |
| | 23 | 248 | | } |
| | | 249 | | |
| | | 250 | | private static DataExportProcessing Processing(int basis, string? noticeVersion, DateTime? recordedAt) => |
| | 23 | 251 | | new() |
| | 23 | 252 | | { |
| | 23 | 253 | | LegalBasis = basis, |
| | 23 | 254 | | LegalBasisName = Enum.IsDefined((LegalBases)basis) |
| | 23 | 255 | | ? ((LegalBases)basis).ToString() |
| | 23 | 256 | | : nameof(LegalBases.Unrecorded), |
| | 23 | 257 | | PrivacyNoticeVersion = noticeVersion, |
| | 23 | 258 | | RecordedAt = recordedAt |
| | 23 | 259 | | }; |
| | | 260 | | } |