< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.AnonymiseExpiredDeletionsCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/AnonymiseExpiredDeletionsCommandHandler.cs
Line coverage
98%
Covered lines: 187
Uncovered lines: 2
Coverable lines: 189
Total lines: 496
Line coverage: 98.9%
Branch coverage
98%
Covered branches: 59
Total branches: 60
Branch coverage: 98.3%
Method coverage
100%
Covered methods: 9
Fully covered methods: 8
Total methods: 9
Method coverage: 100%
Full method coverage: 88.8%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()96.87%323297.29%
CountBacklogAsync()100%44100%
DuePersons(...)100%11100%
DueGoogleUsers(...)100%11100%
WithRetryErrors(...)100%22100%
RetryBlockedRequestsAsync()100%2222100%
ForgetAnonymisedStillNamedAsync()100%11100%
RemovePersonDependentsAsync()100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/AnonymiseExpiredDeletionsCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Auditing;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Command.Services;
 6using ArturRios.Heimdall.Domain.Entities;
 7using ArturRios.Heimdall.Domain.Enums;
 8using ArturRios.Heimdall.Domain.Persistence;
 9using ArturRios.Heimdall.Shared.Messages;
 10using ArturRios.Heimdall.Shared.Retention;
 11using ArturRios.Mediator.Command.Interfaces;
 12using ArturRios.Output;
 13using Microsoft.EntityFrameworkCore;
 14using Microsoft.Extensions.Logging;
 15
 16namespace 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>
 5566public class AnonymiseExpiredDeletionsCommandHandler(
 5567    IAsyncReadOnlyRepository<Person, long> personReader,
 5568    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 5569    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 5570    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 5571    IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter,
 5572    DataRetentionOptions retention,
 5573    IAtomicWrites atomicWrites,
 5574    IScopeOwnerInvitations invitations,
 5575    IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> invitationReader,
 5576    IAuditLogWriter auditLog,
 5577    ILogger<AnonymiseExpiredDeletionsCommandHandler> logger,
 5578    IWriteTransactions transactions,
 5579    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    {
 5892        var output = DataOutput<AnonymiseExpiredDeletionsCommandOutput?>.New;
 5893        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.
 58102        var erasureCutoff = now - retention.SubjectErasureDeadline;
 58103        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.
 58112        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.
 58116        var caughtUp = await ForgetAnonymisedStillNamedAsync(now);
 117
 58118        var result = new AnonymiseExpiredDeletionsCommandOutput { ScopeOwnerInvitationsVoided = caughtUp.Count };
 58119        var anonymisedIds = new List<Guid>();
 58120        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.
 58124        var personsFull = false;
 58125        var googleUsersFull = false;
 58126        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        {
 146132            for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++)
 133            {
 67134                var persons = await DuePersons(now, erasureCutoff, administrativeCutoff)
 67135                    .OrderBy(person => person.DeletedAt)
 67136                    .Take(retention.PurgeBatchSize)
 67137                    .ToListAsync();
 138
 67139                if (persons.Count == 0)
 140                {
 23141                    personsFull = false;
 23142                    break;
 143                }
 144
 44145                var (removed, dependentErrors) = await RemovePersonDependentsAsync(persons);
 146
 44147                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.
 44153                errors.AddRange(dependentErrors);
 154
 44155                if (errors.Count > 0)
 156                {
 0157                    personsFull = false;
 0158                    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.
 44165                var anonymisedPersons = new List<Person>();
 166
 219167                foreach (var person in persons)
 168                {
 66169                    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.
 62173                        anonymisedPersons.Add(person);
 62174                        result.PersonsAnonymised++;
 62175                        anonymisedIds.Add(person.PublicId);
 176                    }
 65177                }
 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.
 43183                var voided = await ErasedPersonInvitations.ForgetAsync(
 104184                    invitations, auditLog, logger, anonymisedPersons.Select(person => person.Id).ToList(), now);
 185
 43186                result.ScopeOwnerInvitationsVoided += voided.Count;
 187
 43188                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.
 43192                if (!personsFull || anonymisedPersons.Count == 0)
 193                {
 194                    break;
 195                }
 15196            }
 197
 198            // After a failed dependent removal nothing else is attempted this run, as before the drain.
 124199            for (var batch = 0; errors.Count == 0 && batch < retention.MaxBatchesPerTick; batch++)
 200            {
 61201                var googleUsers = await DueGoogleUsers(now, erasureCutoff, administrativeCutoff)
 61202                    .OrderBy(googleUser => googleUser.DeletedAt)
 61203                    .Take(retention.PurgeBatchSize)
 61204                    .ToListAsync();
 205
 61206                if (googleUsers.Count == 0)
 207                {
 49208                    googleUsersFull = false;
 49209                    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.
 12215                var anonymisedGoogleUsers = 0;
 216
 57217                foreach (var googleUser in googleUsers)
 218                {
 18219                    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.
 14223                        anonymisedGoogleUsers++;
 14224                        result.GoogleUsersAnonymised++;
 14225                        anonymisedIds.Add(googleUser.PublicId);
 226                    }
 15227                }
 228
 9229                googleUsersFull = googleUsers.Count == retention.PurgeBatchSize;
 230
 231                // As for persons: a full batch in which nobody could be anonymised made no progress.
 9232                if (!googleUsersFull || anonymisedGoogleUsers == 0)
 233                {
 234                    break;
 235                }
 5236            }
 54237        }
 4238        catch (Exception exception)
 239        {
 4240            interrupted = true;
 4241            logger.LogError(
 4242                exception, "Anonymising the identities due failed part-way; those already anonymised are named");
 4243            errors.Add(RetentionMessages.AnonymisationInterrupted);
 4244        }
 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.
 58248        result.Backlog = interrupted
 58249            ? null
 58250            : 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.
 58255        result.AnonymisedIds = anonymisedIds;
 256
 58257        var success = output
 58258            .WithData(result)
 58259            .WithMessage(RetentionMessages.ExpiredDeletionsAnonymised);
 260
 58261        return errors.Count > 0
 58262            ? success.WithErrors(errors.Concat(retryErrors).ToList())
 58263            : WithRetryErrors(success, retryErrors);
 58264    }
 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        {
 54275            return (personsFull ? await DuePersons(now, erasureCutoff, administrativeCutoff).CountAsync() : 0)
 54276                   + (googleUsersFull
 54277                       ? await DueGoogleUsers(now, erasureCutoff, administrativeCutoff).CountAsync()
 54278                       : 0);
 279        }
 1280        catch (Exception exception)
 281        {
 1282            logger.LogWarning(exception, "Counting the identities still due failed; this run's backlog is unknown");
 283
 1284            return null;
 285        }
 54286    }
 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) =>
 74301        personReader.Query()
 74302            // A restricted record is preserved exactly as it stands rather than entering the window
 74303            // (NFR-24): restriction and deletion are independent, so a record can be both — deleted
 74304            // by an administrator while under dispute, or restricted by a subject already pending
 74305            // erasure (UC-44). It is picked up again once the restriction is lifted (UC-45).
 74306            .Where(person => person.IsDeleted
 74307                             && person.AnonymisedAt == null
 74308                             && person.ProcessingRestrictedAt == null
 74309                             && ((person.ErasureDueAt != null && person.ErasureDueAt <= now)
 74310                                 || (person.ErasureDueAt == null
 74311                                     && person.DeletedAt != null
 74312                                     && (person.DeletionKind == SubjectRequested
 74313                                         ? person.DeletedAt <= erasureCutoff
 74314                                         : 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) =>
 62319        googleUserReader.Query()
 62320            .Where(googleUser => googleUser.IsDeleted
 62321                                 && googleUser.AnonymisedAt == null
 62322                                 && googleUser.ProcessingRestrictedAt == null
 62323                                 && ((googleUser.ErasureDueAt != null && googleUser.ErasureDueAt <= now)
 62324                                     || (googleUser.ErasureDueAt == null
 62325                                         && googleUser.DeletedAt != null
 62326                                         && (googleUser.DeletionKind == SubjectRequested
 62327                                             ? googleUser.DeletedAt <= erasureCutoff
 62328                                             : googleUser.DeletedAt <= administrativeCutoff))));
 329
 330    private static DataOutput<AnonymiseExpiredDeletionsCommandOutput?> WithRetryErrors(
 331        DataOutput<AnonymiseExpiredDeletionsCommandOutput?> output, IReadOnlyCollection<string> retryErrors) =>
 54332        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    {
 58377        var failures = 0;
 378
 379        try
 380        {
 58381            DateTime? afterRequestedAt = null;
 58382            var afterId = 0L;
 383
 122384            for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++)
 385            {
 60386                var candidates = personReader.Query()
 60387                    .Where(person => !person.IsDeleted
 60388                                     && person.ErasureRequestedAt != null
 60389                                     && person.ErasureBlockedReason != null
 60390                                     // NFR-24, as above: a restricted record is not moved toward erasure.
 60391                                     && person.ProcessingRestrictedAt == null);
 392
 59393                if (afterRequestedAt is { } at)
 394                {
 2395                    candidates = candidates.Where(person =>
 2396                        person.ErasureRequestedAt > at || (person.ErasureRequestedAt == at && person.Id > afterId));
 397                }
 398
 59399                var blocked = await candidates
 59400                    .OrderBy(person => person.ErasureRequestedAt)
 59401                    .ThenBy(person => person.Id)
 59402                    .Take(retention.PurgeBatchSize)
 59403                    .ToListAsync();
 404
 160405                foreach (var person in blocked)
 406                {
 407                    try
 408                    {
 21409                        await using var transaction = await transactions.BeginAsync();
 410
 411                        // The person as they stand under the locks (#202, #203): the guards decide on this.
 21412                        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.
 21417                        var stillBlocked = locked is null ||
 21418                            await LastScopeOwnerGuard.WouldStripLastOwnerAsync(
 21419                                person.Id, locked.OwnedScopeIds, personReader) ||
 21420                            (locked.IsLiveSystemAdmin &&
 21421                             await LastSystemAdminGuard.WouldLeaveNoneAsync(person.Id, personReader));
 422
 21423                        if (!stillBlocked)
 424                        {
 11425                            await atomicWrites.TrySuspendBlockedErasureAsync(person, now);
 426                        }
 427
 17428                        await transaction.CommitAsync();
 17429                    }
 4430                    catch (Exception exception)
 431                    {
 4432                        failures++;
 4433                        logger.LogError(
 4434                            exception, "Retrying the blocked erasure request of person {PersonId} failed",
 4435                            person.PublicId);
 4436                    }
 21437                }
 438
 59439                if (blocked.Count < retention.PurgeBatchSize)
 440                {
 441                    break;
 442                }
 443
 3444                afterRequestedAt = blocked[^1].ErasureRequestedAt;
 3445                afterId = blocked[^1].Id;
 3446            }
 57447        }
 1448        catch (Exception exception)
 449        {
 1450            failures++;
 1451            logger.LogError(exception, "Loading the blocked erasure requests to retry failed");
 1452        }
 453
 58454        return failures == 0 ? [] : [RetentionMessages.BlockedErasureRetryFailed];
 58455    }
 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    {
 58468        var anonymised = personReader.Query()
 58469            .Where(person => person.AnonymisedAt != null)
 58470            .Select(person => person.Id);
 471
 58472        var stillNamed = await invitationReader.Query()
 58473            .Where(invitation => invitation.EndedAt == null && anonymised.Contains(invitation.InviteePersonId))
 58474            .Select(invitation => invitation.InviteePersonId)
 58475            .Union(invitationReader.Query()
 58476                .Where(invitation => invitation.InvitedByPersonId != null &&
 58477                                     anonymised.Contains(invitation.InvitedByPersonId.Value))
 58478                .Select(invitation => invitation.InvitedByPersonId!.Value))
 58479            .Take(retention.PurgeBatchSize)
 58480            .ToListAsync();
 481
 58482        return await ErasedPersonInvitations.ForgetAsync(invitations, auditLog, logger, stillNamed, now);
 58483    }
 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) =>
 44491        await PersonDependentsRemoval.RemoveAsync(
 66492            persons.Select(person => person.Id).ToList(),
 44493            passwordResetTokenWriter,
 44494            emailVerificationTokenWriter,
 44495            twoFactorAuthWriter);
 496}

Methods/Properties

.ctor(ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.Person,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.GoogleUser,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.PasswordResetToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.EmailVerificationToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.TwoFactorAuth,System.Int64>,ArturRios.Heimdall.Shared.Retention.DataRetentionOptions,ArturRios.Heimdall.Domain.Persistence.IAtomicWrites,ArturRios.Heimdall.Domain.Persistence.IScopeOwnerInvitations,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.ScopeOwnerInvitation,System.Int64>,ArturRios.Heimdall.Command.Auditing.IAuditLogWriter,Microsoft.Extensions.Logging.ILogger`1<ArturRios.Heimdall.Command.Handlers.AnonymiseExpiredDeletionsCommandHandler>,ArturRios.Heimdall.Domain.Persistence.IWriteTransactions,ArturRios.Heimdall.Domain.Persistence.IInvariantLocks)
HandleAsync()
CountBacklogAsync()
DuePersons(System.DateTime,System.DateTime,System.DateTime)
DueGoogleUsers(System.DateTime,System.DateTime,System.DateTime)
WithRetryErrors(ArturRios.Output.DataOutput`1<ArturRios.Heimdall.Command.Output.AnonymiseExpiredDeletionsCommandOutput>,System.Collections.Generic.IReadOnlyCollection`1<System.String>)
RetryBlockedRequestsAsync()
ForgetAnonymisedStillNamedAsync()
RemovePersonDependentsAsync()