| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Auditing; |
| | | 3 | | using ArturRios.Heimdall.Command.Input; |
| | | 4 | | using ArturRios.Heimdall.Command.Output; |
| | | 5 | | using ArturRios.Heimdall.Command.Services; |
| | | 6 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 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 | | using Microsoft.Extensions.Logging; |
| | | 13 | | |
| | | 14 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// Handles <see cref="ReapplyErasuresCommand" /> (NFR-25): anonymises identities named by the |
| | | 18 | | /// erasure ledger, for use after a database restore. All failures are returned as errors on the |
| | | 19 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 20 | | /// </summary> |
| | | 21 | | /// <remarks> |
| | | 22 | | /// <para> |
| | | 23 | | /// <b>It anonymises immediately, not on a deadline.</b> Every identity named here was |
| | | 24 | | /// already erased once; the deadline that governed it has passed, and starting a fresh |
| | | 25 | | /// window would hand back time on an obligation that was discharged before the restore. |
| | | 26 | | /// </para> |
| | | 27 | | /// <para> |
| | | 28 | | /// <b>An unknown identifier is not an error.</b> A ledger covers every erasure ever |
| | | 29 | | /// completed, while a restore reinstates only what one backup held, so most of the list will |
| | | 30 | | /// match nothing on any given run. Treating that as failure would make the reconciliation |
| | | 31 | | /// look broken every time it worked. |
| | | 32 | | /// </para> |
| | | 33 | | /// <para> |
| | | 34 | | /// <b>It is deliberately usable more than once.</b> A restore is a stressful operation and |
| | | 35 | | /// the runbook step may be repeated or half-completed; running it twice anonymises nothing |
| | | 36 | | /// extra and reports the difference. |
| | | 37 | | /// </para> |
| | | 38 | | /// </remarks> |
| | 13 | 39 | | public class ReapplyErasuresCommandHandler( |
| | 13 | 40 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 13 | 41 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 13 | 42 | | IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter, |
| | 13 | 43 | | IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter, |
| | 13 | 44 | | IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter, |
| | 13 | 45 | | IAtomicWrites atomicWrites, |
| | 13 | 46 | | IScopeOwnerInvitations invitations, |
| | 13 | 47 | | IAuditLogWriter auditLog, |
| | 13 | 48 | | ILogger<ReapplyErasuresCommandHandler> logger) |
| | | 49 | | : ICommandHandlerAsync<ReapplyErasuresCommand, ReapplyErasuresCommandOutput> |
| | | 50 | | { |
| | | 51 | | public async Task<DataOutput<ReapplyErasuresCommandOutput?>> HandleAsync(ReapplyErasuresCommand command, Cancellatio |
| | | 52 | | { |
| | 14 | 53 | | var output = DataOutput<ReapplyErasuresCommandOutput?>.New; |
| | 14 | 54 | | var requested = command.SubjectIds.Distinct().ToList(); |
| | | 55 | | |
| | 14 | 56 | | if (requested.Count == 0) |
| | | 57 | | { |
| | 1 | 58 | | return output.WithError(ErasureMessages.NoSubjectsToReapply); |
| | | 59 | | } |
| | | 60 | | |
| | 13 | 61 | | var now = DateTime.UtcNow; |
| | 13 | 62 | | var anonymised = 0; |
| | 13 | 63 | | var alreadyAnonymised = 0; |
| | | 64 | | |
| | 13 | 65 | | var persons = await personReader.Query() |
| | 13 | 66 | | .Where(person => requested.Contains(person.PublicId)) |
| | 13 | 67 | | .ToListAsync(); |
| | | 68 | | |
| | 13 | 69 | | var googleUsers = await googleUserReader.Query() |
| | 13 | 70 | | .Where(googleUser => requested.Contains(googleUser.PublicId)) |
| | 13 | 71 | | .ToListAsync(); |
| | | 72 | | |
| | 13 | 73 | | var toWrite = new List<Person>(); |
| | | 74 | | |
| | 52 | 75 | | foreach (var person in persons) |
| | | 76 | | { |
| | 13 | 77 | | if (person.AnonymisedAt is not null) |
| | | 78 | | { |
| | 4 | 79 | | alreadyAnonymised++; |
| | | 80 | | |
| | 4 | 81 | | continue; |
| | | 82 | | } |
| | | 83 | | |
| | 9 | 84 | | toWrite.Add(person); |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | // The restored copy brought back what the original erasure removed alongside the identity — |
| | | 88 | | // the two-factor secret with its codes, and any reset or verification tokens. Removed before |
| | | 89 | | // the identity is overwritten, as the scheduled pass does, so a failure here leaves the |
| | | 90 | | // record selectable for another attempt rather than anonymised with its secret kept. |
| | 13 | 91 | | var (_, dependentErrors) = await PersonDependentsRemoval.RemoveAsync( |
| | 9 | 92 | | toWrite.Select(person => person.Id).ToList(), |
| | 13 | 93 | | passwordResetTokenWriter, |
| | 13 | 94 | | emailVerificationTokenWriter, |
| | 13 | 95 | | twoFactorAuthWriter); |
| | | 96 | | |
| | 13 | 97 | | if (dependentErrors.Count > 0) |
| | | 98 | | { |
| | 0 | 99 | | return output.WithErrors(dependentErrors); |
| | | 100 | | } |
| | | 101 | | |
| | | 102 | | // As the scheduled pass does: the restored copy may have brought back invitations the erasure |
| | | 103 | | // voided, and the person as the inviter of others (FR-SC-17). Before the identity is written, |
| | | 104 | | // like the dependents, so a failure leaves the person selectable for another attempt; every |
| | | 105 | | // person here ends anonymised, by this run or by one that got there first. Each void is audited |
| | | 106 | | // as it is made, so a later failure cannot lose its entry. |
| | 13 | 107 | | var voided = await ErasedPersonInvitations.ForgetAsync( |
| | 22 | 108 | | invitations, auditLog, logger, toWrite.Select(person => person.Id).ToList(), now); |
| | | 109 | | |
| | | 110 | | // Then each person, by one targeted write (#237): marked as a subject-requested deletion, so |
| | | 111 | | // the record's own state says why it is gone and the audit trail reads correctly afterwards, |
| | | 112 | | // and anonymised with its password and budgets in the same statement. One anonymised since it |
| | | 113 | | // was read is counted as such. |
| | 43 | 114 | | foreach (var person in toWrite) |
| | | 115 | | { |
| | 9 | 116 | | if (await IdentityAnonymiser.ReapplyAsync(atomicWrites, person, now)) |
| | | 117 | | { |
| | 8 | 118 | | anonymised++; |
| | | 119 | | } |
| | | 120 | | else |
| | | 121 | | { |
| | 0 | 122 | | alreadyAnonymised++; |
| | | 123 | | } |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | // Then each Google User, by one targeted write (#205), as each person: marked as a subject-requested |
| | | 127 | | // deletion and anonymised in the same statement. One anonymised since it was read is counted as such. |
| | 26 | 128 | | foreach (var googleUser in googleUsers) |
| | | 129 | | { |
| | 1 | 130 | | if (googleUser.AnonymisedAt is null && await IdentityAnonymiser.ReapplyAsync(atomicWrites, googleUser, now)) |
| | | 131 | | { |
| | 1 | 132 | | anonymised++; |
| | | 133 | | } |
| | | 134 | | else |
| | | 135 | | { |
| | 0 | 136 | | alreadyAnonymised++; |
| | | 137 | | } |
| | | 138 | | } |
| | | 139 | | |
| | 24 | 140 | | var found = persons.Select(person => person.PublicId) |
| | 1 | 141 | | .Concat(googleUsers.Select(googleUser => googleUser.PublicId)) |
| | 12 | 142 | | .ToHashSet(); |
| | | 143 | | |
| | 12 | 144 | | return output |
| | 12 | 145 | | .WithData(new ReapplyErasuresCommandOutput |
| | 12 | 146 | | { |
| | 12 | 147 | | Anonymised = anonymised, |
| | 12 | 148 | | AlreadyAnonymised = alreadyAnonymised, |
| | 15 | 149 | | NotFound = requested.Where(id => !found.Contains(id)).ToList(), |
| | 12 | 150 | | ScopeOwnerInvitationsVoided = voided.Count |
| | 12 | 151 | | }) |
| | 12 | 152 | | .WithMessage(ErasureMessages.ErasuresReapplied); |
| | 13 | 153 | | } |
| | | 154 | | } |