< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.PseudonymiseAuditActorsCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/PseudonymiseAuditActorsCommandHandler.cs
Line coverage
97%
Covered lines: 69
Uncovered lines: 2
Coverable lines: 71
Total lines: 197
Line coverage: 97.1%
Branch coverage
85%
Covered branches: 12
Total branches: 14
Branch coverage: 85.7%
Method coverage
100%
Covered methods: 3
Fully covered methods: 2
Total methods: 3
Method coverage: 100%
Full method coverage: 66.6%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
HandleAsync()85.71%141496.61%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Domain.Entities;
 5using ArturRios.Heimdall.Shared.Messages;
 6using ArturRios.Heimdall.Shared.Retention;
 7using ArturRios.Mediator.Command.Interfaces;
 8using ArturRios.Output;
 9using Microsoft.EntityFrameworkCore;
 10
 11namespace ArturRios.Heimdall.Command.Handlers;
 12
 13/// <summary>
 14///     Handles <see cref="PseudonymiseAuditActorsCommand" /> (NFR-21): clears the actor attribution,
 15///     and any target naming a person or Google User, from audit entries past their attribution
 16///     period, and from entries naming an identity that has since been anonymised or hard-deleted;
 17///     then removes the tombstones of hard-deleted identities that no entry names any more. All
 18///     failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown.
 19/// </summary>
 20/// <remarks>
 21///     <para>
 22///         <b>What this resolves.</b> <c>AUDIT_LOG</c> is where two obligations pull hardest against
 23///         each other. Accountability is why the trail exists, why it is append-only, and why
 24///         <c>ActorPersonId</c> is a bare <c>PublicId</c> rather than a foreign key — so an entry
 25///         survives a hard-deleted person. Storage limitation says an indefinitely retained,
 26///         unalterable record naming an erased person is personal data processed with no remaining
 27///         basis. Clearing the attribution and keeping the entry satisfies both: what happened, when,
 28///         and how often all survive, and once the attribution is gone the entry relates to no
 29///         identifiable person and stops being personal data at all (GDPR Recital 26, LGPD Art. 12).
 30///     </para>
 31///     <para>
 32///         <b>Why targets too.</b> An entry names a person through <c>TargetId</c> as well as
 33///         through its actor. For a command somebody runs on their own account the target is their
 34///         own <c>PublicId</c>, and for an administrator's action on a person it is that person, so
 35///         clearing the actor alone left those entries resolving to a name and an address for as
 36///         long as the person's row stayed identifiable (#184). A target naming a person or Google
 37///         User — in either table, in any deletion state — is therefore cleared on the actor's two
 38///         grounds: the entry is past the attribution period, or that identity has been anonymised.
 39///         A target naming anything else (a scope, an application, a credential, a permission) is
 40///         not personal data and stays.
 41///     </para>
 42///     <para>
 43///         <b>Hard-deleted identities (#195).</b> A target whose row was hard-deleted resolves to
 44///         nobody here, but the id still names that person to anything holding a copy of the old
 45///         row — a backup, an export, a log line — and to the hard delete's own entry, written just
 46///         after the row went. Every hard delete therefore tombstones the id first
 47///         (<see cref="ErasedSubject" />). A target is also due, at any age, when its id is
 48///         tombstoned and no <c>person</c> or <c>google_user</c> row has it: the tombstone says it
 49///         was a person's, and the missing row says the delete really happened, so a tombstone left
 50///         by a delete that failed clears nothing. An id that resolves to no row and was never
 51///         tombstoned — a hard-deleted scope's, or a person's removed before tombstones existed —
 52///         stays as recorded, because nothing says it was ever a person's.
 53///     </para>
 54///     <para>
 55///         <b>Tombstones are short-lived.</b> After clearing, the run removes the tombstones that
 56///         are older than <see cref="TombstoneGracePeriod" /> and that no entry names as its target
 57///         any more. A tombstone named by an entry the batch did not reach waits for a later run.
 58///     </para>
 59///     <para>
 60///         <b>Each part only when it is due.</b> One entry may need its actor cleared, its target
 61///         cleared, or both, and the two are decided separately: a recent entry by a live
 62///         administrator about an erased person loses its target and keeps its actor. Nothing that
 63///         is not due is cleared.
 64///     </para>
 65///     <para>
 66///         <b>Why the database is the authority, not this handler.</b> The trigger installed by
 67///         <c>AllowClearingAuditTarget</c>, and widened by <c>AllowClearingErasedSubjectTarget</c>,
 68///         permits exactly two changes — the actor attribution set to NULL, and a person target set to
 69///         NULL — every other column identical, and each only once due. This handler cannot clear
 70///         either early, cannot reassign either, and cannot alter anything else, because the database
 71///         refuses all three regardless of what the code asks for. The rule lives where it cannot be
 72///         bypassed by a future caller who did not read this comment.
 73///     </para>
 74///     <para>
 75///         <b>Why an erased identity is not left to the clock.</b> A subject-requested erasure
 76///         completes thirty days after the request, and the entries it produced days earlier are
 77///         still well inside the eighteen-month attribution period. Waiting for that period would
 78///         leave an erased person named in the trail for another seventeen months, which is the
 79///         outcome the erasure was meant to end.
 80///     </para>
 81/// </remarks>
 3482public class PseudonymiseAuditActorsCommandHandler(
 3483    IAsyncReadOnlyRepository<AuditLog, long> auditReader,
 3484    IAsyncRepository<AuditLog, long> auditWriter,
 3485    IAsyncReadOnlyRepository<Person, long> personReader,
 3486    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 3487    IAsyncReadOnlyRepository<ErasedSubject, Guid> erasedSubjectReader,
 3488    IAsyncRepository<ErasedSubject, Guid> erasedSubjectWriter,
 3489    DataRetentionOptions retention)
 90    : ICommandHandlerAsync<PseudonymiseAuditActorsCommand, PseudonymiseAuditActorsCommandOutput>
 91{
 92    /// <summary>
 93    ///     How long a tombstone is kept even when no audit entry names it: one day.
 94    /// </summary>
 95    /// <remarks>
 96    ///     The hard delete's own audit entry, which names the removed id as its target, is written by
 97    ///     the auditing decorator after the handler returns, so for a moment the tombstone exists and
 98    ///     nothing names it yet. A run in that moment must not remove it, or that entry would keep the
 99    ///     id for good. A day is far longer than that moment and short enough that a tombstone is not
 100    ///     a lasting record of the person in its own right.
 101    /// </remarks>
 2102    public static readonly TimeSpan TombstoneGracePeriod = TimeSpan.FromDays(1);
 103
 104    public async Task<DataOutput<PseudonymiseAuditActorsCommandOutput?>> HandleAsync(
 105        PseudonymiseAuditActorsCommand command, CancellationToken cancellationToken = default)
 106    {
 37107        var output = DataOutput<PseudonymiseAuditActorsCommandOutput?>.New;
 37108        var cutoff = DateTime.UtcNow - retention.AuditActorRetention;
 37109        var persons = personReader.Query();
 37110        var googleUsers = googleUserReader.Query();
 37111        var tombstones = erasedSubjectReader.Query();
 112
 113        // One query over the union of both grounds, rather than one per part, so a single batch
 114        // bound covers the run, an entry needing both is selected once, and each row carries the
 115        // reason it was selected. The flags are what the loop below acts on, so a row selected for
 116        // its target cannot have its actor cleared with it, nor the reverse.
 37117        var due = await auditReader.Query()
 37118            .Select(entry => new
 37119            {
 37120                Entry = entry,
 37121                ActorDue = entry.ActorPersonId != null
 37122                           && (entry.CreatedAt <= cutoff
 37123                               || persons.Any(person =>
 37124                                   person.PublicId == entry.ActorPersonId && person.AnonymisedAt != null)
 37125                               || googleUsers.Any(googleUser =>
 37126                                   googleUser.PublicId == entry.ActorPersonId && googleUser.AnonymisedAt != null)),
 37127                TargetDue = entry.TargetId != null
 37128                            && (persons.Any(person =>
 37129                                    person.PublicId == entry.TargetId
 37130                                    && (entry.CreatedAt <= cutoff || person.AnonymisedAt != null))
 37131                                || googleUsers.Any(googleUser =>
 37132                                    googleUser.PublicId == entry.TargetId
 37133                                    && (entry.CreatedAt <= cutoff || googleUser.AnonymisedAt != null))
 37134                                || (tombstones.Any(tombstone => tombstone.Id == entry.TargetId)
 37135                                    && !persons.Any(person => person.PublicId == entry.TargetId)
 37136                                    && !googleUsers.Any(googleUser => googleUser.PublicId == entry.TargetId)))
 37137            })
 37138            .Where(row => row.ActorDue || row.TargetDue)
 37139            .OrderBy(row => row.Entry.CreatedAt)
 37140            .Take(retention.PurgeBatchSize)
 37141            .ToListAsync();
 142
 37143        if (due.Count > 0)
 144        {
 110145            foreach (var row in due)
 146            {
 31147                if (row.ActorDue)
 148                {
 16149                    row.Entry.ActorPersonId = null;
 16150                    row.Entry.ActorRole = null;
 151                }
 152
 31153                if (row.TargetDue)
 154                {
 16155                    row.Entry.TargetId = null;
 156                }
 157            }
 158
 55159            var update = await auditWriter.UpdateRangeAsync(due.Select(row => row.Entry).ToList());
 160
 24161            if (!update.Success)
 162            {
 0163                return output.WithErrors(update.Errors);
 164            }
 165        }
 166
 167        // After the clearing, so a tombstone whose last entry this run cleared goes in the same run.
 37168        var settledBefore = DateTime.UtcNow - TombstoneGracePeriod;
 37169        var entries = auditReader.Query();
 37170        var settled = await tombstones
 37171            .Where(tombstone => tombstone.CreatedAt <= settledBefore
 37172                                && !entries.Any(entry => entry.TargetId == tombstone.Id))
 37173            .OrderBy(tombstone => tombstone.CreatedAt)
 37174            .Select(tombstone => tombstone.Id)
 37175            .Take(retention.PurgeBatchSize)
 37176            .ToListAsync();
 177
 37178        if (settled.Count > 0)
 179        {
 6180            var removal = await erasedSubjectWriter.DeleteRangeAsync(settled);
 181
 6182            if (!removal.Success)
 183            {
 0184                return output.WithErrors(removal.Errors);
 185            }
 186        }
 187
 37188        return output
 37189            .WithData(new PseudonymiseAuditActorsCommandOutput
 37190            {
 31191                EntriesPseudonymised = due.Count(row => row.ActorDue),
 31192                TargetsCleared = due.Count(row => row.TargetDue),
 37193                TombstonesRemoved = settled.Count
 37194            })
 37195            .WithMessage(RetentionMessages.AuditActorsPseudonymised);
 37196    }
 197}