| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 5 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 6 | | using ArturRios.Heimdall.Shared.Retention; |
| | | 7 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 8 | | using ArturRios.Output; |
| | | 9 | | using Microsoft.EntityFrameworkCore; |
| | | 10 | | |
| | | 11 | | namespace 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> |
| | 34 | 82 | | public class PseudonymiseAuditActorsCommandHandler( |
| | 34 | 83 | | IAsyncReadOnlyRepository<AuditLog, long> auditReader, |
| | 34 | 84 | | IAsyncRepository<AuditLog, long> auditWriter, |
| | 34 | 85 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 34 | 86 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 34 | 87 | | IAsyncReadOnlyRepository<ErasedSubject, Guid> erasedSubjectReader, |
| | 34 | 88 | | IAsyncRepository<ErasedSubject, Guid> erasedSubjectWriter, |
| | 34 | 89 | | 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> |
| | 2 | 102 | | public static readonly TimeSpan TombstoneGracePeriod = TimeSpan.FromDays(1); |
| | | 103 | | |
| | | 104 | | public async Task<DataOutput<PseudonymiseAuditActorsCommandOutput?>> HandleAsync( |
| | | 105 | | PseudonymiseAuditActorsCommand command, CancellationToken cancellationToken = default) |
| | | 106 | | { |
| | 37 | 107 | | var output = DataOutput<PseudonymiseAuditActorsCommandOutput?>.New; |
| | 37 | 108 | | var cutoff = DateTime.UtcNow - retention.AuditActorRetention; |
| | 37 | 109 | | var persons = personReader.Query(); |
| | 37 | 110 | | var googleUsers = googleUserReader.Query(); |
| | 37 | 111 | | 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. |
| | 37 | 117 | | var due = await auditReader.Query() |
| | 37 | 118 | | .Select(entry => new |
| | 37 | 119 | | { |
| | 37 | 120 | | Entry = entry, |
| | 37 | 121 | | ActorDue = entry.ActorPersonId != null |
| | 37 | 122 | | && (entry.CreatedAt <= cutoff |
| | 37 | 123 | | || persons.Any(person => |
| | 37 | 124 | | person.PublicId == entry.ActorPersonId && person.AnonymisedAt != null) |
| | 37 | 125 | | || googleUsers.Any(googleUser => |
| | 37 | 126 | | googleUser.PublicId == entry.ActorPersonId && googleUser.AnonymisedAt != null)), |
| | 37 | 127 | | TargetDue = entry.TargetId != null |
| | 37 | 128 | | && (persons.Any(person => |
| | 37 | 129 | | person.PublicId == entry.TargetId |
| | 37 | 130 | | && (entry.CreatedAt <= cutoff || person.AnonymisedAt != null)) |
| | 37 | 131 | | || googleUsers.Any(googleUser => |
| | 37 | 132 | | googleUser.PublicId == entry.TargetId |
| | 37 | 133 | | && (entry.CreatedAt <= cutoff || googleUser.AnonymisedAt != null)) |
| | 37 | 134 | | || (tombstones.Any(tombstone => tombstone.Id == entry.TargetId) |
| | 37 | 135 | | && !persons.Any(person => person.PublicId == entry.TargetId) |
| | 37 | 136 | | && !googleUsers.Any(googleUser => googleUser.PublicId == entry.TargetId))) |
| | 37 | 137 | | }) |
| | 37 | 138 | | .Where(row => row.ActorDue || row.TargetDue) |
| | 37 | 139 | | .OrderBy(row => row.Entry.CreatedAt) |
| | 37 | 140 | | .Take(retention.PurgeBatchSize) |
| | 37 | 141 | | .ToListAsync(); |
| | | 142 | | |
| | 37 | 143 | | if (due.Count > 0) |
| | | 144 | | { |
| | 110 | 145 | | foreach (var row in due) |
| | | 146 | | { |
| | 31 | 147 | | if (row.ActorDue) |
| | | 148 | | { |
| | 16 | 149 | | row.Entry.ActorPersonId = null; |
| | 16 | 150 | | row.Entry.ActorRole = null; |
| | | 151 | | } |
| | | 152 | | |
| | 31 | 153 | | if (row.TargetDue) |
| | | 154 | | { |
| | 16 | 155 | | row.Entry.TargetId = null; |
| | | 156 | | } |
| | | 157 | | } |
| | | 158 | | |
| | 55 | 159 | | var update = await auditWriter.UpdateRangeAsync(due.Select(row => row.Entry).ToList()); |
| | | 160 | | |
| | 24 | 161 | | if (!update.Success) |
| | | 162 | | { |
| | 0 | 163 | | 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. |
| | 37 | 168 | | var settledBefore = DateTime.UtcNow - TombstoneGracePeriod; |
| | 37 | 169 | | var entries = auditReader.Query(); |
| | 37 | 170 | | var settled = await tombstones |
| | 37 | 171 | | .Where(tombstone => tombstone.CreatedAt <= settledBefore |
| | 37 | 172 | | && !entries.Any(entry => entry.TargetId == tombstone.Id)) |
| | 37 | 173 | | .OrderBy(tombstone => tombstone.CreatedAt) |
| | 37 | 174 | | .Select(tombstone => tombstone.Id) |
| | 37 | 175 | | .Take(retention.PurgeBatchSize) |
| | 37 | 176 | | .ToListAsync(); |
| | | 177 | | |
| | 37 | 178 | | if (settled.Count > 0) |
| | | 179 | | { |
| | 6 | 180 | | var removal = await erasedSubjectWriter.DeleteRangeAsync(settled); |
| | | 181 | | |
| | 6 | 182 | | if (!removal.Success) |
| | | 183 | | { |
| | 0 | 184 | | return output.WithErrors(removal.Errors); |
| | | 185 | | } |
| | | 186 | | } |
| | | 187 | | |
| | 37 | 188 | | return output |
| | 37 | 189 | | .WithData(new PseudonymiseAuditActorsCommandOutput |
| | 37 | 190 | | { |
| | 31 | 191 | | EntriesPseudonymised = due.Count(row => row.ActorDue), |
| | 31 | 192 | | TargetsCleared = due.Count(row => row.TargetDue), |
| | 37 | 193 | | TombstonesRemoved = settled.Count |
| | 37 | 194 | | }) |
| | 37 | 195 | | .WithMessage(RetentionMessages.AuditActorsPseudonymised); |
| | 37 | 196 | | } |
| | | 197 | | } |