| | | 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.Domain.Enums; |
| | | 6 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 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="RestrictProcessingCommand" /> (UC-44): suspends processing of the |
| | | 15 | | /// caller's own identity without deleting anything (GDPR Art. 18, LGPD Art. 18 III–IV). All |
| | | 16 | | /// failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown. |
| | | 17 | | /// </summary> |
| | | 18 | | /// <remarks> |
| | | 19 | | /// <para> |
| | | 20 | | /// <b>Why this is not the deletion flag.</b> `IsDeleted` means the identity is on its way |
| | | 21 | | /// out: it is excluded from reads, it cascades through UC-04, and NFR-20 eventually |
| | | 22 | | /// anonymises it. A restriction means the opposite — the record is disputed and must be |
| | | 23 | | /// preserved exactly as it stands while that is resolved. Reusing the flag would set an |
| | | 24 | | /// erasure clock running on data the subject has specifically asked be kept. |
| | | 25 | | /// </para> |
| | | 26 | | /// <para> |
| | | 27 | | /// <b>No credential is required, unlike UC-42.</b> Erasure is irreversible, so it demands |
| | | 28 | | /// proof the person is present. A restriction destroys nothing and is liftable, and the |
| | | 29 | | /// subject asking for one may be doing so precisely because they believe the account is |
| | | 30 | | /// compromised — demanding the password of somebody in that position would be the wrong way |
| | | 31 | | /// round. |
| | | 32 | | /// </para> |
| | | 33 | | /// </remarks> |
| | 13 | 34 | | public class RestrictProcessingCommandHandler( |
| | 13 | 35 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 13 | 36 | | IAsyncRepository<Person, long> personWriter, |
| | 13 | 37 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 13 | 38 | | IAsyncRepository<GoogleUser, long> googleUserWriter) |
| | | 39 | | : ICommandHandlerAsync<RestrictProcessingCommand, RestrictProcessingCommandOutput> |
| | | 40 | | { |
| | | 41 | | public async Task<DataOutput<RestrictProcessingCommandOutput?>> HandleAsync(RestrictProcessingCommand command, Cance |
| | | 42 | | { |
| | 13 | 43 | | var output = DataOutput<RestrictProcessingCommandOutput?>.New; |
| | | 44 | | |
| | | 45 | | // AF-44b. Art. 18(1) is exhaustive: a restriction rests on one of its four grounds or it is |
| | | 46 | | // not a restriction under the article. |
| | 13 | 47 | | if (!Enum.IsDefined((RestrictionGrounds)command.Ground)) |
| | | 48 | | { |
| | 4 | 49 | | return output.WithError(ErasureMessages.RestrictionGroundUnknown); |
| | | 50 | | } |
| | | 51 | | |
| | 9 | 52 | | var restrictedAt = DateTime.UtcNow; |
| | | 53 | | |
| | | 54 | | // The lookups omit !IsDeleted deliberately: a suspended identity may still contest the |
| | | 55 | | // accuracy of what is held about it, and Art. 18 does not require the account to be in good |
| | | 56 | | // standing. What it must not be is anonymised, and an anonymised record cannot authenticate. |
| | 9 | 57 | | var person = await personReader.Query() |
| | 9 | 58 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId); |
| | | 59 | | |
| | 9 | 60 | | if (person is not null) |
| | | 61 | | { |
| | 7 | 62 | | if (person.ProcessingRestrictedAt is not null) |
| | | 63 | | { |
| | 2 | 64 | | return output.WithError(ErasureMessages.ProcessingAlreadyRestricted); |
| | | 65 | | } |
| | | 66 | | |
| | 5 | 67 | | person.ProcessingRestrictedAt = restrictedAt; |
| | 5 | 68 | | person.RestrictionGround = command.Ground; |
| | 5 | 69 | | person.RestrictionLiftNotifiedAt = null; |
| | 5 | 70 | | person.UpdatedAt = restrictedAt; |
| | | 71 | | |
| | 5 | 72 | | var update = await personWriter.UpdateAsync(person); |
| | | 73 | | |
| | 5 | 74 | | return update.Success |
| | 5 | 75 | | ? Restricted(output, person.PublicId, restrictedAt, command.Ground) |
| | 5 | 76 | | : output.WithErrors(update.Errors); |
| | | 77 | | } |
| | | 78 | | |
| | 2 | 79 | | var googleUser = await googleUserReader.Query() |
| | 2 | 80 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId); |
| | | 81 | | |
| | 2 | 82 | | if (googleUser is null) |
| | | 83 | | { |
| | 1 | 84 | | return output.WithError(ErasureMessages.NotEligible); |
| | | 85 | | } |
| | | 86 | | |
| | 1 | 87 | | if (googleUser.ProcessingRestrictedAt is not null) |
| | | 88 | | { |
| | 0 | 89 | | return output.WithError(ErasureMessages.ProcessingAlreadyRestricted); |
| | | 90 | | } |
| | | 91 | | |
| | 1 | 92 | | googleUser.ProcessingRestrictedAt = restrictedAt; |
| | 1 | 93 | | googleUser.RestrictionGround = command.Ground; |
| | 1 | 94 | | googleUser.RestrictionLiftNotifiedAt = null; |
| | 1 | 95 | | googleUser.UpdatedAt = restrictedAt; |
| | | 96 | | |
| | 1 | 97 | | var googleUpdate = await googleUserWriter.UpdateAsync(googleUser); |
| | | 98 | | |
| | 1 | 99 | | return googleUpdate.Success |
| | 1 | 100 | | ? Restricted(output, googleUser.PublicId, restrictedAt, command.Ground) |
| | 1 | 101 | | : output.WithErrors(googleUpdate.Errors); |
| | 13 | 102 | | } |
| | | 103 | | |
| | | 104 | | private static DataOutput<RestrictProcessingCommandOutput?> Restricted( |
| | | 105 | | DataOutput<RestrictProcessingCommandOutput?> output, Guid id, DateTime at, int ground) => |
| | 6 | 106 | | output |
| | 6 | 107 | | .WithData(new RestrictProcessingCommandOutput { Id = id, RestrictedAt = at, Ground = ground }) |
| | 6 | 108 | | .WithMessage(ErasureMessages.ProcessingRestricted); |
| | | 109 | | } |