< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.AnonymiseExpiredDeletionsCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/AnonymiseExpiredDeletionsCommandHandler.cs
Line coverage
98%
Covered lines: 116
Uncovered lines: 2
Coverable lines: 118
Total lines: 291
Line coverage: 98.3%
Branch coverage
86%
Covered branches: 26
Total branches: 30
Branch coverage: 86.6%
Method coverage
100%
Covered methods: 6
Fully covered methods: 5
Total methods: 6
Method coverage: 100%
Full method coverage: 83.3%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()81.25%161696.92%
RetryBlockedRequestsAsync()100%88100%
RemovePersonDependentsAsync()100%11100%
SaveAsync()75%44100%
Success(...)100%22100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/AnonymiseExpiredDeletionsCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Entities;
 2using ArturRios.Data.Relational.Core.Interfaces;
 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;
 14
 15namespace 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>
 2556public class AnonymiseExpiredDeletionsCommandHandler(
 2557    IAsyncReadOnlyRepository<Person, long> personReader,
 2558    IAsyncRepository<Person, long> personWriter,
 2559    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 2560    IAsyncRepository<GoogleUser, long> googleUserWriter,
 2561    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 2562    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 2563    IAsyncRepository<TwoFactorAuth, long> twoFactorAuthWriter,
 2564    DataRetentionOptions retention,
 2565    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    {
 2786        var output = DataOutput<AnonymiseExpiredDeletionsCommandOutput?>.New;
 2787        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.
 2796        var erasureCutoff = now - retention.SubjectErasureDeadline;
 2797        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.
 27103        var unblockErrors = await RetryBlockedRequestsAsync(now);
 104
 27105        if (unblockErrors.Count > 0)
 106        {
 0107            return output.WithErrors(unblockErrors);
 108        }
 109
 27110        var persons = await personReader.Query()
 27111            // A restricted record is preserved exactly as it stands rather than entering the window
 27112            // (NFR-24): restriction and deletion are independent, so a record can be both — deleted
 27113            // by an administrator while under dispute, or restricted by a subject already pending
 27114            // erasure (UC-44). It is picked up again once the restriction is lifted (UC-45).
 27115            .Where(person => person.IsDeleted
 27116                             && person.AnonymisedAt == null
 27117                             && person.ProcessingRestrictedAt == null
 27118                             && ((person.ErasureDueAt != null && person.ErasureDueAt <= now)
 27119                                 || (person.ErasureDueAt == null
 27120                                     && person.DeletedAt != null
 27121                                     && (person.DeletionKind == SubjectRequested
 27122                                         ? person.DeletedAt <= erasureCutoff
 27123                                         : person.DeletedAt <= administrativeCutoff))))
 27124            .OrderBy(person => person.DeletedAt)
 27125            .Take(retention.PurgeBatchSize)
 27126            .ToListAsync();
 127
 27128        var googleUsers = await googleUserReader.Query()
 27129            .Where(googleUser => googleUser.IsDeleted
 27130                                 && googleUser.AnonymisedAt == null
 27131                                 && googleUser.ProcessingRestrictedAt == null
 27132                                 && ((googleUser.ErasureDueAt != null && googleUser.ErasureDueAt <= now)
 27133                                     || (googleUser.ErasureDueAt == null
 27134                                         && googleUser.DeletedAt != null
 27135                                         && (googleUser.DeletionKind == SubjectRequested
 27136                                             ? googleUser.DeletedAt <= erasureCutoff
 27137                                             : googleUser.DeletedAt <= administrativeCutoff))))
 27138            .OrderBy(googleUser => googleUser.DeletedAt)
 27139            .Take(retention.PurgeBatchSize)
 27140            .ToListAsync();
 141
 27142        if (persons.Count == 0 && googleUsers.Count == 0)
 143        {
 11144            return Success(output, 0, 0, 0);
 145        }
 146
 16147        var errors = new List<string>();
 16148        var dependentsRemoved = 0;
 149
 16150        if (persons.Count > 0)
 151        {
 15152            var (removed, dependentErrors) = await RemovePersonDependentsAsync(persons);
 153
 15154            dependentsRemoved += removed;
 15155            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.
 16162        if (errors.Count > 0)
 163        {
 0164            return output.WithErrors(errors);
 165        }
 166
 70167        foreach (var person in persons)
 168        {
 19169            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.
 19173            await atomicWrites.ClearSecondFactorAttemptsAsync(person);
 19174            await atomicWrites.ClearReauthenticationAttemptsAsync(person);
 19175            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.
 19179            await atomicWrites.ClearPasswordAsync(person);
 180
 181            // Nothing blocks it any more; it is done.
 19182            person.ErasureBlockedReason = null;
 19183        }
 184
 38185        foreach (var googleUser in googleUsers)
 186        {
 3187            IdentityAnonymiser.Anonymise(googleUser, now);
 188        }
 189
 16190        errors.AddRange(await SaveAsync(persons, personWriter));
 16191        errors.AddRange(await SaveAsync(googleUsers, googleUserWriter));
 192
 16193        return errors.Count > 0
 16194            ? output.WithErrors(errors)
 16195            : Success(
 16196                output, persons.Count, googleUsers.Count, dependentsRemoved,
 19197                persons.Select(person => person.PublicId)
 3198                    .Concat(googleUsers.Select(googleUser => googleUser.PublicId))
 16199                    .ToList());
 27200    }
 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    {
 27216        var blocked = await personReader.Query()
 27217            .Include(person => person.ScopeOwnerships)
 27218            .Where(person => !person.IsDeleted
 27219                             && person.ErasureRequestedAt != null
 27220                             && person.ErasureBlockedReason != null
 27221                             // NFR-24, as above: a restricted record is not moved toward erasure.
 27222                             && person.ProcessingRestrictedAt == null)
 27223            .ToListAsync();
 224
 27225        if (blocked.Count == 0)
 226        {
 24227            return [];
 228        }
 229
 3230        var cleared = new List<Person>();
 231
 14232        foreach (var person in blocked)
 233        {
 4234            if (await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person, personReader))
 235            {
 236                continue;
 237            }
 238
 1239            person.IsDeleted = true;
 1240            person.DeletedAt = person.ErasureRequestedAt;
 1241            person.DeletionKind = (int)DeletionKinds.SubjectRequested;
 1242            person.ErasureBlockedReason = null;
 1243            person.UpdatedAt = now;
 244
 1245            cleared.Add(person);
 1246        }
 247
 3248        return cleared.Count == 0 ? [] : (await SaveAsync(cleared, personWriter)).ToList();
 27249    }
 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) =>
 15257        await PersonDependentsRemoval.RemoveAsync(
 19258            persons.Select(person => person.Id).ToList(),
 15259            passwordResetTokenWriter,
 15260            emailVerificationTokenWriter,
 15261            twoFactorAuthWriter);
 262
 263    private static async Task<IEnumerable<string>> SaveAsync<T>(
 264        IReadOnlyCollection<T> records, IAsyncRepository<T, long> writer) where T : Entity<long>
 265    {
 33266        if (records.Count == 0)
 267        {
 15268            return [];
 269        }
 270
 18271        var update = await writer.UpdateRangeAsync(records);
 272
 18273        return update.Success ? [] : update.Errors;
 33274    }
 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) =>
 27282        output
 27283            .WithData(new AnonymiseExpiredDeletionsCommandOutput
 27284            {
 27285                PersonsAnonymised = persons,
 27286                GoogleUsersAnonymised = googleUsers,
 27287                DependentsRemoved = dependents,
 27288                AnonymisedIds = anonymisedIds ?? []
 27289            })
 27290            .WithMessage(RetentionMessages.ExpiredDeletionsAnonymised);
 291}