| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 7 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 8 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 9 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 10 | | using ArturRios.Output; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="LiftProcessingRestrictionCommand" /> (UC-45): lifts a restriction, after |
| | | 17 | | /// informing the subject where the law requires it. All failures are returned as errors on the |
| | | 18 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 19 | | /// </summary> |
| | | 20 | | /// <remarks> |
| | | 21 | | /// <para> |
| | | 22 | | /// <b>Art. 18(3) is a precondition, not a courtesy.</b> The subject "shall be informed |
| | | 23 | | /// before the restriction is lifted", so a failed notification stops the lift. That is the |
| | | 24 | | /// opposite of how every other delivery in this API behaves — elsewhere a failure is |
| | | 25 | | /// deliberately not the caller's problem — and the difference is deliberate: a lift that |
| | | 26 | | /// proceeded after a failed send would be unlawful and would look identical to one that |
| | | 27 | | /// worked. |
| | | 28 | | /// </para> |
| | | 29 | | /// <para> |
| | | 30 | | /// <b>A subject lifting their own restriction needs no notification.</b> They are the person |
| | | 31 | | /// Art. 18(3) exists to inform. Requiring an email to be delivered to somebody who is |
| | | 32 | | /// standing in front of you, and refusing their request when it bounces, would be the |
| | | 33 | | /// article's letter against its purpose. |
| | | 34 | | /// </para> |
| | | 35 | | /// </remarks> |
| | 17 | 36 | | public class LiftProcessingRestrictionCommandHandler( |
| | 17 | 37 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 17 | 38 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 17 | 39 | | IRestrictionLiftNotifier notifier, |
| | 17 | 40 | | IAtomicWrites atomicWrites) |
| | | 41 | | : ICommandHandlerAsync<LiftProcessingRestrictionCommand, LiftProcessingRestrictionCommandOutput> |
| | | 42 | | { |
| | | 43 | | public async Task<DataOutput<LiftProcessingRestrictionCommandOutput?>> HandleAsync( |
| | | 44 | | LiftProcessingRestrictionCommand command, CancellationToken cancellationToken = default) |
| | | 45 | | { |
| | 17 | 46 | | var output = DataOutput<LiftProcessingRestrictionCommandOutput?>.New; |
| | | 47 | | |
| | 17 | 48 | | var subjectId = command.SubjectId ?? command.ActingPersonId; |
| | 17 | 49 | | var liftingOwn = subjectId == command.ActingPersonId; |
| | | 50 | | |
| | | 51 | | // Only a System Admin may lift somebody else's. The endpoint carries no role requirement, |
| | | 52 | | // because a subject of any role may lift their own, so the rule is data-dependent and lives |
| | | 53 | | // here. |
| | | 54 | | // |
| | | 55 | | // A subject-rights-only token acts as a subject and never as an administrator (AF-45c), |
| | | 56 | | // including after its own restriction was lifted: it was issued to a restricted identity, |
| | | 57 | | // and that is all it ever stands for. |
| | 17 | 58 | | if (!liftingOwn && (command.ActingRole != (int)Roles.SystemAdmin || command.ActingSubjectRightsOnly)) |
| | | 59 | | { |
| | 4 | 60 | | return output.WithError(ErasureMessages.NotEligible); |
| | | 61 | | } |
| | | 62 | | |
| | | 63 | | // The endpoint admits a restricted or suspended caller, because lifting their own restriction |
| | | 64 | | // is a right of theirs (NFR-24). Lifting somebody else's is not: an administrator who is |
| | | 65 | | // restricted or suspended themselves acts here as a subject, not as an administrator. |
| | 13 | 66 | | if (!liftingOwn && await personReader.Query().AnyAsync(x => |
| | 13 | 67 | | x.PublicId == command.ActingPersonId && (x.IsDeleted || x.ProcessingRestrictedAt != null))) |
| | | 68 | | { |
| | 2 | 69 | | return output.WithError(ErasureMessages.NotEligible); |
| | | 70 | | } |
| | | 71 | | |
| | 11 | 72 | | var person = await personReader.Query().FirstOrDefaultAsync(x => x.PublicId == subjectId); |
| | | 73 | | |
| | 11 | 74 | | if (person is not null) |
| | | 75 | | { |
| | | 76 | | // A targeted write of the restriction's columns alone (#237), and only of the |
| | | 77 | | // restriction read: one lifted since answers AF-45a, as it would have a moment earlier, |
| | | 78 | | // and one imposed again since is not lifted by a notice sent about the first. |
| | 9 | 79 | | return await LiftAsync( |
| | 9 | 80 | | output, person.ProcessingRestrictedAt, person.Email, liftingOwn, |
| | 7 | 81 | | async notifiedAt => await atomicWrites.TryLiftRestrictionAsync(person, notifiedAt, DateTime.UtcNow) |
| | 7 | 82 | | ? null |
| | 7 | 83 | | : [ErasureMessages.NotRestricted], |
| | 9 | 84 | | person.PublicId); |
| | | 85 | | } |
| | | 86 | | |
| | 2 | 87 | | var googleUser = await googleUserReader.Query().FirstOrDefaultAsync(x => x.PublicId == subjectId); |
| | | 88 | | |
| | 2 | 89 | | if (googleUser is null) |
| | | 90 | | { |
| | 0 | 91 | | return output.WithError(ErasureMessages.NotEligible); |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | // As for a person (#205): the restriction's columns alone, and only of the restriction read. |
| | 2 | 95 | | return await LiftAsync( |
| | 2 | 96 | | output, googleUser.ProcessingRestrictedAt, googleUser.Email, liftingOwn, |
| | 2 | 97 | | async notifiedAt => await atomicWrites.TryLiftRestrictionAsync(googleUser, notifiedAt, DateTime.UtcNow) |
| | 2 | 98 | | ? null |
| | 2 | 99 | | : [ErasureMessages.NotRestricted], |
| | 2 | 100 | | googleUser.PublicId); |
| | 17 | 101 | | } |
| | | 102 | | |
| | | 103 | | /// <param name="output">The output to answer on.</param> |
| | | 104 | | /// <param name="restrictedAt">When the restriction read began, or <c>null</c> when there is none.</param> |
| | | 105 | | /// <param name="email">Where the Art. 18(3) notice goes.</param> |
| | | 106 | | /// <param name="liftingOwn">Whether the subject is lifting their own restriction.</param> |
| | | 107 | | /// <param name="lift"> |
| | | 108 | | /// Lifts and persists, given when the subject was told; returns the errors, or <c>null</c>. |
| | | 109 | | /// </param> |
| | | 110 | | /// <param name="subjectPublicId">The subject's public id, for the response.</param> |
| | | 111 | | private async Task<DataOutput<LiftProcessingRestrictionCommandOutput?>> LiftAsync( |
| | | 112 | | DataOutput<LiftProcessingRestrictionCommandOutput?> output, |
| | | 113 | | DateTime? restrictedAt, |
| | | 114 | | string email, |
| | | 115 | | bool liftingOwn, |
| | | 116 | | Func<DateTime?, Task<IEnumerable<string>?>> lift, |
| | | 117 | | Guid subjectPublicId) |
| | | 118 | | { |
| | | 119 | | // AF-45a. |
| | 11 | 120 | | if (restrictedAt is null) |
| | | 121 | | { |
| | 1 | 122 | | return output.WithError(ErasureMessages.NotRestricted); |
| | | 123 | | } |
| | | 124 | | |
| | 10 | 125 | | DateTime? notifiedAt = null; |
| | | 126 | | |
| | 10 | 127 | | if (!liftingOwn) |
| | | 128 | | { |
| | | 129 | | // AF-45b. Informing comes first and the lift does not proceed without it. |
| | 5 | 130 | | if (!await notifier.NotifyAsync(email)) |
| | | 131 | | { |
| | 1 | 132 | | return output.WithError(ErasureMessages.RestrictionLiftNotificationFailed); |
| | | 133 | | } |
| | | 134 | | |
| | 4 | 135 | | notifiedAt = DateTime.UtcNow; |
| | | 136 | | } |
| | | 137 | | |
| | 9 | 138 | | var errors = await lift(notifiedAt); |
| | | 139 | | |
| | 9 | 140 | | return errors is null |
| | 9 | 141 | | ? output |
| | 9 | 142 | | .WithData(new LiftProcessingRestrictionCommandOutput |
| | 9 | 143 | | { |
| | 9 | 144 | | Id = subjectPublicId, |
| | 9 | 145 | | LiftedAt = DateTime.UtcNow, |
| | 9 | 146 | | SubjectNotified = notifiedAt is not null |
| | 9 | 147 | | }) |
| | 9 | 148 | | .WithMessage(ErasureMessages.RestrictionLifted) |
| | 9 | 149 | | : output.WithErrors(errors); |
| | 11 | 150 | | } |
| | | 151 | | } |