| | | 1 | | using ArturRios.Heimdall.Command.Input; |
| | | 2 | | using ArturRios.Heimdall.Command.Output; |
| | | 3 | | using ArturRios.Heimdall.Shared.Retention; |
| | | 4 | | using ArturRios.Mediator.Command; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Heimdall.WebApi.Retention; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Runs <see cref="AnonymiseExpiredDeletionsCommand" /> on an interval, so a logically deleted |
| | | 10 | | /// identity reaches a terminal state instead of sitting in the database indefinitely (NFR-20). |
| | | 11 | | /// </summary> |
| | | 12 | | /// <remarks> |
| | | 13 | | /// This is the pass that actually discharges GDPR Art. 17 and LGPD Art. 16 for an identity, so |
| | | 14 | | /// unlike the token purge it logs every run that changed something, however small. |
| | | 15 | | /// </remarks> |
| | | 16 | | public class IdentityAnonymisationService( |
| | | 17 | | IServiceScopeFactory scopeFactory, |
| | | 18 | | DataRetentionOptions retention, |
| | | 19 | | ILogger<IdentityAnonymisationService> logger) |
| | 970 | 20 | | : ScheduledPassService(scopeFactory, retention.PurgeInterval, logger) |
| | | 21 | | { |
| | 0 | 22 | | public override string PassName => "identity-anonymisation"; |
| | | 23 | | |
| | | 24 | | protected override string StartupDescription => |
| | 966 | 25 | | $"Identity anonymisation scheduled every {retention.PurgeInterval}, anonymising records " + |
| | 966 | 26 | | $"{retention.SubjectErasureDeadline} after a requested erasure and " + |
| | 966 | 27 | | $"{retention.AdministrativeDeletionWindow} after an administrative deletion"; |
| | | 28 | | |
| | | 29 | | protected override async Task<ScheduledPassResult> RunAsync(IServiceProvider scope) |
| | | 30 | | { |
| | 4 | 31 | | var mediator = scope.GetRequiredService<CommandMediator>(); |
| | | 32 | | |
| | 4 | 33 | | var result = await mediator |
| | 4 | 34 | | .ExecuteCommandAsync<AnonymiseExpiredDeletionsCommand, AnonymiseExpiredDeletionsCommandOutput>( |
| | 4 | 35 | | new AnonymiseExpiredDeletionsCommand()); |
| | | 36 | | |
| | 4 | 37 | | if (!result.Success) |
| | | 38 | | { |
| | | 39 | | // A retry of a blocked request can fail after the due identities were anonymised (#210); the |
| | | 40 | | // ledger still has to be written then, or those erasures would leave no trace outside the |
| | | 41 | | // database. |
| | 2 | 42 | | return result.Data is { TotalAnonymised: > 0 } done |
| | 2 | 43 | | ? new ScheduledPassResult( |
| | 2 | 44 | | $"Identity anonymisation failed: {string.Join("; ", result.Errors)}. " + |
| | 2 | 45 | | $"Anonymised {done.TotalAnonymised} identities nonetheless. {Ledger(done)}", |
| | 2 | 46 | | true, done.TotalAnonymised) |
| | 2 | 47 | | : ScheduledPassResult.Failure("Identity anonymisation", result.Errors); |
| | | 48 | | } |
| | | 49 | | |
| | 2 | 50 | | var anonymised = result.Data!; |
| | | 51 | | |
| | | 52 | | // The identifiers are named, not just counted: this line is the erasure ledger (NFR-25), |
| | | 53 | | // and it is what makes a restore reconcilable. A backup taken before a subject asked |
| | | 54 | | // reinstates them with no trace of the request, so the list has to survive somewhere the |
| | | 55 | | // restore cannot roll back — which means outside the database, in the logs. |
| | | 56 | | // |
| | | 57 | | // Safe to write and to ship off the host precisely because these identities are now |
| | | 58 | | // anonymised: a PublicId that resolves to nobody is not personal data. |
| | | 59 | | // |
| | | 60 | | // The identities still due are what the run left at its batch cap (#206): a number that does not |
| | | 61 | | // fall from run to run is a backlog that is not draining, and deadlines passing. |
| | 2 | 62 | | return anonymised.TotalAnonymised == 0 && anonymised.Backlog == 0 |
| | 2 | 63 | | ? ScheduledPassResult.Quiet |
| | 2 | 64 | | : new ScheduledPassResult( |
| | 2 | 65 | | $"Anonymised {anonymised.TotalAnonymised} identities past their retention window: " + |
| | 2 | 66 | | $"{anonymised.PersonsAnonymised} persons, {anonymised.GoogleUsersAnonymised} Google Users, " + |
| | 2 | 67 | | $"removing {anonymised.DependentsRemoved} dependent rows; " + |
| | 2 | 68 | | $"{anonymised.Backlog?.ToString() ?? "an unknown number"} still due after this run. " + |
| | 2 | 69 | | Ledger(anonymised), |
| | 2 | 70 | | false, anonymised.TotalAnonymised); |
| | 4 | 71 | | } |
| | | 72 | | |
| | | 73 | | private static string Ledger(AnonymiseExpiredDeletionsCommandOutput anonymised) => |
| | 3 | 74 | | $"Erasure ledger: {string.Join(",", anonymised.AnonymisedIds)}"; |
| | | 75 | | } |