< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.PurgeExpiredTokensCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/PurgeExpiredTokensCommandHandler.cs
Line coverage
100%
Covered lines: 112
Uncovered lines: 0
Coverable lines: 112
Total lines: 259
Line coverage: 100%
Branch coverage
92%
Covered branches: 13
Total branches: 14
Branch coverage: 92.8%
Method coverage
100%
Covered methods: 3
Fully covered methods: 3
Total methods: 3
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%22100%
PurgeAsync()91.66%1212100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/PurgeExpiredTokensCommandHandler.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.Domain.Entities;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Heimdall.Shared.Retention;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="PurgeExpiredTokensCommand" /> (NFR-19): removes password reset tokens,
 16///     email verification tokens, two-factor email codes, unconfirmed pending registrations (UC-47),
 17///     spent application credentials (UC-55), ended registration-attempt notice budgets (FR-SR-14)
 18///     and the records of used Google ID tokens (FR-GO-21) whose retention period has run out, and
 19///     scope owner invitations ended or expired more than 90 days ago (FR-SC-18). All failures are
 20///     returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 21/// </summary>
 22/// <remarks>
 23///     <para>
 24///         <b>What makes a row purgeable.</b> The four token and registration tables purge on
 25///         expiry: <c>ExpiresAt &lt;= now - grace</c>. It covers a used row and an expired-unused
 26///         row alike, and it is the only condition that can be applied to all four: neither
 27///         <see cref="PasswordResetToken" /> nor <see cref="EmailVerificationToken" /> records when
 28///         it was consumed, and only <see cref="TwoFactorEmailCode" /> carries a <c>CreatedAt</c>.
 29///         Expiry is a sound proxy in any case — each of these is issued with a lifetime measured
 30///         in minutes or hours, so a row's expiry is never far from its issue. A
 31///         <see cref="PendingRegistration" /> is held for the same grace period, so that following
 32///         a stale confirmation link answers "expired" rather than "invalid" for as long as it does
 33///         for the tokens.
 34///     </para>
 35///     <para>
 36///         An <see cref="ApplicationCredential" /> purges on a condition of its own: revoked or
 37///         expired at or before <c>now - grace</c>, whichever came first. Expiry alone would not
 38///         do — a credential may have none, and would then be kept for ever once revoked.
 39///     </para>
 40///     <para>
 41///         An <see cref="AttemptNoticeBudget" /> purges once its window ended at or before the
 42///         cutoff. An ended window counts nothing — the next notice starts a new one — so the row is
 43///         only a digest of an address waiting to be removed, and the grace keeps it no longer than a
 44///         token is kept.
 45///     </para>
 46///     <para>
 47///         A <see cref="GoogleIdTokenUse" /> purges on the token's own expiry, as the tokens above do:
 48///         an expired Google ID token is refused by its verifier whether or not its use is recorded,
 49///         so after the grace the record guards nothing.
 50///     </para>
 51///     <para>
 52///         A <see cref="ScopeOwnerInvitation" /> keeps a period of its own,
 53///         <see cref="DataRetentionOptions.ScopeInvitationRetention" />, counted from its end — or, for
 54///         one that ran out unanswered and was never ended, from its expiry. An ended invitation does
 55///         nothing; the period lets a scope's owners see who declined or let it lapse.
 56///     </para>
 57///     <para>
 58///         <b>Why a live token is never at risk.</b> The cutoff is strictly in the past, so a token
 59///         that has not yet expired cannot match however the grace period is configured — the
 60///         options type refuses a zero or negative one. A person mid-recovery is never affected by
 61///         a run.
 62///     </para>
 63///     <para>
 64///         <b>Draining a backlog (#206).</b> Each table is taken a batch at a time, and the batch is
 65///         repeated while it comes back full, up to <see cref="DataRetentionOptions.MaxBatchesPerTick" />
 66///         batches a run. One batch a run held a table to 12,000 rows a day at the defaults, which a
 67///         backlog could outgrow for good; each batch is still its own short delete. A run that stops
 68///         at the cap counts what is left and reports it as <c>Backlog</c>.
 69///     </para>
 70///     <para>
 71///         <b>Running on more than one instance.</b> NFR-06 requires that no instance assume it is
 72///         alone, and two purges may overlap. Nothing is claimed or locked: each run selects a
 73///         batch, and <c>DeleteRangeAsync</c> re-reads those ids and removes what still exists, so
 74///         an instance whose rows another already deleted simply removes fewer than it selected and
 75///         reports the true count. That is the whole of the coordination, and it is enough because
 76///         deleting an already-deleted row is the one concurrent outcome this pass can have.
 77///     </para>
 78/// </remarks>
 2479public class PurgeExpiredTokensCommandHandler(
 2480    IAsyncReadOnlyRepository<PasswordResetToken, long> passwordResetTokenReader,
 2481    IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter,
 2482    IAsyncReadOnlyRepository<EmailVerificationToken, long> emailVerificationTokenReader,
 2483    IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter,
 2484    IAsyncReadOnlyRepository<TwoFactorEmailCode, long> twoFactorEmailCodeReader,
 2485    IAsyncRepository<TwoFactorEmailCode, long> twoFactorEmailCodeWriter,
 2486    IAsyncReadOnlyRepository<PendingRegistration, long> pendingRegistrationReader,
 2487    IAsyncRepository<PendingRegistration, long> pendingRegistrationWriter,
 2488    IAsyncReadOnlyRepository<ApplicationCredential, long> applicationCredentialReader,
 2489    IAsyncRepository<ApplicationCredential, long> applicationCredentialWriter,
 2490    IAsyncReadOnlyRepository<AttemptNoticeBudget, long> attemptNoticeBudgetReader,
 2491    IAsyncRepository<AttemptNoticeBudget, long> attemptNoticeBudgetWriter,
 2492    IAsyncReadOnlyRepository<GoogleIdTokenUse, string> googleIdTokenUseReader,
 2493    IAsyncRepository<GoogleIdTokenUse, string> googleIdTokenUseWriter,
 2494    IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> scopeOwnerInvitationReader,
 2495    IAsyncRepository<ScopeOwnerInvitation, long> scopeOwnerInvitationWriter,
 2496    DataRetentionOptions retention)
 97    : ICommandHandlerAsync<PurgeExpiredTokensCommand, PurgeExpiredTokensCommandOutput>
 98{
 99    public async Task<DataOutput<PurgeExpiredTokensCommandOutput?>> HandleAsync(PurgeExpiredTokensCommand command, Cance
 100    {
 27101        var output = DataOutput<PurgeExpiredTokensCommandOutput?>.New;
 27102        var cutoff = DateTime.UtcNow - retention.SingleUseTokenGrace;
 103
 104        // Oldest first, so a backlog drains in the order it accumulated and no row can be starved by
 105        // fresher ones arriving ahead of it.
 27106        var passwordResetTokens = await PurgeAsync(
 27107            passwordResetTokenReader.Query()
 27108                .Where(token => token.ExpiresAt <= cutoff)
 27109                .OrderBy(token => token.ExpiresAt),
 27110            passwordResetTokenWriter);
 111
 27112        var emailVerificationTokens = await PurgeAsync(
 27113            emailVerificationTokenReader.Query()
 27114                .Where(token => token.ExpiresAt <= cutoff)
 27115                .OrderBy(token => token.ExpiresAt),
 27116            emailVerificationTokenWriter);
 117
 27118        var twoFactorEmailCodes = await PurgeAsync(
 27119            twoFactorEmailCodeReader.Query()
 27120                .Where(code => code.ExpiresAt <= cutoff)
 27121                .OrderBy(code => code.ExpiresAt),
 27122            twoFactorEmailCodeWriter);
 123
 27124        var pendingRegistrations = await PurgeAsync(
 27125            pendingRegistrationReader.Query()
 27126                .Where(registration => registration.ExpiresAt <= cutoff)
 27127                .OrderBy(registration => registration.ExpiresAt),
 27128            pendingRegistrationWriter);
 129
 130        // UC-55: a revoked or expired credential can never be accepted again, so after the same
 131        // grace as the single-use tokens it is removed — revoked or expired, whichever came first.
 27132        var applicationCredentials = await PurgeAsync(
 27133            applicationCredentialReader.Query()
 27134                .Where(credential => (credential.RevokedAt != null && credential.RevokedAt <= cutoff) ||
 27135                                     (credential.ExpiresAt != null && credential.ExpiresAt <= cutoff))
 27136                .OrderBy(credential => credential.RevokedAt ?? credential.ExpiresAt),
 27137            applicationCredentialWriter);
 138
 139        // FR-SR-14: a notice budget whose window has ended counts nothing any more.
 27140        var attemptNoticeBudgets = await PurgeAsync(
 27141            attemptNoticeBudgetReader.Query()
 27142                .Where(budget => budget.WindowEndsAt <= cutoff)
 27143                .OrderBy(budget => budget.WindowEndsAt),
 27144            attemptNoticeBudgetWriter);
 145
 146        // FR-GO-21: an expired Google ID token cannot be accepted again, used or not.
 27147        var googleIdTokenUses = await PurgeAsync(
 27148            googleIdTokenUseReader.Query()
 27149                .Where(use => use.ExpiresAt <= cutoff)
 27150                .OrderBy(use => use.ExpiresAt),
 27151            googleIdTokenUseWriter);
 152
 153        // FR-SC-18: an invitation that ended, or ran out unanswered, is kept 90 days and then removed.
 27154        var invitationCutoff = DateTime.UtcNow - retention.ScopeInvitationRetention;
 155
 27156        var scopeOwnerInvitations = await PurgeAsync(
 27157            scopeOwnerInvitationReader.Query()
 27158                .Where(invitation => (invitation.EndedAt != null && invitation.EndedAt <= invitationCutoff) ||
 27159                                     (invitation.EndedAt == null && invitation.ExpiresAt <= invitationCutoff))
 27160                .OrderBy(invitation => invitation.EndedAt ?? invitation.ExpiresAt),
 27161            scopeOwnerInvitationWriter);
 162
 163        // Every table is attempted before the run reports, so one failing table does not hide
 164        // whether the others were cleared — and the next run retries whatever was missed.
 27165        var errors = passwordResetTokens.Errors
 27166            .Concat(emailVerificationTokens.Errors)
 27167            .Concat(twoFactorEmailCodes.Errors)
 27168            .Concat(pendingRegistrations.Errors)
 27169            .Concat(applicationCredentials.Errors)
 27170            .Concat(attemptNoticeBudgets.Errors)
 27171            .Concat(googleIdTokenUses.Errors)
 27172            .Concat(scopeOwnerInvitations.Errors)
 27173            .ToList();
 174
 27175        var purged = new PurgeExpiredTokensCommandOutput
 27176        {
 27177            PasswordResetTokensPurged = passwordResetTokens.Count,
 27178            EmailVerificationTokensPurged = emailVerificationTokens.Count,
 27179            TwoFactorEmailCodesPurged = twoFactorEmailCodes.Count,
 27180            PendingRegistrationsPurged = pendingRegistrations.Count,
 27181            ApplicationCredentialsPurged = applicationCredentials.Count,
 27182            AttemptNoticeBudgetsPurged = attemptNoticeBudgets.Count,
 27183            GoogleIdTokenUsesPurged = googleIdTokenUses.Count,
 27184            ScopeOwnerInvitationsPurged = scopeOwnerInvitations.Count,
 27185            Backlog = passwordResetTokens.Backlog + emailVerificationTokens.Backlog +
 27186                      twoFactorEmailCodes.Backlog + pendingRegistrations.Backlog +
 27187                      applicationCredentials.Backlog + attemptNoticeBudgets.Backlog +
 27188                      googleIdTokenUses.Backlog + scopeOwnerInvitations.Backlog
 27189        };
 190
 191        // The counts ride along with the errors: batches removed before a failure are gone, and the run's
 192        // record should say so rather than claim nothing happened.
 27193        if (errors.Count > 0)
 194        {
 1195            return output.WithData(purged).WithErrors(errors);
 196        }
 197
 198        // A run that removed nothing is a success: most runs find nothing to do, and a failed
 199        // outcome would fill the audit trail with refusals that never happened.
 26200        return output
 26201            .WithData(purged)
 26202            .WithMessage(RetentionMessages.ExpiredTokensPurged);
 27203    }
 204
 205    /// <summary>
 206    ///     Removes the candidates <c>PurgeBatchSize</c> at a time, repeating while a batch comes back
 207    ///     full, for at most <c>MaxBatchesPerTick</c> batches. Reports how many rows were actually
 208    ///     removed, how many are still due when the run stopped with a full batch, and the persistence
 209    ///     errors if a delete failed — which ends the table's run, keeping what earlier batches removed.
 210    /// </summary>
 211    private async Task<(int Count, int Backlog, IEnumerable<string> Errors)> PurgeAsync<T, TKey>(
 212        IQueryable<T> candidates,
 213        IAsyncRepository<T, TKey> writer)
 214        where T : Entity<TKey>
 215        where TKey : IEquatable<TKey>
 216    {
 216217        var removed = 0;
 218
 460219        for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++)
 220        {
 226221            var ids = await candidates
 226222                .Take(retention.PurgeBatchSize)
 226223                .Select(entity => entity.Id)
 226224                .ToListAsync();
 225
 226226            if (ids.Count == 0)
 227            {
 191228                return (removed, 0, []);
 229            }
 230
 35231            var deletion = await writer.DeleteRangeAsync(ids);
 232
 35233            if (!deletion.Success)
 234            {
 1235                return (removed, 0, deletion.Errors);
 236            }
 237
 238            // The count comes from what the delete reported removing, not from what was selected: they
 239            // differ when another instance got there first, and the trail should record what this run
 240            // did rather than what it intended.
 34241            var count = deletion.Data?.Count() ?? 0;
 34242            removed += count;
 243
 244            // A batch that was not full took everything due. One that removed nothing made no progress
 245            // — another instance is deleting the same rows — so it is not repeated either.
 34246            if (ids.Count < retention.PurgeBatchSize)
 247            {
 19248                return (removed, 0, []);
 249            }
 250
 15251            if (count == 0)
 252            {
 253                break;
 254            }
 14255        }
 256
 5257        return (removed, await candidates.CountAsync(), []);
 216258    }
 259}

Methods/Properties

.ctor(ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.PasswordResetToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.PasswordResetToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.EmailVerificationToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.EmailVerificationToken,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.TwoFactorEmailCode,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.TwoFactorEmailCode,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.PendingRegistration,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.PendingRegistration,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.ApplicationCredential,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.ApplicationCredential,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.AttemptNoticeBudget,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.AttemptNoticeBudget,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.GoogleIdTokenUse,System.String>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.GoogleIdTokenUse,System.String>,ArturRios.Data.Relational.Core.Interfaces.IAsyncReadOnlyRepository`2<ArturRios.Heimdall.Domain.Entities.ScopeOwnerInvitation,System.Int64>,ArturRios.Data.Relational.Core.Interfaces.IAsyncRepository`2<ArturRios.Heimdall.Domain.Entities.ScopeOwnerInvitation,System.Int64>,ArturRios.Heimdall.Shared.Retention.DataRetentionOptions)
HandleAsync()
PurgeAsync()