| | | 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> |
| | 42 | 82 | | public class PseudonymiseAuditActorsCommandHandler( |
| | 42 | 83 | | IAsyncReadOnlyRepository<AuditLog, long> auditReader, |
| | 42 | 84 | | IAsyncRepository<AuditLog, long> auditWriter, |
| | 42 | 85 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 42 | 86 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 42 | 87 | | IAsyncReadOnlyRepository<ErasedSubject, Guid> erasedSubjectReader, |
| | 42 | 88 | | IAsyncRepository<ErasedSubject, Guid> erasedSubjectWriter, |
| | 42 | 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 | | { |
| | 46 | 107 | | var output = DataOutput<PseudonymiseAuditActorsCommandOutput?>.New; |
| | 46 | 108 | | var cutoff = DateTime.UtcNow - retention.AuditActorRetention; |
| | 46 | 109 | | 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. |
| | 46 | 113 | | var entriesFull = false; |
| | | 114 | | |
| | 136 | 115 | | for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++) |
| | | 116 | | { |
| | 62 | 117 | | var due = await SelectDueAsync(cutoff); |
| | | 118 | | |
| | 62 | 119 | | if (due.Count == 0) |
| | | 120 | | { |
| | 17 | 121 | | entriesFull = false; |
| | 17 | 122 | | break; |
| | | 123 | | } |
| | | 124 | | |
| | 262 | 125 | | foreach (var row in due) |
| | | 126 | | { |
| | 86 | 127 | | if (row.ActorDue) |
| | | 128 | | { |
| | 53 | 129 | | row.Entry.ActorPersonId = null; |
| | 53 | 130 | | row.Entry.ActorRole = null; |
| | | 131 | | } |
| | | 132 | | |
| | 86 | 133 | | if (row.TargetDue) |
| | | 134 | | { |
| | 46 | 135 | | row.Entry.TargetId = null; |
| | | 136 | | } |
| | | 137 | | } |
| | | 138 | | |
| | 131 | 139 | | var update = await auditWriter.UpdateRangeAsync(due.Select(row => row.Entry).ToList()); |
| | | 140 | | |
| | 45 | 141 | | if (!update.Success) |
| | | 142 | | { |
| | | 143 | | // What earlier batches cleared stays cleared, and is reported with the failure. |
| | 1 | 144 | | return output.WithData(result).WithErrors(update.Errors); |
| | | 145 | | } |
| | | 146 | | |
| | 128 | 147 | | result.EntriesPseudonymised += due.Count(row => row.ActorDue); |
| | 128 | 148 | | result.TargetsCleared += due.Count(row => row.TargetDue); |
| | | 149 | | |
| | 44 | 150 | | entriesFull = due.Count == retention.PurgeBatchSize; |
| | | 151 | | |
| | 44 | 152 | | if (!entriesFull) |
| | | 153 | | { |
| | | 154 | | break; |
| | | 155 | | } |
| | 22 | 156 | | } |
| | | 157 | | |
| | | 158 | | // After the clearing, so a tombstone whose last entry this run cleared goes in the same run. |
| | 45 | 159 | | var settledBefore = DateTime.UtcNow - TombstoneGracePeriod; |
| | 45 | 160 | | var tombstonesFull = false; |
| | | 161 | | |
| | 92 | 162 | | for (var batch = 0; batch < retention.MaxBatchesPerTick; batch++) |
| | | 163 | | { |
| | 46 | 164 | | var settled = await Settled(settledBefore) |
| | 46 | 165 | | .OrderBy(tombstone => tombstone.CreatedAt) |
| | 46 | 166 | | .Select(tombstone => tombstone.Id) |
| | 46 | 167 | | .Take(retention.PurgeBatchSize) |
| | 46 | 168 | | .ToListAsync(); |
| | | 169 | | |
| | 46 | 170 | | if (settled.Count == 0) |
| | | 171 | | { |
| | 39 | 172 | | tombstonesFull = false; |
| | 39 | 173 | | break; |
| | | 174 | | } |
| | | 175 | | |
| | 7 | 176 | | var removal = await erasedSubjectWriter.DeleteRangeAsync(settled); |
| | | 177 | | |
| | 7 | 178 | | if (!removal.Success) |
| | | 179 | | { |
| | 0 | 180 | | return output.WithData(result).WithErrors(removal.Errors); |
| | | 181 | | } |
| | | 182 | | |
| | 7 | 183 | | result.TombstonesRemoved += settled.Count; |
| | 7 | 184 | | tombstonesFull = settled.Count == retention.PurgeBatchSize; |
| | | 185 | | |
| | 7 | 186 | | if (!tombstonesFull) |
| | | 187 | | { |
| | | 188 | | break; |
| | | 189 | | } |
| | 1 | 190 | | } |
| | | 191 | | |
| | | 192 | | // Counted only where the run stopped on a full batch: one that was not full took all that was due. |
| | 45 | 193 | | result.Backlog = (entriesFull ? await CountDueAsync(cutoff) : 0) |
| | 45 | 194 | | + (tombstonesFull ? await Settled(settledBefore).CountAsync() : 0); |
| | | 195 | | |
| | 45 | 196 | | return output |
| | 45 | 197 | | .WithData(result) |
| | 45 | 198 | | .WithMessage(RetentionMessages.AuditActorsPseudonymised); |
| | 46 | 199 | | } |
| | | 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 | | { |
| | 62 | 230 | | var size = retention.PurgeBatchSize; |
| | 62 | 231 | | var grounds = await DueGroundsAsync(cutoff); |
| | | 232 | | |
| | 62 | 233 | | var actorDue = new HashSet<long>(); |
| | 62 | 234 | | var targetDue = new HashSet<long>(); |
| | 62 | 235 | | var candidates = new Dictionary<long, DateTime>(); |
| | | 236 | | |
| | 438 | 237 | | foreach (var (ground, isActor) in grounds) |
| | | 238 | | { |
| | 157 | 239 | | var batch = await ground |
| | 157 | 240 | | .OrderBy(entry => entry.CreatedAt) |
| | 157 | 241 | | .ThenBy(entry => entry.Id) |
| | 157 | 242 | | .Select(entry => new { entry.Id, entry.CreatedAt }) |
| | 157 | 243 | | .Take(size) |
| | 157 | 244 | | .ToListAsync(); |
| | | 245 | | |
| | 554 | 246 | | foreach (var row in batch) |
| | | 247 | | { |
| | 120 | 248 | | candidates[row.Id] = row.CreatedAt; |
| | 120 | 249 | | (isActor ? actorDue : targetDue).Add(row.Id); |
| | | 250 | | } |
| | | 251 | | } |
| | | 252 | | |
| | 62 | 253 | | var ids = candidates |
| | 104 | 254 | | .OrderBy(candidate => candidate.Value) |
| | 104 | 255 | | .ThenBy(candidate => candidate.Key) |
| | 62 | 256 | | .Take(size) |
| | 86 | 257 | | .Select(candidate => candidate.Key) |
| | 62 | 258 | | .ToList(); |
| | | 259 | | |
| | 62 | 260 | | if (ids.Count == 0) |
| | | 261 | | { |
| | 17 | 262 | | return []; |
| | | 263 | | } |
| | | 264 | | |
| | | 265 | | // By primary key: the batch is already chosen, and these are the rows the write needs. |
| | 45 | 266 | | var entries = await auditReader.Query() |
| | 45 | 267 | | .Where(entry => ids.Contains(entry.Id)) |
| | 45 | 268 | | .ToListAsync(); |
| | | 269 | | |
| | 45 | 270 | | return entries |
| | 86 | 271 | | .OrderBy(entry => entry.CreatedAt) |
| | 86 | 272 | | .ThenBy(entry => entry.Id) |
| | 86 | 273 | | .Select(entry => new DueEntry |
| | 86 | 274 | | { |
| | 86 | 275 | | Entry = entry, |
| | 86 | 276 | | ActorDue = actorDue.Contains(entry.Id), |
| | 86 | 277 | | TargetDue = targetDue.Contains(entry.Id) |
| | 86 | 278 | | }) |
| | 45 | 279 | | .ToList(); |
| | 62 | 280 | | } |
| | | 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 | | { |
| | 6 | 285 | | var due = (await DueGroundsAsync(cutoff)) |
| | 14 | 286 | | .Select(ground => ground.Ground.Select(entry => entry.Id)) |
| | 14 | 287 | | .Aggregate((all, next) => all.Union(next)); |
| | | 288 | | |
| | 6 | 289 | | return await due.CountAsync(); |
| | 6 | 290 | | } |
| | | 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 | | { |
| | 68 | 316 | | var entries = auditReader.Query(); |
| | 68 | 317 | | var persons = personReader.Query(); |
| | 68 | 318 | | var googleUsers = googleUserReader.Query(); |
| | | 319 | | |
| | 68 | 320 | | var identities = persons.Select(person => person.PublicId) |
| | 68 | 321 | | .Concat(googleUsers.Select(googleUser => googleUser.PublicId)); |
| | | 322 | | |
| | 68 | 323 | | var anonymised = persons.Where(person => person.AnonymisedAt != null).Select(person => person.PublicId) |
| | 68 | 324 | | .Concat(googleUsers.Where(googleUser => googleUser.AnonymisedAt != null) |
| | 68 | 325 | | .Select(googleUser => googleUser.PublicId)); |
| | | 326 | | |
| | 68 | 327 | | var hardDeleted = erasedSubjectReader.Query() |
| | 68 | 328 | | .Where(tombstone => !persons.Any(person => person.PublicId == tombstone.Id) |
| | 68 | 329 | | && !googleUsers.Any(googleUser => googleUser.PublicId == tombstone.Id)) |
| | 68 | 330 | | .Select(tombstone => tombstone.Id); |
| | | 331 | | |
| | 68 | 332 | | var namedAsActor = await anonymised |
| | 68 | 333 | | .Where(id => entries.Any(entry => entry.ActorPersonId == id)) |
| | 68 | 334 | | .ToListAsync(); |
| | | 335 | | |
| | 68 | 336 | | var namedAsTarget = await anonymised.Concat(hardDeleted) |
| | 68 | 337 | | .Where(id => entries.Any(entry => entry.TargetId == id)) |
| | 68 | 338 | | .ToListAsync(); |
| | | 339 | | |
| | 68 | 340 | | List<(IQueryable<AuditLog> Ground, bool IsActor)> grounds = |
| | 68 | 341 | | [ |
| | 68 | 342 | | // The actor, past the period: the partial index on created_at over attributed entries. |
| | 68 | 343 | | (entries.Where(entry => entry.ActorPersonId != null && entry.CreatedAt <= cutoff), true), |
| | 68 | 344 | | |
| | 68 | 345 | | // A person target, past the period: the partial index on created_at over entries with a target. |
| | 68 | 346 | | (entries.Where(entry => entry.TargetId != null |
| | 68 | 347 | | && entry.CreatedAt <= cutoff |
| | 68 | 348 | | && identities.Contains(entry.TargetId.Value)), false) |
| | 68 | 349 | | ]; |
| | | 350 | | |
| | | 351 | | // The actor or a target anonymised, or a target hard-deleted: the entries naming those still named. |
| | 68 | 352 | | if (namedAsActor.Count > 0) |
| | | 353 | | { |
| | 17 | 354 | | grounds.Add((entries.Where(entry => |
| | 17 | 355 | | entry.ActorPersonId != null && namedAsActor.Contains(entry.ActorPersonId.Value)), true)); |
| | | 356 | | } |
| | | 357 | | |
| | 68 | 358 | | if (namedAsTarget.Count > 0) |
| | | 359 | | { |
| | 18 | 360 | | grounds.Add((entries.Where(entry => |
| | 18 | 361 | | entry.TargetId != null && namedAsTarget.Contains(entry.TargetId.Value)), false)); |
| | | 362 | | } |
| | | 363 | | |
| | 68 | 364 | | return grounds; |
| | 68 | 365 | | } |
| | | 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 | | { |
| | 46 | 370 | | var entries = auditReader.Query(); |
| | | 371 | | |
| | 46 | 372 | | return erasedSubjectReader.Query() |
| | 46 | 373 | | .Where(tombstone => tombstone.CreatedAt <= settledBefore |
| | 46 | 374 | | && !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 | | { |
| | 324 | 380 | | public AuditLog Entry { get; init; } = null!; |
| | | 381 | | |
| | 256 | 382 | | public bool ActorDue { get; init; } |
| | | 383 | | |
| | 256 | 384 | | public bool TargetDue { get; init; } |
| | | 385 | | } |
| | | 386 | | } |