< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.PseudonymiseAuditActorsCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/PseudonymiseAuditActorsCommandHandler.cs
Line coverage
99%
Covered lines: 140
Uncovered lines: 1
Coverable lines: 141
Total lines: 386
Line coverage: 99.2%
Branch coverage
94%
Covered branches: 36
Total branches: 38
Branch coverage: 94.7%
Method coverage
100%
Covered methods: 10
Fully covered methods: 9
Total methods: 10
Method coverage: 100%
Full method coverage: 90%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
HandleAsync()92.3%262697.72%
SelectDueAsync()100%88100%
CountDueAsync()100%11100%
DueGroundsAsync()100%44100%
Settled(...)100%11100%
get_Entry()100%11100%
get_ActorDue()100%11100%
get_TargetDue()100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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>
 4282public class PseudonymiseAuditActorsCommandHandler(
 4283    IAsyncReadOnlyRepository<AuditLog, long> auditReader,
 4284    IAsyncRepository<AuditLog, long> auditWriter,
 4285    IAsyncReadOnlyRepository<Person, long> personReader,
 4286    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 4287    IAsyncReadOnlyRepository<ErasedSubject, Guid> erasedSubjectReader,
 4288    IAsyncRepository<ErasedSubject, Guid> erasedSubjectWriter,
 4289    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    {
 46107        var output = DataOutput<PseudonymiseAuditActorsCommandOutput?>.New;
 46108        var cutoff = DateTime.UtcNow - retention.AuditActorRetention;
 46109        var result = new PseudonymiseAuditActorsCommandOutput();
 110
 111        // A batch at a time, repeated while a batch comes back full (#206): one batch a run held the
 112        // trail to 12,000 clearings a day at the defaults, which a busy deployment outgrows for good.
 46113        var entriesFull = false;
 114
 136115        for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++)
 116        {
 62117            var due = await SelectDueAsync(cutoff);
 118
 62119            if (due.Count == 0)
 120            {
 17121                entriesFull = false;
 17122                break;
 123            }
 124
 262125            foreach (var row in due)
 126            {
 86127                if (row.ActorDue)
 128                {
 53129                    row.Entry.ActorPersonId = null;
 53130                    row.Entry.ActorRole = null;
 131                }
 132
 86133                if (row.TargetDue)
 134                {
 46135                    row.Entry.TargetId = null;
 136                }
 137            }
 138
 131139            var update = await auditWriter.UpdateRangeAsync(due.Select(row => row.Entry).ToList());
 140
 45141            if (!update.Success)
 142            {
 143                // What earlier batches cleared stays cleared, and is reported with the failure.
 1144                return output.WithData(result).WithErrors(update.Errors);
 145            }
 146
 128147            result.EntriesPseudonymised += due.Count(row => row.ActorDue);
 128148            result.TargetsCleared += due.Count(row => row.TargetDue);
 149
 44150            entriesFull = due.Count == retention.PurgeBatchSize;
 151
 44152            if (!entriesFull)
 153            {
 154                break;
 155            }
 22156        }
 157
 158        // After the clearing, so a tombstone whose last entry this run cleared goes in the same run.
 45159        var settledBefore = DateTime.UtcNow - TombstoneGracePeriod;
 45160        var tombstonesFull = false;
 161
 92162        for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++)
 163        {
 46164            var settled = await Settled(settledBefore)
 46165                .OrderBy(tombstone => tombstone.CreatedAt)
 46166                .Select(tombstone => tombstone.Id)
 46167                .Take(retention.PurgeBatchSize)
 46168                .ToListAsync();
 169
 46170            if (settled.Count == 0)
 171            {
 39172                tombstonesFull = false;
 39173                break;
 174            }
 175
 7176            var removal = await erasedSubjectWriter.DeleteRangeAsync(settled);
 177
 7178            if (!removal.Success)
 179            {
 0180                return output.WithData(result).WithErrors(removal.Errors);
 181            }
 182
 7183            result.TombstonesRemoved += settled.Count;
 7184            tombstonesFull = settled.Count == retention.PurgeBatchSize;
 185
 7186            if (!tombstonesFull)
 187            {
 188                break;
 189            }
 1190        }
 191
 192        // Counted only where the run stopped on a full batch: one that was not full took all that was due.
 45193        result.Backlog = (entriesFull ? await CountDueAsync(cutoff) : 0)
 45194                         + (tombstonesFull ? await Settled(settledBefore).CountAsync() : 0);
 195
 45196        return output
 45197            .WithData(result)
 45198            .WithMessage(RetentionMessages.AuditActorsPseudonymised);
 46199    }
 200
 201    /// <summary>One batch of the entries due, oldest first, each with the parts of it that are due.</summary>
 202    /// <remarks>
 203    ///     <para>
 204    ///         <b>Simple queries, not one (#206).</b> The single query this replaced evaluated up to
 205    ///         four correlated subqueries for every attributed entry in the attribution window, in
 206    ///         <c>created_at</c> order, so a run with nothing due still read the whole window, on every
 207    ///         instance, every run. Each ground is now its own query that projects ids rather than
 208    ///         entities, and each is served by an index. The two grounds of age read a partial index on
 209    ///         <c>created_at</c> over the entries that still have an actor, or a target
 210    ///         (<c>AddAuditRetentionIndexes</c>), from the oldest up to the cutoff: an actor past the period is
 211    ///         always cleared, so that range holds only the backlog; a target past the period stays when it
 212    ///         names a scope or anything else that is not a person, so the target range also holds those,
 213    ///         each checked against the identities by their unique index. The grounds of identity are
 214    ///         reached from the anonymised identities and the tombstones, through the indexes on
 215    ///         <c>actor_person_id</c> and <c>target_id</c>, so their cost follows the identities rather than
 216    ///         the trail (see <see cref="DueGroundsAsync" />).
 217    ///     </para>
 218    ///     <para>
 219    ///         <b>One batch bound still covers the run.</b> Each query takes its own oldest batch, by
 220    ///         <c>created_at</c> and then id; the batch is the oldest of their union. An entry in it is
 221    ///         necessarily in the batch of every ground it is due on — nothing older than it is due on that
 222    ///         ground than is due at all — so the grounds a selected entry was returned by are exactly the
 223    ///         parts of it that are due. That is what the clearing acts on, so a row selected for its target
 224    ///         cannot have its actor cleared with it, nor the reverse, and an entry due on both grounds is
 225    ///         selected, written and counted once, as before.
 226    ///     </para>
 227    /// </remarks>
 228    private async Task<List<DueEntry>> SelectDueAsync(DateTime cutoff)
 229    {
 62230        var size = retention.PurgeBatchSize;
 62231        var grounds = await DueGroundsAsync(cutoff);
 232
 62233        var actorDue = new HashSet<long>();
 62234        var targetDue = new HashSet<long>();
 62235        var candidates = new Dictionary<long, DateTime>();
 236
 438237        foreach (var (ground, isActor) in grounds)
 238        {
 157239            var batch = await ground
 157240                .OrderBy(entry => entry.CreatedAt)
 157241                .ThenBy(entry => entry.Id)
 157242                .Select(entry => new { entry.Id, entry.CreatedAt })
 157243                .Take(size)
 157244                .ToListAsync();
 245
 554246            foreach (var row in batch)
 247            {
 120248                candidates[row.Id] = row.CreatedAt;
 120249                (isActor ? actorDue : targetDue).Add(row.Id);
 250            }
 251        }
 252
 62253        var ids = candidates
 104254            .OrderBy(candidate => candidate.Value)
 104255            .ThenBy(candidate => candidate.Key)
 62256            .Take(size)
 86257            .Select(candidate => candidate.Key)
 62258            .ToList();
 259
 62260        if (ids.Count == 0)
 261        {
 17262            return [];
 263        }
 264
 265        // By primary key: the batch is already chosen, and these are the rows the write needs.
 45266        var entries = await auditReader.Query()
 45267            .Where(entry => ids.Contains(entry.Id))
 45268            .ToListAsync();
 269
 45270        return entries
 86271            .OrderBy(entry => entry.CreatedAt)
 86272            .ThenBy(entry => entry.Id)
 86273            .Select(entry => new DueEntry
 86274            {
 86275                Entry = entry,
 86276                ActorDue = actorDue.Contains(entry.Id),
 86277                TargetDue = targetDue.Contains(entry.Id)
 86278            })
 45279            .ToList();
 62280    }
 281
 282    /// <summary>How many entries are still due: the backlog a run that stopped at its cap leaves.</summary>
 283    private async Task<int> CountDueAsync(DateTime cutoff)
 284    {
 6285        var due = (await DueGroundsAsync(cutoff))
 14286            .Select(ground => ground.Ground.Select(entry => entry.Id))
 14287            .Aggregate((all, next) => all.Union(next));
 288
 6289        return await due.CountAsync();
 6290    }
 291
 292    /// <summary>
 293    ///     The grounds on which an entry is due, each as a query over the entries due on it, and whether it
 294    ///     clears the actor (otherwise the target). An entry is due when it is due on any of them.
 295    /// </summary>
 296    /// <remarks>
 297    ///     <para>
 298    ///         Together these are exactly the rule the trigger enforces. The actor is due past the
 299    ///         attribution period, or once the identity it names is anonymised. A target is due when it names
 300    ///         a person or Google User and the entry is past the period, or when that identity is anonymised,
 301    ///         or — at any age — when its id was tombstoned by a hard delete and no <c>person</c> or
 302    ///         <c>google_user</c> row has it any more (#195).
 303    ///     </para>
 304    ///     <para>
 305    ///         The grounds of identity are asked in two steps. First, which anonymised or hard-deleted
 306    ///         identities the trail still names, asked from the identities' side: one probe of the index on
 307    ///         <c>actor_person_id</c> or <c>target_id</c> each, so that when nothing is due — the usual case —
 308    ///         the cost is the identities, not the trail. Then the entries naming those, by id. Asked in one
 309    ///         step, as entries whose actor is in a subquery, PostgreSQL may walk <c>created_at</c> in order,
 310    ///         or scan the table, expecting to meet such entries early: measured, it scanned the whole trail
 311    ///         when nothing was due.
 312    ///     </para>
 313    /// </remarks>
 314    private async Task<List<(IQueryable<AuditLog> Ground, bool IsActor)>> DueGroundsAsync(DateTime cutoff)
 315    {
 68316        var entries = auditReader.Query();
 68317        var persons = personReader.Query();
 68318        var googleUsers = googleUserReader.Query();
 319
 68320        var identities = persons.Select(person => person.PublicId)
 68321            .Concat(googleUsers.Select(googleUser => googleUser.PublicId));
 322
 68323        var anonymised = persons.Where(person => person.AnonymisedAt != null).Select(person => person.PublicId)
 68324            .Concat(googleUsers.Where(googleUser => googleUser.AnonymisedAt != null)
 68325                .Select(googleUser => googleUser.PublicId));
 326
 68327        var hardDeleted = erasedSubjectReader.Query()
 68328            .Where(tombstone => !persons.Any(person => person.PublicId == tombstone.Id)
 68329                                && !googleUsers.Any(googleUser => googleUser.PublicId == tombstone.Id))
 68330            .Select(tombstone => tombstone.Id);
 331
 68332        var namedAsActor = await anonymised
 68333            .Where(id => entries.Any(entry => entry.ActorPersonId == id))
 68334            .ToListAsync();
 335
 68336        var namedAsTarget = await anonymised.Concat(hardDeleted)
 68337            .Where(id => entries.Any(entry => entry.TargetId == id))
 68338            .ToListAsync();
 339
 68340        List<(IQueryable<AuditLog> Ground, bool IsActor)> grounds =
 68341        [
 68342            // The actor, past the period: the partial index on created_at over attributed entries.
 68343            (entries.Where(entry => entry.ActorPersonId != null && entry.CreatedAt <= cutoff), true),
 68344
 68345            // A person target, past the period: the partial index on created_at over entries with a target.
 68346            (entries.Where(entry => entry.TargetId != null
 68347                                    && entry.CreatedAt <= cutoff
 68348                                    && identities.Contains(entry.TargetId.Value)), false)
 68349        ];
 350
 351        // The actor or a target anonymised, or a target hard-deleted: the entries naming those still named.
 68352        if (namedAsActor.Count > 0)
 353        {
 17354            grounds.Add((entries.Where(entry =>
 17355                entry.ActorPersonId != null && namedAsActor.Contains(entry.ActorPersonId.Value)), true));
 356        }
 357
 68358        if (namedAsTarget.Count > 0)
 359        {
 18360            grounds.Add((entries.Where(entry =>
 18361                entry.TargetId != null && namedAsTarget.Contains(entry.TargetId.Value)), false));
 362        }
 363
 68364        return grounds;
 68365    }
 366
 367    /// <summary>The tombstones past their grace that no audit entry names as its target any more.</summary>
 368    private IQueryable<ErasedSubject> Settled(DateTime settledBefore)
 369    {
 46370        var entries = auditReader.Query();
 371
 46372        return erasedSubjectReader.Query()
 46373            .Where(tombstone => tombstone.CreatedAt <= settledBefore
 46374                                && !entries.Any(entry => entry.TargetId == tombstone.Id));
 375    }
 376
 377    /// <summary>An entry due for clearing, and which of its two parts are due.</summary>
 378    private sealed class DueEntry
 379    {
 324380        public AuditLog Entry { get; init; } = null!;
 381
 256382        public bool ActorDue { get; init; }
 383
 256384        public bool TargetDue { get; init; }
 385    }
 386}