< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.RequestErasureCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/RequestErasureCommandHandler.cs
Line coverage
96%
Covered lines: 92
Uncovered lines: 3
Coverable lines: 95
Total lines: 292
Line coverage: 96.8%
Branch coverage
88%
Covered branches: 46
Total branches: 52
Branch coverage: 88.4%
Method coverage
100%
Covered methods: 5
Fully covered methods: 2
Total methods: 5
Method coverage: 100%
Full method coverage: 40%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()92.85%1414100%
RequestForPersonAsync()90%202097.14%
RequestForGoogleUserAsync()81.25%161695.83%
Recorded(...)100%22100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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.Persistence;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Heimdall.Shared.Retention;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using FluentValidation;
 12using Microsoft.EntityFrameworkCore;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="RequestErasureCommand" /> (UC-42): re-authenticates the caller, records
 18///     their erasure request against their own record, lifts any restriction on processing they
 19///     had in place, and suspends the identity so the request takes effect immediately. The
 20///     anonymisation pass (NFR-20) completes it on the deadline. All failures are returned as
 21///     errors on the <see cref="DataOutput{T}" /> rather than thrown.
 22/// </summary>
 23/// <remarks>
 24///     <para>
 25///         <b>Why the subject is never named in the request.</b> The identity acted on is the one
 26///         the token names, always. There is no path parameter and no body field for it, so the
 27///         endpoint has no shape in which one identity could ask for another's erasure — a rule
 28///         enforced by the absence of the parameter rather than by a check that could be got wrong.
 29///     </para>
 30///     <para>
 31///         <b>Why re-authentication.</b> Erasure is irreversible, and a bearer token is not proof
 32///         that the person is present — one left open on a shared machine, or lifted from a log,
 33///         would otherwise be enough to destroy the account it belongs to. A person presents their
 34///         password; a Google User presents a fresh Google ID token, because they have no password.
 35///         A person's password is charged to their re-authentication budget (FR-AU-13) and refused
 36///         with the message every re-authentication shares; a Google User's token, which cannot be
 37///         guessed, is refused with <see cref="ErasureMessages.CredentialNotAccepted" />. Both are 401.
 38///     </para>
 39///     <para>
 40///         <b>Why the request is recorded before it can be carried out.</b> NFR-12 refuses to
 41///         suspend the last owner of a scope, and clearing that needs a human to transfer ownership
 42///         (UC-21). Refusing the request outright would be the wrong answer twice over: the subject
 43///         has exercised a right that does not depend on the scope's ownership arrangements, and
 44///         GDPR Art. 12(3)'s clock starts at the request whether or not anything blocks it. So the
 45///         request is always recorded and always starts the deadline; the block is recorded
 46///         alongside it, and the anonymisation pass carries the erasure out as soon as it clears.
 47///     </para>
 48///     <para>
 49///         <b>Why the request lifts a restriction.</b> A restricted record is kept out of the
 50///         anonymisation pass (NFR-24), so an erasure requested under a restriction would wait,
 51///         unerased, for as long as the restriction stood — and the statutory deadline would pass
 52///         meanwhile. Only the subject can impose a restriction (UC-44), and only the subject can
 53///         get here, credential in hand: asking to be erased is their later and more specific
 54///         instruction, and it supersedes their wish that the record be preserved. The lift is the
 55///         one UC-45 performs for a subject lifting their own — no Art. 18(3) notice, since they
 56///         are the person it would inform — and it is saved with the request, so neither can
 57///         happen without the other. The response and the audit trail both say it happened.
 58///     </para>
 59///     <para>
 60///         <b>Why the deadline is stored rather than derived.</b> The obligation attaches when the
 61///         request is made. Recomputing it from the current configuration on every read would let a
 62///         later change move an obligation that had already attached.
 63///     </para>
 64///     <para>
 65///         <b>Why the last System Admin is blocked too</b> (#203). NFR-32 keeps an active System Admin, and
 66///         suspending the last one would leave nobody able to create a scope, change a role or run the
 67///         retention tooling. The request is recorded and blocked exactly as the last owner's is, and the
 68///         anonymisation pass carries it out once another active System Admin exists.
 69///     </para>
 70///     <para>
 71///         <b>Why the block is decided under a lock</b> (#202, #203). The guards and the write they decide
 72///         run in one transaction holding the rows of the scopes the person owns and the System Admin set
 73///         (<see cref="IInvariantLocks" />), on the person as read under them (<see cref="LockedPerson" />),
 74///         as every writer that can take an owner or a System Admin out does. Otherwise two co-owners, or two administr
 75///         each see the other still there, and both be suspended. The password is checked before the
 76///         transaction opens, so no lock waits on Argon2.
 77///     </para>
 78/// </remarks>
 5179public class RequestErasureCommandHandler(
 5180    IValidator<RequestErasureCommand> validator,
 5181    IAsyncReadOnlyRepository<Person, long> personReader,
 5182    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 5183    IGoogleIdTokenVerifier tokenVerifier,
 5184    DataRetentionOptions retention,
 5185    IAtomicWrites atomicWrites,
 5186    IWriteTransactions transactions,
 5187    IInvariantLocks locks)
 88    : ICommandHandlerAsync<RequestErasureCommand, RequestErasureCommandOutput>
 89{
 90    public async Task<DataOutput<RequestErasureCommandOutput?>> HandleAsync(RequestErasureCommand command, CancellationT
 91    {
 5192        var output = DataOutput<RequestErasureCommandOutput?>.New;
 93
 94        // NFR-10: validate input shape.
 5195        var validation = await validator.ValidateAsync(command);
 96
 5197        if (!validation.IsValid)
 98        {
 099            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 100        }
 101
 102        // The person table first, then the Google User table — the same order UC-36 uses, and for
 103        // the same reason: the two identity tables are never joined, and a PublicId belongs to one
 104        // of them.
 105        //
 106        // The lookups include logically deleted identities so that a subject whose first request
 107        // suspended them is told AF-42c — the request they are repeating exists — rather than the
 108        // generic AF-42b. The endpoint admits such a caller because it is a data subject right; any
 109        // other deleted identity is refused with AF-42b once AF-42c has been ruled out.
 51110        var person = await personReader.Query()
 51111            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 112
 51113        if (person is not null)
 114        {
 39115            return person.ErasureRequestedAt is null && person.IsDeleted
 39116                ? output.WithError(ErasureMessages.NotEligible)
 39117                : await RequestForPersonAsync(output, command, person);
 118        }
 119
 12120        var googleUser = await googleUserReader.Query()
 12121            .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId);
 122
 12123        return googleUser is null || (googleUser.ErasureRequestedAt is null && googleUser.IsDeleted)
 12124            ? output.WithError(ErasureMessages.NotEligible)
 12125            : await RequestForGoogleUserAsync(output, command, googleUser);
 51126    }
 127
 128    private async Task<DataOutput<RequestErasureCommandOutput?>> RequestForPersonAsync(
 129        DataOutput<RequestErasureCommandOutput?> output, RequestErasureCommand command, Person person)
 130    {
 131        // AF-42c. Checked before the credential so a repeat request does not cost an Argon2id
 132        // derivation, and so the answer does not depend on getting the password right a second time.
 37133        if (person.ErasureRequestedAt is not null)
 134        {
 4135            return output.WithError(ErasureMessages.ErasureAlreadyRequested);
 136        }
 137
 138        // AF-42a. The same derivation UC-11 performs, through the same gate, so this endpoint cannot
 139        // be used to exhaust the process's memory any more than login can (Threat Model TH-03). It
 140        // is therefore governed by NFR-18 rather than NFR-05, like every password-verifying
 141        // endpoint.
 142        //
 143        // FR-AU-13: charged to the re-authentication budget first, and refused with the message
 144        // every re-authentication shares. A paused budget still has the password compared, and
 145        // refuses whatever the result.
 33146        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 147
 33148        if (string.IsNullOrWhiteSpace(command.Password) ||
 33149            !await PasswordHashGate.Shared.TextMatchesAsync(command.Password, person.PasswordHash, person.Salt) ||
 33150            !admitted)
 151        {
 12152            return output.WithError(AuthMessages.PasswordOrCodeIncorrect);
 153        }
 154
 21155        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 156
 21157        var requestedAt = DateTime.UtcNow;
 21158        var dueAt = requestedAt + retention.SubjectErasureDeadline;
 159
 21160        var restrictionLifted = person.ProcessingRestrictedAt is not null;
 161
 162        // The decision and the write under the locks of the person's scopes (#202): leaving this block
 163        // before the commit rolls back.
 21164        await using var transaction = await transactions.BeginAsync();
 165
 166        // The person as they stand under the locks (#202, #203): the guards decide on this.
 21167        var locked = await LockedPerson.LockAsync(locks, personReader, person.Id);
 168
 21169        if (locked is null)
 170        {
 0171            return output.WithError(ErasureMessages.NotEligible);
 172        }
 173
 174        // AF-42d (NFR-12): a scope must keep an owner. Suspending the last one would leave it
 175        // ownerless, so the suspension waits — but the request and its deadline do not. AF-42e
 176        // (NFR-32): likewise the last active System Admin.
 21177        var blockedReason =
 21178            await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person.Id, locked.OwnedScopeIds, personReader)
 21179                ? ErasureMessages.BlockedByLastScopeOwnership
 21180                : locked.IsLiveSystemAdmin && await LastSystemAdminGuard.WouldLeaveNoneAsync(person.Id, personReader)
 21181                    ? ErasureMessages.BlockedByLastSystemAdmin
 21182                    : null;
 21183        var blocked = blockedReason is not null;
 184
 185        // The request, the lifted restriction, and the suspension or the reason it waits, as one
 186        // targeted write of those columns alone (#237). The restriction is lifted as UC-45 lifts a
 187        // subject's own, whether or not NFR-12 blocks the suspension (AF-42d): a blocked subject is then
 188        // neither restricted nor suspended, which lets them take part in the ownership transfer (UC-21)
 189        // that unblocks their own request. Made only while no request is recorded and the person is
 190        // live. A request recorded since the read refuses this one as AF-42c, and a deletion as AF-42b,
 191        // as either would have a moment earlier; the row is read again, past the tracked copy, to tell
 192        // which.
 21193        if (!await atomicWrites.TryRecordErasureRequestAsync(person, requestedAt, dueAt, blockedReason))
 194        {
 1195            var requestedSince = await personReader.Query()
 1196                .Where(x => x.Id == person.Id)
 1197                .Select(x => x.ErasureRequestedAt)
 1198                .FirstOrDefaultAsync();
 199
 1200            return output.WithError(requestedSince is not null
 1201                ? ErasureMessages.ErasureAlreadyRequested
 1202                : ErasureMessages.NotEligible);
 203        }
 204
 20205        await transaction.CommitAsync();
 206
 20207        return Recorded(
 20208            output, person.PublicId, requestedAt, dueAt, blocked, person.ErasureBlockedReason,
 20209            restrictionLifted);
 37210    }
 211
 212    private async Task<DataOutput<RequestErasureCommandOutput?>> RequestForGoogleUserAsync(
 213        DataOutput<RequestErasureCommandOutput?> output, RequestErasureCommand command, GoogleUser googleUser)
 214    {
 215        // AF-42c.
 11216        if (googleUser.ErasureRequestedAt is not null)
 217        {
 0218            return output.WithError(ErasureMessages.ErasureAlreadyRequested);
 219        }
 220
 221        // AF-42a. A Google User has no password, so the equivalent proof of presence is a token
 222        // Google minted for them just now. Verifying the signature is not enough on its own: the
 223        // subject claim has to name this very Google User, or any valid token for any Google account
 224        // would erase whichever account the bearer token happened to name.
 11225        if (string.IsNullOrWhiteSpace(command.IdToken))
 226        {
 2227            return output.WithError(ErasureMessages.CredentialNotAccepted);
 228        }
 229
 9230        var payload = await tokenVerifier.VerifyAsync(command.IdToken);
 231
 9232        if (payload is null || payload.Subject != googleUser.GoogleId)
 233        {
 3234            return output.WithError(ErasureMessages.CredentialNotAccepted);
 235        }
 236
 237        // FR-GO-21: the token is a proof of presence only once. A token already used — to sign in, or
 238        // to ask for this before — is refused as an invalid one is, before anything is written.
 6239        if (!await atomicWrites.TryRecordGoogleIdTokenUseAsync(
 6240                GoogleIdTokenUse.KeyOf(payload.TokenId, command.IdToken), payload.ExpiresAt ?? DateTime.UtcNow))
 241        {
 2242            return output.WithError(ErasureMessages.CredentialNotAccepted);
 243        }
 244
 4245        var requestedAt = DateTime.UtcNow;
 4246        var dueAt = requestedAt + retention.SubjectErasureDeadline;
 4247        var restrictionLifted = googleUser.ProcessingRestrictedAt is not null;
 248
 249        // The request, the lifted restriction — as UC-45 lifts a subject's own, with no notice owed —
 250        // and the suspension, as one targeted write of those columns alone (#205), made only while no
 251        // request is recorded and the Google User is live. Nothing can block a Google User's erasure:
 252        // FR-GO-04 makes them permanently User-equivalent, so they own no scope and NFR-12 has nothing
 253        // to protect. The suspension is immediate and deliberate: a subject who has asked to be erased
 254        // has withdrawn the basis the account operates on, and IsDeleted stops every path — sign-in
 255        // refuses it, and ActorLivenessFilter refuses an unexpired token. A request recorded since the
 256        // read refuses this one as AF-42c, and a deletion as AF-42b; the row is read again to tell which.
 4257        if (!await atomicWrites.TryRecordErasureRequestAsync(googleUser, requestedAt, dueAt))
 258        {
 1259            var requestedSince = await googleUserReader.Query()
 1260                .Where(x => x.Id == googleUser.Id)
 1261                .Select(x => x.ErasureRequestedAt)
 1262                .FirstOrDefaultAsync();
 263
 1264            return output.WithError(requestedSince is not null
 1265                ? ErasureMessages.ErasureAlreadyRequested
 1266                : ErasureMessages.NotEligible);
 267        }
 268
 3269        return Recorded(
 3270            output, googleUser.PublicId, requestedAt, dueAt, blocked: false, blockedReason: null, restrictionLifted);
 11271    }
 272
 273    private static DataOutput<RequestErasureCommandOutput?> Recorded(
 274        DataOutput<RequestErasureCommandOutput?> output,
 275        Guid id,
 276        DateTime requestedAt,
 277        DateTime dueAt,
 278        bool blocked,
 279        string? blockedReason,
 280        bool restrictionLifted) =>
 23281        output
 23282            .WithData(new RequestErasureCommandOutput
 23283            {
 23284                Id = id,
 23285                RequestedAt = requestedAt,
 23286                DueAt = dueAt,
 23287                Blocked = blocked,
 23288                BlockedReason = blockedReason,
 23289                RestrictionLifted = restrictionLifted
 23290            })
 23291            .WithMessage(blocked ? ErasureMessages.ErasureRequestedButBlocked : ErasureMessages.ErasureRequested);
 292}