| | | 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.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 | | using Microsoft.Extensions.Logging; |
| | | 15 | | |
| | | 16 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 17 | | |
| | | 18 | | /// <summary> |
| | | 19 | | /// Handles <see cref="AnonymiseExpiredDeletionsCommand" /> (NFR-20): finds logically deleted |
| | | 20 | | /// persons and Google Users whose retention window has run out, removes the dependent rows that |
| | | 21 | | /// hold their data, and overwrites every identifying value on the record itself through |
| | | 22 | | /// <see cref="IdentityAnonymiser" />. All failures are returned as errors on the |
| | | 23 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 24 | | /// </summary> |
| | | 25 | | /// <remarks> |
| | | 26 | | /// <para> |
| | | 27 | | /// <b>Which deadline applies.</b> The one the record's <c>DeletionKind</c> names. A subject |
| | | 28 | | /// who asked to be erased gets the statutory deadline; an administrative deletion gets the |
| | | 29 | | /// longer reversal window (Data Retention Schedule §4). A record deleted before this |
| | | 30 | | /// mechanism existed carries no kind at all, and is treated as administrative — the longer |
| | | 31 | | /// of the two, because guessing that somebody had requested erasure would be inventing a |
| | | 32 | | /// request that may never have been made. |
| | | 33 | | /// </para> |
| | | 34 | | /// <para> |
| | | 35 | | /// <b>Why the dependents go first.</b> Anonymising the person while leaving their password |
| | | 36 | | /// reset tokens and two-factor configuration in place would erase the name and keep the |
| | | 37 | | /// material: a TOTP secret and a recovery code hash belong to the person, and a reset token |
| | | 38 | | /// addresses the mailbox that was just disowned. The token purge would reach the tokens |
| | | 39 | | /// eventually, on its own much shorter window, but "eventually" is not what an erasure |
| | | 40 | | /// obligation accepts, and it would never reach the two-factor rows at all. |
| | | 41 | | /// </para> |
| | | 42 | | /// <para> |
| | | 43 | | /// <b>Invitations.</b> A person anonymised has their unended scope owner invitations voided and |
| | | 44 | | /// is cleared as the inviter of the rest (FR-SC-17); each void is audited, on its invitation, as |
| | | 45 | | /// soon as it is made (<see cref="ErasedPersonInvitations" />). The voids follow the anonymisation |
| | | 46 | | /// rather than preceding it with the dependents, because only the targeted write knows whether the |
| | | 47 | | /// person is erased at all. A run that anonymised a person and then failed before forgetting them |
| | | 48 | | /// is caught up by the next one, which first forgets every anonymised person an invitation still |
| | | 49 | | /// names. |
| | | 50 | | /// </para> |
| | | 51 | | /// <para> |
| | | 52 | | /// <b>What is deliberately left alone.</b> The scope membership and ownership rows, the |
| | | 53 | | /// applications an owner held, and the audit entries naming them. All are foreign keys or |
| | | 54 | | /// accountability records that NFR-07 requires to keep resolving, and none carries an |
| | | 55 | | /// identifying value once the record they point at has been anonymised. The audit trail's |
| | | 56 | | /// own actor reference is #97's, not this pass's — a different mechanism for a table that |
| | | 57 | | /// refuses updates by design. |
| | | 58 | | /// </para> |
| | | 59 | | /// <para> |
| | | 60 | | /// <b>NFR-12 is untouched.</b> Anonymisation changes no <c>SCOPE_OWNER</c> row, so a scope |
| | | 61 | | /// keeps exactly the owners it had. The guarantee is not weakened here because it was |
| | | 62 | | /// already enforced at deletion time: UC-09 refuses to logically delete a scope's last |
| | | 63 | | /// owner, so no record reaching this pass was one. |
| | | 64 | | /// </para> |
| | | 65 | | /// </remarks> |
| | 55 | 66 | | public class AnonymiseExpiredDeletionsCommandHandler( |
| | 55 | 67 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 55 | 68 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 55 | 69 | | IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter, |
| | 55 | 70 | | IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter, |
| | 55 | 71 | | IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter, |
| | 55 | 72 | | DataRetentionOptions retention, |
| | 55 | 73 | | IAtomicWrites atomicWrites, |
| | 55 | 74 | | IScopeOwnerInvitations invitations, |
| | 55 | 75 | | IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> invitationReader, |
| | 55 | 76 | | IAuditLogWriter auditLog, |
| | 55 | 77 | | ILogger<AnonymiseExpiredDeletionsCommandHandler> logger, |
| | 55 | 78 | | IWriteTransactions transactions, |
| | 55 | 79 | | IInvariantLocks locks) |
| | | 80 | | : ICommandHandlerAsync<AnonymiseExpiredDeletionsCommand, AnonymiseExpiredDeletionsCommandOutput> |
| | | 81 | | { |
| | | 82 | | /// <summary> |
| | | 83 | | /// <c>DeletionKinds.SubjectRequested</c> as a plain <c>int</c>. The comparison happens inside |
| | | 84 | | /// an expression EF Core translates to SQL, and a cast of an enum member there is one more |
| | | 85 | | /// thing for the provider to have an opinion about than it needs to be. |
| | | 86 | | /// </summary> |
| | | 87 | | private const int SubjectRequested = (int)DeletionKinds.SubjectRequested; |
| | | 88 | | |
| | | 89 | | public async Task<DataOutput<AnonymiseExpiredDeletionsCommandOutput?>> HandleAsync( |
| | | 90 | | AnonymiseExpiredDeletionsCommand command, CancellationToken cancellationToken = default) |
| | | 91 | | { |
| | 58 | 92 | | var output = DataOutput<AnonymiseExpiredDeletionsCommandOutput?>.New; |
| | 58 | 93 | | var now = DateTime.UtcNow; |
| | | 94 | | |
| | | 95 | | // Two cutoffs rather than one, because the deadline depends on why the record was deleted. |
| | | 96 | | // They are the fallback: where the subject made a request (UC-42), the deadline was computed |
| | | 97 | | // and stored when the request attached, and that stored value wins — recomputing it from the |
| | | 98 | | // current configuration would let a later change move an obligation already owed. The |
| | | 99 | | // cutoffs still apply to an administrative deletion, which has no stored deadline, and to a |
| | | 100 | | // subject-requested record that somehow lacks one, which would otherwise silently fall to |
| | | 101 | | // the longer window. |
| | 58 | 102 | | var erasureCutoff = now - retention.SubjectErasureDeadline; |
| | 58 | 103 | | var administrativeCutoff = now - retention.AdministrativeDeletionWindow; |
| | | 104 | | |
| | | 105 | | // A request NFR-12 blocked at the time it was made may have become possible since — an owner |
| | | 106 | | // transferred, a co-owner added (UC-21). Re-checked here rather than requiring an |
| | | 107 | | // administrator to resubmit anything: the subject asked once, and the deadline has been |
| | | 108 | | // running the whole time. |
| | | 109 | | // |
| | | 110 | | // Best-effort (#210): a request the retry cannot save is reported with the run's errors and |
| | | 111 | | // does not stop the due identities below, whose statutory deadlines are running regardless. |
| | 58 | 112 | | var retryErrors = await RetryBlockedRequestsAsync(now); |
| | | 113 | | |
| | | 114 | | // Before selecting anyone new: a person anonymised by an earlier run that failed before their |
| | | 115 | | // invitations were forgotten is never selected again, and would otherwise stay named for good. |
| | 58 | 116 | | var caughtUp = await ForgetAnonymisedStillNamedAsync(now); |
| | | 117 | | |
| | 58 | 118 | | var result = new AnonymiseExpiredDeletionsCommandOutput { ScopeOwnerInvitationsVoided = caughtUp.Count }; |
| | 58 | 119 | | var anonymisedIds = new List<Guid>(); |
| | 58 | 120 | | var errors = new List<string>(); |
| | | 121 | | |
| | | 122 | | // A batch at a time, repeated while a batch comes back full (#206): one batch a run left a large |
| | | 123 | | // scope's deleted members waiting days past their deadline. |
| | 58 | 124 | | var personsFull = false; |
| | 58 | 125 | | var googleUsersFull = false; |
| | 58 | 126 | | var interrupted = false; |
| | | 127 | | |
| | | 128 | | // Thrown part-way, the run still reports what it did: the identities anonymised before the |
| | | 129 | | // failure are erased, and the ledger has to name them (NFR-25), from every batch of the run. |
| | | 130 | | try |
| | | 131 | | { |
| | 146 | 132 | | for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++) |
| | | 133 | | { |
| | 67 | 134 | | var persons = await DuePersons(now, erasureCutoff, administrativeCutoff) |
| | 67 | 135 | | .OrderBy(person => person.DeletedAt) |
| | 67 | 136 | | .Take(retention.PurgeBatchSize) |
| | 67 | 137 | | .ToListAsync(); |
| | | 138 | | |
| | 67 | 139 | | if (persons.Count == 0) |
| | | 140 | | { |
| | 23 | 141 | | personsFull = false; |
| | 23 | 142 | | break; |
| | | 143 | | } |
| | | 144 | | |
| | 44 | 145 | | var (removed, dependentErrors) = await RemovePersonDependentsAsync(persons); |
| | | 146 | | |
| | 44 | 147 | | result.DependentsRemoved += removed; |
| | | 148 | | |
| | | 149 | | // A failed dependent removal stops the run before the record is overwritten. Anonymising |
| | | 150 | | // anyway would leave the person's two-factor secret behind with nothing left to connect it |
| | | 151 | | // to a name — material kept past its purpose, and unreachable by the next run, which selects |
| | | 152 | | // on records not yet anonymised. |
| | 44 | 153 | | errors.AddRange(dependentErrors); |
| | | 154 | | |
| | 44 | 155 | | if (errors.Count > 0) |
| | | 156 | | { |
| | 0 | 157 | | personsFull = false; |
| | 0 | 158 | | break; |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | // One targeted write per person (#237): the identifying values, the password, the factor and |
| | | 162 | | // account-email budgets together, and only while the record is still deleted, unanonymised |
| | | 163 | | // and unrestricted. A record restricted since it was selected is preserved as it stands |
| | | 164 | | // (NFR-24), and is not counted. |
| | 44 | 165 | | var anonymisedPersons = new List<Person>(); |
| | | 166 | | |
| | 219 | 167 | | foreach (var person in persons) |
| | | 168 | | { |
| | 66 | 169 | | if (await IdentityAnonymiser.AnonymiseAsync(atomicWrites, person, now)) |
| | | 170 | | { |
| | | 171 | | // Counted and named as soon as it is written, so a failure on the next one cannot |
| | | 172 | | // drop it from the ledger. |
| | 62 | 173 | | anonymisedPersons.Add(person); |
| | 62 | 174 | | result.PersonsAnonymised++; |
| | 62 | 175 | | anonymisedIds.Add(person.PublicId); |
| | | 176 | | } |
| | 65 | 177 | | } |
| | | 178 | | |
| | | 179 | | // An erased person cannot answer an invitation, and keeping one open would keep naming them |
| | | 180 | | // (FR-SC-17): their unended invitations are voided, and they are cleared as the inviter of any |
| | | 181 | | // other. Only for the persons actually anonymised — one restricted since the selection is not |
| | | 182 | | // erased (NFR-24), so what names them stays as it stands. |
| | 43 | 183 | | var voided = await ErasedPersonInvitations.ForgetAsync( |
| | 104 | 184 | | invitations, auditLog, logger, anonymisedPersons.Select(person => person.Id).ToList(), now); |
| | | 185 | | |
| | 43 | 186 | | result.ScopeOwnerInvitationsVoided += voided.Count; |
| | | 187 | | |
| | 43 | 188 | | personsFull = persons.Count == retention.PurgeBatchSize; |
| | | 189 | | |
| | | 190 | | // A full batch in which nobody could be anonymised made no progress — every one of them |
| | | 191 | | // changed since it was selected — so it is not repeated: the next run starts afresh. |
| | 43 | 192 | | if (!personsFull || anonymisedPersons.Count == 0) |
| | | 193 | | { |
| | | 194 | | break; |
| | | 195 | | } |
| | 15 | 196 | | } |
| | | 197 | | |
| | | 198 | | // After a failed dependent removal nothing else is attempted this run, as before the drain. |
| | 124 | 199 | | for (var batch = 0; errors.Count == 0 && batch < retention.MaxBatchesPerTick; batch++) |
| | | 200 | | { |
| | 61 | 201 | | var googleUsers = await DueGoogleUsers(now, erasureCutoff, administrativeCutoff) |
| | 61 | 202 | | .OrderBy(googleUser => googleUser.DeletedAt) |
| | 61 | 203 | | .Take(retention.PurgeBatchSize) |
| | 61 | 204 | | .ToListAsync(); |
| | | 205 | | |
| | 61 | 206 | | if (googleUsers.Count == 0) |
| | | 207 | | { |
| | 49 | 208 | | googleUsersFull = false; |
| | 49 | 209 | | break; |
| | | 210 | | } |
| | | 211 | | |
| | | 212 | | // One targeted write per Google User (#205), as for a person: only while the record is still |
| | | 213 | | // deleted, unanonymised and unrestricted. One restricted since it was selected is preserved |
| | | 214 | | // as it stands (NFR-24), and is not counted. |
| | 12 | 215 | | var anonymisedGoogleUsers = 0; |
| | | 216 | | |
| | 57 | 217 | | foreach (var googleUser in googleUsers) |
| | | 218 | | { |
| | 18 | 219 | | if (await IdentityAnonymiser.AnonymiseAsync(atomicWrites, googleUser, now)) |
| | | 220 | | { |
| | | 221 | | // Counted and named as soon as it is written, so a failure on the next one cannot |
| | | 222 | | // drop it from the ledger. |
| | 14 | 223 | | anonymisedGoogleUsers++; |
| | 14 | 224 | | result.GoogleUsersAnonymised++; |
| | 14 | 225 | | anonymisedIds.Add(googleUser.PublicId); |
| | | 226 | | } |
| | 15 | 227 | | } |
| | | 228 | | |
| | 9 | 229 | | googleUsersFull = googleUsers.Count == retention.PurgeBatchSize; |
| | | 230 | | |
| | | 231 | | // As for persons: a full batch in which nobody could be anonymised made no progress. |
| | 9 | 232 | | if (!googleUsersFull || anonymisedGoogleUsers == 0) |
| | | 233 | | { |
| | | 234 | | break; |
| | | 235 | | } |
| | 5 | 236 | | } |
| | 54 | 237 | | } |
| | 4 | 238 | | catch (Exception exception) |
| | | 239 | | { |
| | 4 | 240 | | interrupted = true; |
| | 4 | 241 | | logger.LogError( |
| | 4 | 242 | | exception, "Anonymising the identities due failed part-way; those already anonymised are named"); |
| | 4 | 243 | | errors.Add(RetentionMessages.AnonymisationInterrupted); |
| | 4 | 244 | | } |
| | | 245 | | |
| | | 246 | | // Outside the catch above: the erasures are done by now, so a count that fails leaves the run as it was |
| | | 247 | | // and only the backlog unknown. After an interrupted run nothing says where the batches stopped. |
| | 58 | 248 | | result.Backlog = interrupted |
| | 58 | 249 | | ? null |
| | 58 | 250 | | : await CountBacklogAsync(personsFull, googleUsersFull, now, erasureCutoff, administrativeCutoff); |
| | | 251 | | |
| | | 252 | | // The errors ride along with the data rather than replacing it: the identities anonymised before |
| | | 253 | | // the failure, in this batch or an earlier one, are erased, and the caller has to be able to name |
| | | 254 | | // them (the erasure ledger, NFR-25) or a restore could not re-apply their erasures. |
| | 58 | 255 | | result.AnonymisedIds = anonymisedIds; |
| | | 256 | | |
| | 58 | 257 | | var success = output |
| | 58 | 258 | | .WithData(result) |
| | 58 | 259 | | .WithMessage(RetentionMessages.ExpiredDeletionsAnonymised); |
| | | 260 | | |
| | 58 | 261 | | return errors.Count > 0 |
| | 58 | 262 | | ? success.WithErrors(errors.Concat(retryErrors).ToList()) |
| | 58 | 263 | | : WithRetryErrors(success, retryErrors); |
| | 58 | 264 | | } |
| | | 265 | | |
| | | 266 | | /// <summary> |
| | | 267 | | /// The identities still due once the run stopped, counted only where it stopped on a full batch — one |
| | | 268 | | /// that was not full took all that was due — or <c>null</c> when the count itself failed. |
| | | 269 | | /// </summary> |
| | | 270 | | private async Task<int?> CountBacklogAsync( |
| | | 271 | | bool personsFull, bool googleUsersFull, DateTime now, DateTime erasureCutoff, DateTime administrativeCutoff) |
| | | 272 | | { |
| | | 273 | | try |
| | | 274 | | { |
| | 54 | 275 | | return (personsFull ? await DuePersons(now, erasureCutoff, administrativeCutoff).CountAsync() : 0) |
| | 54 | 276 | | + (googleUsersFull |
| | 54 | 277 | | ? await DueGoogleUsers(now, erasureCutoff, administrativeCutoff).CountAsync() |
| | 54 | 278 | | : 0); |
| | | 279 | | } |
| | 1 | 280 | | catch (Exception exception) |
| | | 281 | | { |
| | 1 | 282 | | logger.LogWarning(exception, "Counting the identities still due failed; this run's backlog is unknown"); |
| | | 283 | | |
| | 1 | 284 | | return null; |
| | | 285 | | } |
| | 54 | 286 | | } |
| | | 287 | | |
| | | 288 | | /// <summary> |
| | | 289 | | /// The logically deleted persons whose retention window has run out and who are neither |
| | | 290 | | /// anonymised nor restricted. |
| | | 291 | | /// </summary> |
| | | 292 | | /// <remarks> |
| | | 293 | | /// This and <see cref="DueGoogleUsers" /> are near-identical and are still written out twice. A |
| | | 294 | | /// generic helper over an interface both entities implement would read better and would not |
| | | 295 | | /// translate: EF Core maps the properties of the entity types, and an expression tree |
| | | 296 | | /// referencing an interface's members gives it member accesses it cannot resolve to columns. |
| | | 297 | | /// The failure mode is a silent client-side evaluation of the whole table, or an exception — |
| | | 298 | | /// neither worth the six lines saved. |
| | | 299 | | /// </remarks> |
| | | 300 | | private IQueryable<Person> DuePersons(DateTime now, DateTime erasureCutoff, DateTime administrativeCutoff) => |
| | 74 | 301 | | personReader.Query() |
| | 74 | 302 | | // A restricted record is preserved exactly as it stands rather than entering the window |
| | 74 | 303 | | // (NFR-24): restriction and deletion are independent, so a record can be both — deleted |
| | 74 | 304 | | // by an administrator while under dispute, or restricted by a subject already pending |
| | 74 | 305 | | // erasure (UC-44). It is picked up again once the restriction is lifted (UC-45). |
| | 74 | 306 | | .Where(person => person.IsDeleted |
| | 74 | 307 | | && person.AnonymisedAt == null |
| | 74 | 308 | | && person.ProcessingRestrictedAt == null |
| | 74 | 309 | | && ((person.ErasureDueAt != null && person.ErasureDueAt <= now) |
| | 74 | 310 | | || (person.ErasureDueAt == null |
| | 74 | 311 | | && person.DeletedAt != null |
| | 74 | 312 | | && (person.DeletionKind == SubjectRequested |
| | 74 | 313 | | ? person.DeletedAt <= erasureCutoff |
| | 74 | 314 | | : person.DeletedAt <= administrativeCutoff)))); |
| | | 315 | | |
| | | 316 | | /// <summary>The Google User counterpart of <see cref="DuePersons" />.</summary> |
| | | 317 | | private IQueryable<GoogleUser> DueGoogleUsers( |
| | | 318 | | DateTime now, DateTime erasureCutoff, DateTime administrativeCutoff) => |
| | 62 | 319 | | googleUserReader.Query() |
| | 62 | 320 | | .Where(googleUser => googleUser.IsDeleted |
| | 62 | 321 | | && googleUser.AnonymisedAt == null |
| | 62 | 322 | | && googleUser.ProcessingRestrictedAt == null |
| | 62 | 323 | | && ((googleUser.ErasureDueAt != null && googleUser.ErasureDueAt <= now) |
| | 62 | 324 | | || (googleUser.ErasureDueAt == null |
| | 62 | 325 | | && googleUser.DeletedAt != null |
| | 62 | 326 | | && (googleUser.DeletionKind == SubjectRequested |
| | 62 | 327 | | ? googleUser.DeletedAt <= erasureCutoff |
| | 62 | 328 | | : googleUser.DeletedAt <= administrativeCutoff)))); |
| | | 329 | | |
| | | 330 | | private static DataOutput<AnonymiseExpiredDeletionsCommandOutput?> WithRetryErrors( |
| | | 331 | | DataOutput<AnonymiseExpiredDeletionsCommandOutput?> output, IReadOnlyCollection<string> retryErrors) => |
| | 54 | 332 | | retryErrors.Count == 0 ? output : output.WithErrors(retryErrors.ToList()); |
| | | 333 | | |
| | | 334 | | /// <summary> |
| | | 335 | | /// Suspends the persons whose erasure request was blocked when it was made and is no longer |
| | | 336 | | /// blocked, so the anonymisation can proceed on the next selection. |
| | | 337 | | /// </summary> |
| | | 338 | | /// <remarks> |
| | | 339 | | /// The suspension is dated from the <em>request</em>, not from the moment the block cleared. |
| | | 340 | | /// Dating it from now would restart the retention window and hand back the whole deadline |
| | | 341 | | /// for a request that has already been outstanding — a record blocked for two months would |
| | | 342 | | /// get another month, which is exactly the breach the deadline exists to prevent. Dating it |
| | | 343 | | /// from the request means a long-blocked record is due immediately, which is correct: it is |
| | | 344 | | /// already late. |
| | | 345 | | /// <para> |
| | | 346 | | /// <b>Best-effort and bounded (#210).</b> A failure on one request is logged and counted, and |
| | | 347 | | /// the next is still tried; the caller carries the errors on without stopping. Requests are |
| | | 348 | | /// loaded <see cref="DataRetentionOptions.PurgeBatchSize" /> at a time, oldest first, so a |
| | | 349 | | /// backlog cannot turn one run into one unbounded read. |
| | | 350 | | /// </para> |
| | | 351 | | /// <para> |
| | | 352 | | /// <b>Past the ones still blocked (#206).</b> A request still blocked, or one whose retry fails, |
| | | 353 | | /// stays in the selection, so taking the oldest batch again would take the same requests again: |
| | | 354 | | /// a batch of them would keep every newer request from ever being retried. Each further batch |
| | | 355 | | /// therefore starts after the last request of the one before, by request date and then id, and |
| | | 356 | | /// batches are taken while they come back full, up to |
| | | 357 | | /// <see cref="DataRetentionOptions.MaxBatchesPerTick" />. No request is tried twice in a run. |
| | | 358 | | /// </para> |
| | | 359 | | /// <para> |
| | | 360 | | /// <b>Both blocks (#203).</b> A request blocked because its subject was a scope's last owner |
| | | 361 | | /// (NFR-12) or the last active System Admin (NFR-32) is released once neither holds — whichever |
| | | 362 | | /// reason was recorded, both are asked again. |
| | | 363 | | /// </para> |
| | | 364 | | /// <para> |
| | | 365 | | /// <b>Each under the locks of the person's scopes (#202).</b> The guards and the suspension run in |
| | | 366 | | /// a transaction of their own per request, holding the rows of the scopes the person owns and the |
| | | 367 | | /// System Admin set (<see cref="IInvariantLocks" />, <see cref="LockedPerson" />), as every writer |
| | | 368 | | /// that can take an owner or a System Admin out does, so one removed or deleted at the same moment |
| | | 369 | | /// is seen. |
| | | 370 | | /// A request still blocked commits that transaction too: nothing was written, and committing |
| | | 371 | | /// keeps the run's other tracked requests tracked, which a rollback would clear. |
| | | 372 | | /// </para> |
| | | 373 | | /// </remarks> |
| | | 374 | | /// <returns>The errors to report: empty when every attempted retry went through.</returns> |
| | | 375 | | private async Task<IReadOnlyCollection<string>> RetryBlockedRequestsAsync(DateTime now) |
| | | 376 | | { |
| | 58 | 377 | | var failures = 0; |
| | | 378 | | |
| | | 379 | | try |
| | | 380 | | { |
| | 58 | 381 | | DateTime? afterRequestedAt = null; |
| | 58 | 382 | | var afterId = 0L; |
| | | 383 | | |
| | 122 | 384 | | for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++) |
| | | 385 | | { |
| | 60 | 386 | | var candidates = personReader.Query() |
| | 60 | 387 | | .Where(person => !person.IsDeleted |
| | 60 | 388 | | && person.ErasureRequestedAt != null |
| | 60 | 389 | | && person.ErasureBlockedReason != null |
| | 60 | 390 | | // NFR-24, as above: a restricted record is not moved toward erasure. |
| | 60 | 391 | | && person.ProcessingRestrictedAt == null); |
| | | 392 | | |
| | 59 | 393 | | if (afterRequestedAt is { } at) |
| | | 394 | | { |
| | 2 | 395 | | candidates = candidates.Where(person => |
| | 2 | 396 | | person.ErasureRequestedAt > at || (person.ErasureRequestedAt == at && person.Id > afterId)); |
| | | 397 | | } |
| | | 398 | | |
| | 59 | 399 | | var blocked = await candidates |
| | 59 | 400 | | .OrderBy(person => person.ErasureRequestedAt) |
| | 59 | 401 | | .ThenBy(person => person.Id) |
| | 59 | 402 | | .Take(retention.PurgeBatchSize) |
| | 59 | 403 | | .ToListAsync(); |
| | | 404 | | |
| | 160 | 405 | | foreach (var person in blocked) |
| | | 406 | | { |
| | | 407 | | try |
| | | 408 | | { |
| | 21 | 409 | | await using var transaction = await transactions.BeginAsync(); |
| | | 410 | | |
| | | 411 | | // The person as they stand under the locks (#202, #203): the guards decide on this. |
| | 21 | 412 | | var locked = await LockedPerson.LockAsync(locks, personReader, person.Id); |
| | | 413 | | |
| | | 414 | | // Released once neither NFR-12 nor NFR-32 blocks it, whichever blocked it first (#203). |
| | | 415 | | // A targeted write (#237), made only while the request is still blocked and the record |
| | | 416 | | // live and unrestricted, so a restriction or deletion that landed since stands. |
| | 21 | 417 | | var stillBlocked = locked is null || |
| | 21 | 418 | | await LastScopeOwnerGuard.WouldStripLastOwnerAsync( |
| | 21 | 419 | | person.Id, locked.OwnedScopeIds, personReader) || |
| | 21 | 420 | | (locked.IsLiveSystemAdmin && |
| | 21 | 421 | | await LastSystemAdminGuard.WouldLeaveNoneAsync(person.Id, personReader)); |
| | | 422 | | |
| | 21 | 423 | | if (!stillBlocked) |
| | | 424 | | { |
| | 11 | 425 | | await atomicWrites.TrySuspendBlockedErasureAsync(person, now); |
| | | 426 | | } |
| | | 427 | | |
| | 17 | 428 | | await transaction.CommitAsync(); |
| | 17 | 429 | | } |
| | 4 | 430 | | catch (Exception exception) |
| | | 431 | | { |
| | 4 | 432 | | failures++; |
| | 4 | 433 | | logger.LogError( |
| | 4 | 434 | | exception, "Retrying the blocked erasure request of person {PersonId} failed", |
| | 4 | 435 | | person.PublicId); |
| | 4 | 436 | | } |
| | 21 | 437 | | } |
| | | 438 | | |
| | 59 | 439 | | if (blocked.Count < retention.PurgeBatchSize) |
| | | 440 | | { |
| | | 441 | | break; |
| | | 442 | | } |
| | | 443 | | |
| | 3 | 444 | | afterRequestedAt = blocked[^1].ErasureRequestedAt; |
| | 3 | 445 | | afterId = blocked[^1].Id; |
| | 3 | 446 | | } |
| | 57 | 447 | | } |
| | 1 | 448 | | catch (Exception exception) |
| | | 449 | | { |
| | 1 | 450 | | failures++; |
| | 1 | 451 | | logger.LogError(exception, "Loading the blocked erasure requests to retry failed"); |
| | 1 | 452 | | } |
| | | 453 | | |
| | 58 | 454 | | return failures == 0 ? [] : [RetentionMessages.BlockedErasureRetryFailed]; |
| | 58 | 455 | | } |
| | | 456 | | |
| | | 457 | | /// <summary> |
| | | 458 | | /// Forgets, as <see cref="ErasedPersonInvitations" /> does, the persons already anonymised whom an |
| | | 459 | | /// unended invitation still names as invitee, or any invitation as inviter — at most a batch per run. |
| | | 460 | | /// </summary> |
| | | 461 | | /// <remarks> |
| | | 462 | | /// Normally none: the run that anonymised a person forgot them too. One that failed in between |
| | | 463 | | /// leaves them anonymised, and the selection takes only persons not yet anonymised, so nothing else |
| | | 464 | | /// would come back for them. Both invitation columns are indexed. |
| | | 465 | | /// </remarks> |
| | | 466 | | private async Task<IReadOnlyList<Guid>> ForgetAnonymisedStillNamedAsync(DateTime now) |
| | | 467 | | { |
| | 58 | 468 | | var anonymised = personReader.Query() |
| | 58 | 469 | | .Where(person => person.AnonymisedAt != null) |
| | 58 | 470 | | .Select(person => person.Id); |
| | | 471 | | |
| | 58 | 472 | | var stillNamed = await invitationReader.Query() |
| | 58 | 473 | | .Where(invitation => invitation.EndedAt == null && anonymised.Contains(invitation.InviteePersonId)) |
| | 58 | 474 | | .Select(invitation => invitation.InviteePersonId) |
| | 58 | 475 | | .Union(invitationReader.Query() |
| | 58 | 476 | | .Where(invitation => invitation.InvitedByPersonId != null && |
| | 58 | 477 | | anonymised.Contains(invitation.InvitedByPersonId.Value)) |
| | 58 | 478 | | .Select(invitation => invitation.InvitedByPersonId!.Value)) |
| | 58 | 479 | | .Take(retention.PurgeBatchSize) |
| | 58 | 480 | | .ToListAsync(); |
| | | 481 | | |
| | 58 | 482 | | return await ErasedPersonInvitations.ForgetAsync(invitations, auditLog, logger, stillNamed, now); |
| | 58 | 483 | | } |
| | | 484 | | |
| | | 485 | | /// <summary> |
| | | 486 | | /// Permanently removes the rows that hold data belonging to the persons being anonymised — |
| | | 487 | | /// see <see cref="PersonDependentsRemoval" />, which the re-application of erasures shares. |
| | | 488 | | /// </summary> |
| | | 489 | | private async Task<(int Removed, IEnumerable<string> Errors)> RemovePersonDependentsAsync( |
| | | 490 | | IReadOnlyCollection<Person> persons) => |
| | 44 | 491 | | await PersonDependentsRemoval.RemoveAsync( |
| | 66 | 492 | | persons.Select(person => person.Id).ToList(), |
| | 44 | 493 | | passwordResetTokenWriter, |
| | 44 | 494 | | emailVerificationTokenWriter, |
| | 44 | 495 | | twoFactorAuthWriter); |
| | | 496 | | } |