| | | 1 | | using ArturRios.Data.Relational.Core.Entities; |
| | | 2 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 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.Enums; |
| | | 8 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 9 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 10 | | using ArturRios.Heimdall.Shared.Retention; |
| | | 11 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 12 | | using ArturRios.Output; |
| | | 13 | | using Microsoft.EntityFrameworkCore; |
| | | 14 | | |
| | | 15 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 16 | | |
| | | 17 | | /// <summary> |
| | | 18 | | /// Handles <see cref="AnonymiseExpiredDeletionsCommand" /> (NFR-20): finds logically deleted |
| | | 19 | | /// persons and Google Users whose retention window has run out, removes the dependent rows that |
| | | 20 | | /// hold their data, and overwrites every identifying value on the record itself through |
| | | 21 | | /// <see cref="IdentityAnonymiser" />. All failures are returned as errors on the |
| | | 22 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <remarks> |
| | | 25 | | /// <para> |
| | | 26 | | /// <b>Which deadline applies.</b> The one the record's <c>DeletionKind</c> names. A subject |
| | | 27 | | /// who asked to be erased gets the statutory deadline; an administrative deletion gets the |
| | | 28 | | /// longer reversal window (Data Retention Schedule §4). A record deleted before this |
| | | 29 | | /// mechanism existed carries no kind at all, and is treated as administrative — the longer |
| | | 30 | | /// of the two, because guessing that somebody had requested erasure would be inventing a |
| | | 31 | | /// request that may never have been made. |
| | | 32 | | /// </para> |
| | | 33 | | /// <para> |
| | | 34 | | /// <b>Why the dependents go first.</b> Anonymising the person while leaving their password |
| | | 35 | | /// reset tokens and two-factor configuration in place would erase the name and keep the |
| | | 36 | | /// material: a TOTP secret and a recovery code hash belong to the person, and a reset token |
| | | 37 | | /// addresses the mailbox that was just disowned. The token purge would reach the tokens |
| | | 38 | | /// eventually, on its own much shorter window, but "eventually" is not what an erasure |
| | | 39 | | /// obligation accepts, and it would never reach the two-factor rows at all. |
| | | 40 | | /// </para> |
| | | 41 | | /// <para> |
| | | 42 | | /// <b>What is deliberately left alone.</b> The scope membership and ownership rows, the |
| | | 43 | | /// applications an owner held, and the audit entries naming them. All are foreign keys or |
| | | 44 | | /// accountability records that NFR-07 requires to keep resolving, and none carries an |
| | | 45 | | /// identifying value once the record they point at has been anonymised. The audit trail's |
| | | 46 | | /// own actor reference is #97's, not this pass's — a different mechanism for a table that |
| | | 47 | | /// refuses updates by design. |
| | | 48 | | /// </para> |
| | | 49 | | /// <para> |
| | | 50 | | /// <b>NFR-12 is untouched.</b> Anonymisation changes no <c>SCOPE_OWNER</c> row, so a scope |
| | | 51 | | /// keeps exactly the owners it had. The guarantee is not weakened here because it was |
| | | 52 | | /// already enforced at deletion time: UC-09 refuses to logically delete a scope's last |
| | | 53 | | /// owner, so no record reaching this pass was one. |
| | | 54 | | /// </para> |
| | | 55 | | /// </remarks> |
| | 25 | 56 | | public class AnonymiseExpiredDeletionsCommandHandler( |
| | 25 | 57 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 25 | 58 | | IAsyncRepository<Person, long> personWriter, |
| | 25 | 59 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 25 | 60 | | IAsyncRepository<GoogleUser, long> googleUserWriter, |
| | 25 | 61 | | IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter, |
| | 25 | 62 | | IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter, |
| | 25 | 63 | | IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter, |
| | 25 | 64 | | DataRetentionOptions retention, |
| | 25 | 65 | | IAtomicWrites atomicWrites) |
| | | 66 | | : ICommandHandlerAsync<AnonymiseExpiredDeletionsCommand, AnonymiseExpiredDeletionsCommandOutput> |
| | | 67 | | { |
| | | 68 | | /// <summary> |
| | | 69 | | /// <c>DeletionKinds.SubjectRequested</c> as a plain <c>int</c>. The comparison happens inside |
| | | 70 | | /// an expression EF Core translates to SQL, and a cast of an enum member there is one more |
| | | 71 | | /// thing for the provider to have an opinion about than it needs to be. |
| | | 72 | | /// </summary> |
| | | 73 | | private const int SubjectRequested = (int)DeletionKinds.SubjectRequested; |
| | | 74 | | |
| | | 75 | | /// <remarks> |
| | | 76 | | /// The two queries below are near-identical and are still written out twice. A generic |
| | | 77 | | /// helper over an interface both entities implement would read better and would not |
| | | 78 | | /// translate: EF Core maps the properties of the entity types, and an expression tree |
| | | 79 | | /// referencing an interface's members gives it member accesses it cannot resolve to columns. |
| | | 80 | | /// The failure mode is a silent client-side evaluation of the whole table, or an exception — |
| | | 81 | | /// neither worth the six lines saved. |
| | | 82 | | /// </remarks> |
| | | 83 | | public async Task<DataOutput<AnonymiseExpiredDeletionsCommandOutput?>> HandleAsync( |
| | | 84 | | AnonymiseExpiredDeletionsCommand command, CancellationToken cancellationToken = default) |
| | | 85 | | { |
| | 27 | 86 | | var output = DataOutput<AnonymiseExpiredDeletionsCommandOutput?>.New; |
| | 27 | 87 | | var now = DateTime.UtcNow; |
| | | 88 | | |
| | | 89 | | // Two cutoffs rather than one, because the deadline depends on why the record was deleted. |
| | | 90 | | // They are the fallback: where the subject made a request (UC-42), the deadline was computed |
| | | 91 | | // and stored when the request attached, and that stored value wins — recomputing it from the |
| | | 92 | | // current configuration would let a later change move an obligation already owed. The |
| | | 93 | | // cutoffs still apply to an administrative deletion, which has no stored deadline, and to a |
| | | 94 | | // subject-requested record that somehow lacks one, which would otherwise silently fall to |
| | | 95 | | // the longer window. |
| | 27 | 96 | | var erasureCutoff = now - retention.SubjectErasureDeadline; |
| | 27 | 97 | | var administrativeCutoff = now - retention.AdministrativeDeletionWindow; |
| | | 98 | | |
| | | 99 | | // A request NFR-12 blocked at the time it was made may have become possible since — an owner |
| | | 100 | | // transferred, a co-owner added (UC-21). Re-checked here rather than requiring an |
| | | 101 | | // administrator to resubmit anything: the subject asked once, and the deadline has been |
| | | 102 | | // running the whole time. |
| | 27 | 103 | | var unblockErrors = await RetryBlockedRequestsAsync(now); |
| | | 104 | | |
| | 27 | 105 | | if (unblockErrors.Count > 0) |
| | | 106 | | { |
| | 0 | 107 | | return output.WithErrors(unblockErrors); |
| | | 108 | | } |
| | | 109 | | |
| | 27 | 110 | | var persons = await personReader.Query() |
| | 27 | 111 | | // A restricted record is preserved exactly as it stands rather than entering the window |
| | 27 | 112 | | // (NFR-24): restriction and deletion are independent, so a record can be both — deleted |
| | 27 | 113 | | // by an administrator while under dispute, or restricted by a subject already pending |
| | 27 | 114 | | // erasure (UC-44). It is picked up again once the restriction is lifted (UC-45). |
| | 27 | 115 | | .Where(person => person.IsDeleted |
| | 27 | 116 | | && person.AnonymisedAt == null |
| | 27 | 117 | | && person.ProcessingRestrictedAt == null |
| | 27 | 118 | | && ((person.ErasureDueAt != null && person.ErasureDueAt <= now) |
| | 27 | 119 | | || (person.ErasureDueAt == null |
| | 27 | 120 | | && person.DeletedAt != null |
| | 27 | 121 | | && (person.DeletionKind == SubjectRequested |
| | 27 | 122 | | ? person.DeletedAt <= erasureCutoff |
| | 27 | 123 | | : person.DeletedAt <= administrativeCutoff)))) |
| | 27 | 124 | | .OrderBy(person => person.DeletedAt) |
| | 27 | 125 | | .Take(retention.PurgeBatchSize) |
| | 27 | 126 | | .ToListAsync(); |
| | | 127 | | |
| | 27 | 128 | | var googleUsers = await googleUserReader.Query() |
| | 27 | 129 | | .Where(googleUser => googleUser.IsDeleted |
| | 27 | 130 | | && googleUser.AnonymisedAt == null |
| | 27 | 131 | | && googleUser.ProcessingRestrictedAt == null |
| | 27 | 132 | | && ((googleUser.ErasureDueAt != null && googleUser.ErasureDueAt <= now) |
| | 27 | 133 | | || (googleUser.ErasureDueAt == null |
| | 27 | 134 | | && googleUser.DeletedAt != null |
| | 27 | 135 | | && (googleUser.DeletionKind == SubjectRequested |
| | 27 | 136 | | ? googleUser.DeletedAt <= erasureCutoff |
| | 27 | 137 | | : googleUser.DeletedAt <= administrativeCutoff)))) |
| | 27 | 138 | | .OrderBy(googleUser => googleUser.DeletedAt) |
| | 27 | 139 | | .Take(retention.PurgeBatchSize) |
| | 27 | 140 | | .ToListAsync(); |
| | | 141 | | |
| | 27 | 142 | | if (persons.Count == 0 && googleUsers.Count == 0) |
| | | 143 | | { |
| | 11 | 144 | | return Success(output, 0, 0, 0); |
| | | 145 | | } |
| | | 146 | | |
| | 16 | 147 | | var errors = new List<string>(); |
| | 16 | 148 | | var dependentsRemoved = 0; |
| | | 149 | | |
| | 16 | 150 | | if (persons.Count > 0) |
| | | 151 | | { |
| | 15 | 152 | | var (removed, dependentErrors) = await RemovePersonDependentsAsync(persons); |
| | | 153 | | |
| | 15 | 154 | | dependentsRemoved += removed; |
| | 15 | 155 | | errors.AddRange(dependentErrors); |
| | | 156 | | } |
| | | 157 | | |
| | | 158 | | // A failed dependent removal stops the run before the record is overwritten. Anonymising |
| | | 159 | | // anyway would leave the person's two-factor secret behind with nothing left to connect it |
| | | 160 | | // to a name — material kept past its purpose, and unreachable by the next run, which selects |
| | | 161 | | // on records not yet anonymised. |
| | 16 | 162 | | if (errors.Count > 0) |
| | | 163 | | { |
| | 0 | 164 | | return output.WithErrors(errors); |
| | | 165 | | } |
| | | 166 | | |
| | 70 | 167 | | foreach (var person in persons) |
| | | 168 | | { |
| | 19 | 169 | | IdentityAnonymiser.Anonymise(person, now); |
| | | 170 | | |
| | | 171 | | // The factor budgets are behavioural data too, and a whole-row save never writes them |
| | | 172 | | // (FR-AU-12, FR-AU-13): they are cleared by their own targeted writes. |
| | 19 | 173 | | await atomicWrites.ClearSecondFactorAttemptsAsync(person); |
| | 19 | 174 | | await atomicWrites.ClearReauthenticationAttemptsAsync(person); |
| | 19 | 175 | | await atomicWrites.ClearAccountEmailsAsync(person); |
| | | 176 | | |
| | | 177 | | // The password hash and salt likewise: a whole-row save never writes them once the row |
| | | 178 | | // exists, so the anonymiser's in-memory clear reaches the row only through this. |
| | 19 | 179 | | await atomicWrites.ClearPasswordAsync(person); |
| | | 180 | | |
| | | 181 | | // Nothing blocks it any more; it is done. |
| | 19 | 182 | | person.ErasureBlockedReason = null; |
| | 19 | 183 | | } |
| | | 184 | | |
| | 38 | 185 | | foreach (var googleUser in googleUsers) |
| | | 186 | | { |
| | 3 | 187 | | IdentityAnonymiser.Anonymise(googleUser, now); |
| | | 188 | | } |
| | | 189 | | |
| | 16 | 190 | | errors.AddRange(await SaveAsync(persons, personWriter)); |
| | 16 | 191 | | errors.AddRange(await SaveAsync(googleUsers, googleUserWriter)); |
| | | 192 | | |
| | 16 | 193 | | return errors.Count > 0 |
| | 16 | 194 | | ? output.WithErrors(errors) |
| | 16 | 195 | | : Success( |
| | 16 | 196 | | output, persons.Count, googleUsers.Count, dependentsRemoved, |
| | 19 | 197 | | persons.Select(person => person.PublicId) |
| | 3 | 198 | | .Concat(googleUsers.Select(googleUser => googleUser.PublicId)) |
| | 16 | 199 | | .ToList()); |
| | 27 | 200 | | } |
| | | 201 | | |
| | | 202 | | /// <summary> |
| | | 203 | | /// Suspends the persons whose erasure request was blocked when it was made and is no longer |
| | | 204 | | /// blocked, so the anonymisation can proceed on the next selection. |
| | | 205 | | /// </summary> |
| | | 206 | | /// <remarks> |
| | | 207 | | /// The suspension is dated from the <em>request</em>, not from the moment the block cleared. |
| | | 208 | | /// Dating it from now would restart the retention window and hand back the whole deadline |
| | | 209 | | /// for a request that has already been outstanding — a record blocked for two months would |
| | | 210 | | /// get another month, which is exactly the breach the deadline exists to prevent. Dating it |
| | | 211 | | /// from the request means a long-blocked record is due immediately, which is correct: it is |
| | | 212 | | /// already late. |
| | | 213 | | /// </remarks> |
| | | 214 | | private async Task<List<string>> RetryBlockedRequestsAsync(DateTime now) |
| | | 215 | | { |
| | 27 | 216 | | var blocked = await personReader.Query() |
| | 27 | 217 | | .Include(person => person.ScopeOwnerships) |
| | 27 | 218 | | .Where(person => !person.IsDeleted |
| | 27 | 219 | | && person.ErasureRequestedAt != null |
| | 27 | 220 | | && person.ErasureBlockedReason != null |
| | 27 | 221 | | // NFR-24, as above: a restricted record is not moved toward erasure. |
| | 27 | 222 | | && person.ProcessingRestrictedAt == null) |
| | 27 | 223 | | .ToListAsync(); |
| | | 224 | | |
| | 27 | 225 | | if (blocked.Count == 0) |
| | | 226 | | { |
| | 24 | 227 | | return []; |
| | | 228 | | } |
| | | 229 | | |
| | 3 | 230 | | var cleared = new List<Person>(); |
| | | 231 | | |
| | 14 | 232 | | foreach (var person in blocked) |
| | | 233 | | { |
| | 4 | 234 | | if (await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person, personReader)) |
| | | 235 | | { |
| | | 236 | | continue; |
| | | 237 | | } |
| | | 238 | | |
| | 1 | 239 | | person.IsDeleted = true; |
| | 1 | 240 | | person.DeletedAt = person.ErasureRequestedAt; |
| | 1 | 241 | | person.DeletionKind = (int)DeletionKinds.SubjectRequested; |
| | 1 | 242 | | person.ErasureBlockedReason = null; |
| | 1 | 243 | | person.UpdatedAt = now; |
| | | 244 | | |
| | 1 | 245 | | cleared.Add(person); |
| | 1 | 246 | | } |
| | | 247 | | |
| | 3 | 248 | | return cleared.Count == 0 ? [] : (await SaveAsync(cleared, personWriter)).ToList(); |
| | 27 | 249 | | } |
| | | 250 | | |
| | | 251 | | /// <summary> |
| | | 252 | | /// Permanently removes the rows that hold data belonging to the persons being anonymised — |
| | | 253 | | /// see <see cref="PersonDependentsRemoval" />, which the re-application of erasures shares. |
| | | 254 | | /// </summary> |
| | | 255 | | private async Task<(int Removed, IEnumerable<string> Errors)> RemovePersonDependentsAsync( |
| | | 256 | | IReadOnlyCollection<Person> persons) => |
| | 15 | 257 | | await PersonDependentsRemoval.RemoveAsync( |
| | 19 | 258 | | persons.Select(person => person.Id).ToList(), |
| | 15 | 259 | | passwordResetTokenWriter, |
| | 15 | 260 | | emailVerificationTokenWriter, |
| | 15 | 261 | | twoFactorAuthWriter); |
| | | 262 | | |
| | | 263 | | private static async Task<IEnumerable<string>> SaveAsync<T>( |
| | | 264 | | IReadOnlyCollection<T> records, IAsyncRepository<T, long> writer) where T : Entity<long> |
| | | 265 | | { |
| | 33 | 266 | | if (records.Count == 0) |
| | | 267 | | { |
| | 15 | 268 | | return []; |
| | | 269 | | } |
| | | 270 | | |
| | 18 | 271 | | var update = await writer.UpdateRangeAsync(records); |
| | | 272 | | |
| | 18 | 273 | | return update.Success ? [] : update.Errors; |
| | 33 | 274 | | } |
| | | 275 | | |
| | | 276 | | private static DataOutput<AnonymiseExpiredDeletionsCommandOutput?> Success( |
| | | 277 | | DataOutput<AnonymiseExpiredDeletionsCommandOutput?> output, |
| | | 278 | | int persons, |
| | | 279 | | int googleUsers, |
| | | 280 | | int dependents, |
| | | 281 | | IEnumerable<Guid>? anonymisedIds = null) => |
| | 27 | 282 | | output |
| | 27 | 283 | | .WithData(new AnonymiseExpiredDeletionsCommandOutput |
| | 27 | 284 | | { |
| | 27 | 285 | | PersonsAnonymised = persons, |
| | 27 | 286 | | GoogleUsersAnonymised = googleUsers, |
| | 27 | 287 | | DependentsRemoved = dependents, |
| | 27 | 288 | | AnonymisedIds = anonymisedIds ?? [] |
| | 27 | 289 | | }) |
| | 27 | 290 | | .WithMessage(RetentionMessages.ExpiredDeletionsAnonymised); |
| | | 291 | | } |