| | | 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.Domain.Persistence; |
| | | 8 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 9 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 10 | | using ArturRios.Output; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="ReapplyErasuresCommand" /> (NFR-25): anonymises identities named by the |
| | | 17 | | /// erasure ledger, for use after a database restore. All failures are returned as errors on the |
| | | 18 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 19 | | /// </summary> |
| | | 20 | | /// <remarks> |
| | | 21 | | /// <para> |
| | | 22 | | /// <b>It anonymises immediately, not on a deadline.</b> Every identity named here was |
| | | 23 | | /// already erased once; the deadline that governed it has passed, and starting a fresh |
| | | 24 | | /// window would hand back time on an obligation that was discharged before the restore. |
| | | 25 | | /// </para> |
| | | 26 | | /// <para> |
| | | 27 | | /// <b>An unknown identifier is not an error.</b> A ledger covers every erasure ever |
| | | 28 | | /// completed, while a restore reinstates only what one backup held, so most of the list will |
| | | 29 | | /// match nothing on any given run. Treating that as failure would make the reconciliation |
| | | 30 | | /// look broken every time it worked. |
| | | 31 | | /// </para> |
| | | 32 | | /// <para> |
| | | 33 | | /// <b>It is deliberately usable more than once.</b> A restore is a stressful operation and |
| | | 34 | | /// the runbook step may be repeated or half-completed; running it twice anonymises nothing |
| | | 35 | | /// extra and reports the difference. |
| | | 36 | | /// </para> |
| | | 37 | | /// </remarks> |
| | 9 | 38 | | public class ReapplyErasuresCommandHandler( |
| | 9 | 39 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 9 | 40 | | IAsyncRepository<Person, long> personWriter, |
| | 9 | 41 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 9 | 42 | | IAsyncRepository<GoogleUser, long> googleUserWriter, |
| | 9 | 43 | | IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter, |
| | 9 | 44 | | IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter, |
| | 9 | 45 | | IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter, |
| | 9 | 46 | | IAtomicWrites atomicWrites) |
| | | 47 | | : ICommandHandlerAsync<ReapplyErasuresCommand, ReapplyErasuresCommandOutput> |
| | | 48 | | { |
| | | 49 | | public async Task<DataOutput<ReapplyErasuresCommandOutput?>> HandleAsync(ReapplyErasuresCommand command, Cancellatio |
| | | 50 | | { |
| | 10 | 51 | | var output = DataOutput<ReapplyErasuresCommandOutput?>.New; |
| | 10 | 52 | | var requested = command.SubjectIds.Distinct().ToList(); |
| | | 53 | | |
| | 10 | 54 | | if (requested.Count == 0) |
| | | 55 | | { |
| | 1 | 56 | | return output.WithError(ErasureMessages.NoSubjectsToReapply); |
| | | 57 | | } |
| | | 58 | | |
| | 9 | 59 | | var now = DateTime.UtcNow; |
| | 9 | 60 | | var anonymised = 0; |
| | 9 | 61 | | var alreadyAnonymised = 0; |
| | | 62 | | |
| | 9 | 63 | | var persons = await personReader.Query() |
| | 9 | 64 | | .Where(person => requested.Contains(person.PublicId)) |
| | 9 | 65 | | .ToListAsync(); |
| | | 66 | | |
| | 9 | 67 | | var googleUsers = await googleUserReader.Query() |
| | 9 | 68 | | .Where(googleUser => requested.Contains(googleUser.PublicId)) |
| | 9 | 69 | | .ToListAsync(); |
| | | 70 | | |
| | 9 | 71 | | var toWrite = new List<Person>(); |
| | | 72 | | |
| | 36 | 73 | | foreach (var person in persons) |
| | | 74 | | { |
| | 9 | 75 | | if (person.AnonymisedAt is not null) |
| | | 76 | | { |
| | 3 | 77 | | alreadyAnonymised++; |
| | | 78 | | |
| | 3 | 79 | | continue; |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | // Marked as a subject-requested deletion dated now, so the record's own state says why |
| | | 83 | | // it is gone and the audit trail reads correctly afterwards. |
| | 6 | 84 | | person.IsDeleted = true; |
| | 6 | 85 | | person.DeletedAt = person.DeletedAt ?? now; |
| | 6 | 86 | | person.DeletionKind = (int)DeletionKinds.SubjectRequested; |
| | | 87 | | |
| | 6 | 88 | | IdentityAnonymiser.Anonymise(person, now); |
| | | 89 | | |
| | | 90 | | // The factor budgets, as the scheduled pass clears them: by their own targeted writes. |
| | 6 | 91 | | await atomicWrites.ClearSecondFactorAttemptsAsync(person); |
| | 6 | 92 | | await atomicWrites.ClearReauthenticationAttemptsAsync(person); |
| | 6 | 93 | | await atomicWrites.ClearAccountEmailsAsync(person); |
| | | 94 | | |
| | | 95 | | // The password hash and salt likewise: a whole-row save never writes them once the row |
| | | 96 | | // exists, so the anonymiser's in-memory clear reaches the row only through this. |
| | 6 | 97 | | await atomicWrites.ClearPasswordAsync(person); |
| | | 98 | | |
| | | 99 | | // Nothing blocks it any more; it is done — as the scheduled pass leaves it. |
| | 6 | 100 | | person.ErasureBlockedReason = null; |
| | | 101 | | |
| | 6 | 102 | | toWrite.Add(person); |
| | 6 | 103 | | anonymised++; |
| | 6 | 104 | | } |
| | | 105 | | |
| | 9 | 106 | | var googleUsersToWrite = new List<GoogleUser>(); |
| | | 107 | | |
| | 20 | 108 | | foreach (var googleUser in googleUsers) |
| | | 109 | | { |
| | 1 | 110 | | if (googleUser.AnonymisedAt is not null) |
| | | 111 | | { |
| | 0 | 112 | | alreadyAnonymised++; |
| | | 113 | | |
| | 0 | 114 | | continue; |
| | | 115 | | } |
| | | 116 | | |
| | 1 | 117 | | googleUser.IsDeleted = true; |
| | 1 | 118 | | googleUser.DeletedAt = googleUser.DeletedAt ?? now; |
| | 1 | 119 | | googleUser.DeletionKind = (int)DeletionKinds.SubjectRequested; |
| | | 120 | | |
| | 1 | 121 | | IdentityAnonymiser.Anonymise(googleUser, now); |
| | | 122 | | |
| | 1 | 123 | | googleUsersToWrite.Add(googleUser); |
| | 1 | 124 | | anonymised++; |
| | | 125 | | } |
| | | 126 | | |
| | | 127 | | // The restored copy brought back what the original erasure removed alongside the identity — |
| | | 128 | | // the two-factor secret with its codes, and any reset or verification tokens. Removed before |
| | | 129 | | // the identity is overwritten, as the scheduled pass does, so a failure here leaves the |
| | | 130 | | // record selectable for another attempt rather than anonymised with its secret kept. |
| | 9 | 131 | | var (_, dependentErrors) = await PersonDependentsRemoval.RemoveAsync( |
| | 6 | 132 | | toWrite.Select(person => person.Id).ToList(), |
| | 9 | 133 | | passwordResetTokenWriter, |
| | 9 | 134 | | emailVerificationTokenWriter, |
| | 9 | 135 | | twoFactorAuthWriter); |
| | | 136 | | |
| | 9 | 137 | | if (dependentErrors.Count > 0) |
| | | 138 | | { |
| | 0 | 139 | | return output.WithErrors(dependentErrors); |
| | | 140 | | } |
| | | 141 | | |
| | 9 | 142 | | var errors = new List<string>(); |
| | | 143 | | |
| | 9 | 144 | | if (toWrite.Count > 0) |
| | | 145 | | { |
| | 6 | 146 | | var update = await personWriter.UpdateRangeAsync(toWrite); |
| | | 147 | | |
| | 6 | 148 | | if (!update.Success) |
| | | 149 | | { |
| | 0 | 150 | | errors.AddRange(update.Errors); |
| | | 151 | | } |
| | | 152 | | } |
| | | 153 | | |
| | 9 | 154 | | if (googleUsersToWrite.Count > 0) |
| | | 155 | | { |
| | 1 | 156 | | var update = await googleUserWriter.UpdateRangeAsync(googleUsersToWrite); |
| | | 157 | | |
| | 1 | 158 | | if (!update.Success) |
| | | 159 | | { |
| | 0 | 160 | | errors.AddRange(update.Errors); |
| | | 161 | | } |
| | | 162 | | } |
| | | 163 | | |
| | 9 | 164 | | if (errors.Count > 0) |
| | | 165 | | { |
| | 0 | 166 | | return output.WithErrors(errors); |
| | | 167 | | } |
| | | 168 | | |
| | 18 | 169 | | var found = persons.Select(person => person.PublicId) |
| | 1 | 170 | | .Concat(googleUsers.Select(googleUser => googleUser.PublicId)) |
| | 9 | 171 | | .ToHashSet(); |
| | | 172 | | |
| | 9 | 173 | | return output |
| | 9 | 174 | | .WithData(new ReapplyErasuresCommandOutput |
| | 9 | 175 | | { |
| | 9 | 176 | | Anonymised = anonymised, |
| | 9 | 177 | | AlreadyAnonymised = alreadyAnonymised, |
| | 12 | 178 | | NotFound = requested.Where(id => !found.Contains(id)).ToList() |
| | 9 | 179 | | }) |
| | 9 | 180 | | .WithMessage(ErasureMessages.ErasuresReapplied); |
| | 10 | 181 | | } |
| | | 182 | | } |