< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.RequestErasureCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/RequestErasureCommandHandler.cs
Line coverage
98%
Covered lines: 101
Uncovered lines: 2
Coverable lines: 103
Total lines: 307
Line coverage: 98%
Branch coverage
90%
Covered branches: 38
Total branches: 42
Branch coverage: 90.4%
Method coverage
100%
Covered methods: 9
Fully covered methods: 7
Total methods: 9
Method coverage: 100%
Full method coverage: 77.7%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()92.85%1414100%
RequestForPersonAsync()91.66%1212100%
RequestForGoogleUserAsync()80%101095.23%
LiftRestriction(...)100%22100%
LiftRestriction(...)100%22100%
Suspend(...)100%11100%
Suspend(...)100%11100%
Recorded(...)100%22100%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Heimdall.Shared.Retention;
 10using ArturRios.Mediator.Command.Interfaces;
 11using ArturRios.Output;
 12using FluentValidation;
 13using Microsoft.EntityFrameworkCore;
 14
 15namespace ArturRios.Heimdall.Command.Handlers;
 16
 17/// <summary>
 18///     Handles <see cref="RequestErasureCommand" /> (UC-42): re-authenticates the caller, records
 19///     their erasure request against their own record, lifts any restriction on processing they
 20///     had in place, and suspends the identity so the request takes effect immediately. The
 21///     anonymisation pass (NFR-20) completes it on the deadline. All failures are returned as
 22///     errors on the <see cref="DataOutput{T}" /> rather than thrown.
 23/// </summary>
 24/// <remarks>
 25///     <para>
 26///         <b>Why the subject is never named in the request.</b> The identity acted on is the one
 27///         the token names, always. There is no path parameter and no body field for it, so the
 28///         endpoint has no shape in which one identity could ask for another's erasure — a rule
 29///         enforced by the absence of the parameter rather than by a check that could be got wrong.
 30///     </para>
 31///     <para>
 32///         <b>Why re-authentication.</b> Erasure is irreversible, and a bearer token is not proof
 33///         that the person is present — one left open on a shared machine, or lifted from a log,
 34///         would otherwise be enough to destroy the account it belongs to. A person presents their
 35///         password; a Google User presents a fresh Google ID token, because they have no password.
 36///         A person's password is charged to their re-authentication budget (FR-AU-13) and refused
 37///         with the message every re-authentication shares; a Google User's token, which cannot be
 38///         guessed, is refused with <see cref="ErasureMessages.CredentialNotAccepted" />. Both are 401.
 39///     </para>
 40///     <para>
 41///         <b>Why the request is recorded before it can be carried out.</b> NFR-12 refuses to
 42///         suspend the last owner of a scope, and clearing that needs a human to transfer ownership
 43///         (UC-21). Refusing the request outright would be the wrong answer twice over: the subject
 44///         has exercised a right that does not depend on the scope's ownership arrangements, and
 45///         GDPR Art. 12(3)'s clock starts at the request whether or not anything blocks it. So the
 46///         request is always recorded and always starts the deadline; the block is recorded
 47///         alongside it, and the anonymisation pass carries the erasure out as soon as it clears.
 48///     </para>
 49///     <para>
 50///         <b>Why the request lifts a restriction.</b> A restricted record is kept out of the
 51///         anonymisation pass (NFR-24), so an erasure requested under a restriction would wait,
 52///         unerased, for as long as the restriction stood — and the statutory deadline would pass
 53///         meanwhile. Only the subject can impose a restriction (UC-44), and only the subject can
 54///         get here, credential in hand: asking to be erased is their later and more specific
 55///         instruction, and it supersedes their wish that the record be preserved. The lift is the
 56///         one UC-45 performs for a subject lifting their own — no Art. 18(3) notice, since they
 57///         are the person it would inform — and it is saved with the request, so neither can
 58///         happen without the other. The response and the audit trail both say it happened.
 59///     </para>
 60///     <para>
 61///         <b>Why the deadline is stored rather than derived.</b> The obligation attaches when the
 62///         request is made. Recomputing it from the current configuration on every read would let a
 63///         later change move an obligation that had already attached.
 64///     </para>
 65/// </remarks>
 3966public class RequestErasureCommandHandler(
 3967    IValidator<RequestErasureCommand> validator,
 3968    IAsyncReadOnlyRepository<Person, long> personReader,
 3969    IAsyncRepository<Person, long> personWriter,
 3970    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 3971    IAsyncRepository<GoogleUser, long> googleUserWriter,
 3972    IGoogleIdTokenVerifier tokenVerifier,
 3973    DataRetentionOptions retention,
 3974    IAtomicWrites atomicWrites)
 75    : ICommandHandlerAsync<RequestErasureCommand, RequestErasureCommandOutput>
 76{
 77    public async Task<DataOutput<RequestErasureCommandOutput?>> HandleAsync(RequestErasureCommand command, CancellationT
 78    {
 3979        var output = DataOutput<RequestErasureCommandOutput?>.New;
 80
 81        // NFR-10: validate input shape.
 3982        var validation = await validator.ValidateAsync(command);
 83
 3984        if (!validation.IsValid)
 85        {
 086            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 87        }
 88
 89        // The person table first, then the Google User table — the same order UC-36 uses, and for
 90        // the same reason: the two identity tables are never joined, and a PublicId belongs to one
 91        // of them.
 92        //
 93        // The lookups include logically deleted identities so that a subject whose first request
 94        // suspended them is told AF-42c — the request they are repeating exists — rather than the
 95        // generic AF-42b. The endpoint admits such a caller because it is a data subject right; any
 96        // other deleted identity is refused with AF-42b once AF-42c has been ruled out.
 3997        var person = await personReader.Query()
 3998            .Include(x => x.ScopeOwnerships)
 3999            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 100
 39101        if (person is not null)
 102        {
 31103            return person.ErasureRequestedAt is null && person.IsDeleted
 31104                ? output.WithError(ErasureMessages.NotEligible)
 31105                : await RequestForPersonAsync(output, command, person);
 106        }
 107
 8108        var googleUser = await googleUserReader.Query()
 8109            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 110
 8111        return googleUser is null || (googleUser.ErasureRequestedAt is null && googleUser.IsDeleted)
 8112            ? output.WithError(ErasureMessages.NotEligible)
 8113            : await RequestForGoogleUserAsync(output, command, googleUser);
 39114    }
 115
 116    private async Task<DataOutput<RequestErasureCommandOutput?>> RequestForPersonAsync(
 117        DataOutput<RequestErasureCommandOutput?> output, RequestErasureCommand command, Person person)
 118    {
 119        // AF-42c. Checked before the credential so a repeat request does not cost an Argon2id
 120        // derivation, and so the answer does not depend on getting the password right a second time.
 29121        if (person.ErasureRequestedAt is not null)
 122        {
 4123            return output.WithError(ErasureMessages.ErasureAlreadyRequested);
 124        }
 125
 126        // AF-42a. The same derivation UC-11 performs, through the same gate, so this endpoint cannot
 127        // be used to exhaust the process's memory any more than login can (Threat Model TH-03). It
 128        // is therefore governed by NFR-18 rather than NFR-05, like every password-verifying
 129        // endpoint.
 130        //
 131        // FR-AU-13: charged to the re-authentication budget first, and refused with the message
 132        // every re-authentication shares. A paused budget still has the password compared, and
 133        // refuses whatever the result.
 25134        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 135
 25136        if (string.IsNullOrWhiteSpace(command.Password) ||
 25137            !await PasswordHashGate.Shared.TextMatchesAsync(command.Password, person.PasswordHash, person.Salt) ||
 25138            !admitted)
 139        {
 11140            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 141        }
 142
 14143        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 144
 14145        var requestedAt = DateTime.UtcNow;
 14146        var dueAt = requestedAt + retention.SubjectErasureDeadline;
 147
 14148        person.ErasureRequestedAt = requestedAt;
 14149        person.ErasureDueAt = dueAt;
 14150        person.UpdatedAt = requestedAt;
 151
 14152        var restrictionLifted = LiftRestriction(person);
 153
 154        // AF-42d (NFR-12): a scope must keep an owner. Suspending the last one would leave it
 155        // ownerless, so the suspension waits — but the request and its deadline do not.
 14156        var blocked = await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person, personReader);
 157
 14158        if (blocked)
 159        {
 4160            person.ErasureBlockedReason = ErasureMessages.BlockedByLastScopeOwnership;
 161        }
 162        else
 163        {
 10164            Suspend(person, requestedAt);
 165        }
 166
 14167        var update = await personWriter.UpdateAsync(person);
 168
 14169        return !update.Success
 14170            ? output.WithErrors(update.Errors)
 14171            : Recorded(
 14172                output, person.PublicId, requestedAt, dueAt, blocked, person.ErasureBlockedReason,
 14173                restrictionLifted);
 29174    }
 175
 176    private async Task<DataOutput<RequestErasureCommandOutput?>> RequestForGoogleUserAsync(
 177        DataOutput<RequestErasureCommandOutput?> output, RequestErasureCommand command, GoogleUser googleUser)
 178    {
 179        // AF-42c.
 7180        if (googleUser.ErasureRequestedAt is not null)
 181        {
 0182            return output.WithError(ErasureMessages.ErasureAlreadyRequested);
 183        }
 184
 185        // AF-42a. A Google User has no password, so the equivalent proof of presence is a token
 186        // Google minted for them just now. Verifying the signature is not enough on its own: the
 187        // subject claim has to name this very Google User, or any valid token for any Google account
 188        // would erase whichever account the bearer token happened to name.
 7189        if (string.IsNullOrWhiteSpace(command.IdToken))
 190        {
 2191            return output.WithError(ErasureMessages.CredentialNotAccepted);
 192        }
 193
 5194        var payload = await tokenVerifier.VerifyAsync(command.IdToken);
 195
 5196        if (payload is null || payload.Subject != googleUser.GoogleId)
 197        {
 3198            return output.WithError(ErasureMessages.CredentialNotAccepted);
 199        }
 200
 2201        var requestedAt = DateTime.UtcNow;
 2202        var dueAt = requestedAt + retention.SubjectErasureDeadline;
 203
 2204        googleUser.ErasureRequestedAt = requestedAt;
 2205        googleUser.ErasureDueAt = dueAt;
 2206        googleUser.UpdatedAt = requestedAt;
 207
 2208        var restrictionLifted = LiftRestriction(googleUser);
 209
 210        // Nothing can block a Google User's erasure: FR-GO-04 makes them permanently User-equivalent,
 211        // so they own no scope and NFR-12 has nothing to protect.
 2212        Suspend(googleUser, requestedAt);
 213
 2214        var update = await googleUserWriter.UpdateAsync(googleUser);
 215
 2216        return !update.Success
 2217            ? output.WithErrors(update.Errors)
 2218            : Recorded(
 2219                output, googleUser.PublicId, requestedAt, dueAt, blocked: false, blockedReason: null,
 2220                restrictionLifted);
 7221    }
 222
 223    /// <summary>
 224    ///     Lifts the subject's restriction on processing, if they have one, exactly as UC-45 does
 225    ///     when a subject lifts their own: the restriction and its ground are cleared, and no
 226    ///     notification is recorded because none is owed.
 227    /// </summary>
 228    /// <returns>Whether there was a restriction to lift.</returns>
 229    /// <remarks>
 230    ///     Done whether or not NFR-12 blocks the suspension (AF-42d). A blocked subject is then
 231    ///     neither restricted nor suspended, which is what lets them take part in the ownership
 232    ///     transfer (UC-21) that unblocks their own request.
 233    /// </remarks>
 234    private static bool LiftRestriction(Person person)
 235    {
 14236        if (person.ProcessingRestrictedAt is null)
 237        {
 8238            return false;
 239        }
 240
 6241        person.ProcessingRestrictedAt = null;
 6242        person.RestrictionGround = null;
 6243        person.RestrictionLiftNotifiedAt = null;
 244
 6245        return true;
 246    }
 247
 248    /// <inheritdoc cref="LiftRestriction(Person)" />
 249    private static bool LiftRestriction(GoogleUser googleUser)
 250    {
 2251        if (googleUser.ProcessingRestrictedAt is null)
 252        {
 1253            return false;
 254        }
 255
 1256        googleUser.ProcessingRestrictedAt = null;
 1257        googleUser.RestrictionGround = null;
 1258        googleUser.RestrictionLiftNotifiedAt = null;
 259
 1260        return true;
 261    }
 262
 263    /// <summary>
 264    ///     Suspends the identity: the same logical deletion UC-09 and UC-28 perform, carrying the
 265    ///     kind that gives it the statutory deadline rather than the administrative window.
 266    /// </summary>
 267    /// <remarks>
 268    ///     Suspension is immediate and deliberate. A subject who has asked to be erased has
 269    ///     withdrawn the basis on which the account operates, so continuing to authenticate them
 270    ///     while the deadline runs would be processing they have objected to. `IsDeleted` already
 271    ///     stops every path: login refuses it, and `ActorLivenessFilter` refuses an unexpired token.
 272    /// </remarks>
 273    private static void Suspend(Person person, DateTime at)
 274    {
 10275        person.IsDeleted = true;
 10276        person.DeletedAt = at;
 10277        person.DeletionKind = (int)DeletionKinds.SubjectRequested;
 10278    }
 279
 280    /// <inheritdoc cref="Suspend(Person, DateTime)" />
 281    private static void Suspend(GoogleUser googleUser, DateTime at)
 282    {
 2283        googleUser.IsDeleted = true;
 2284        googleUser.DeletedAt = at;
 2285        googleUser.DeletionKind = (int)DeletionKinds.SubjectRequested;
 2286    }
 287
 288    private static DataOutput<RequestErasureCommandOutput?> Recorded(
 289        DataOutput<RequestErasureCommandOutput?> output,
 290        Guid id,
 291        DateTime requestedAt,
 292        DateTime dueAt,
 293        bool blocked,
 294        string? blockedReason,
 295        bool restrictionLifted) =>
 16296        output
 16297            .WithData(new RequestErasureCommandOutput
 16298            {
 16299                Id = id,
 16300                RequestedAt = requestedAt,
 16301                DueAt = dueAt,
 16302                Blocked = blocked,
 16303                BlockedReason = blockedReason,
 16304                RestrictionLifted = restrictionLifted
 16305            })
 16306            .WithMessage(blocked ? ErasureMessages.ErasureRequestedButBlocked : ErasureMessages.ErasureRequested);
 307}