| | | 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.Heimdall.Shared.Retention; |
| | | 10 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 11 | | using ArturRios.Output; |
| | | 12 | | using FluentValidation; |
| | | 13 | | using Microsoft.EntityFrameworkCore; |
| | | 14 | | |
| | | 15 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 16 | | |
| | | 17 | | /// <summary> |
| | | 18 | | /// Handles <see cref="RequestErasureCommand" /> (UC-42): re-authenticates the caller, records |
| | | 19 | | /// their erasure request against their own record, lifts any restriction on processing they |
| | | 20 | | /// had in place, and suspends the identity so the request takes effect immediately. The |
| | | 21 | | /// anonymisation pass (NFR-20) completes it on the deadline. All failures are returned as |
| | | 22 | | /// errors on the <see cref="DataOutput{T}" /> rather than thrown. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <remarks> |
| | | 25 | | /// <para> |
| | | 26 | | /// <b>Why the subject is never named in the request.</b> The identity acted on is the one |
| | | 27 | | /// the token names, always. There is no path parameter and no body field for it, so the |
| | | 28 | | /// endpoint has no shape in which one identity could ask for another's erasure — a rule |
| | | 29 | | /// enforced by the absence of the parameter rather than by a check that could be got wrong. |
| | | 30 | | /// </para> |
| | | 31 | | /// <para> |
| | | 32 | | /// <b>Why re-authentication.</b> Erasure is irreversible, and a bearer token is not proof |
| | | 33 | | /// that the person is present — one left open on a shared machine, or lifted from a log, |
| | | 34 | | /// would otherwise be enough to destroy the account it belongs to. A person presents their |
| | | 35 | | /// password; a Google User presents a fresh Google ID token, because they have no password. |
| | | 36 | | /// A person's password is charged to their re-authentication budget (FR-AU-13) and refused |
| | | 37 | | /// with the message every re-authentication shares; a Google User's token, which cannot be |
| | | 38 | | /// guessed, is refused with <see cref="ErasureMessages.CredentialNotAccepted" />. Both are 401. |
| | | 39 | | /// </para> |
| | | 40 | | /// <para> |
| | | 41 | | /// <b>Why the request is recorded before it can be carried out.</b> NFR-12 refuses to |
| | | 42 | | /// suspend the last owner of a scope, and clearing that needs a human to transfer ownership |
| | | 43 | | /// (UC-21). Refusing the request outright would be the wrong answer twice over: the subject |
| | | 44 | | /// has exercised a right that does not depend on the scope's ownership arrangements, and |
| | | 45 | | /// GDPR Art. 12(3)'s clock starts at the request whether or not anything blocks it. So the |
| | | 46 | | /// request is always recorded and always starts the deadline; the block is recorded |
| | | 47 | | /// alongside it, and the anonymisation pass carries the erasure out as soon as it clears. |
| | | 48 | | /// </para> |
| | | 49 | | /// <para> |
| | | 50 | | /// <b>Why the request lifts a restriction.</b> A restricted record is kept out of the |
| | | 51 | | /// anonymisation pass (NFR-24), so an erasure requested under a restriction would wait, |
| | | 52 | | /// unerased, for as long as the restriction stood — and the statutory deadline would pass |
| | | 53 | | /// meanwhile. Only the subject can impose a restriction (UC-44), and only the subject can |
| | | 54 | | /// get here, credential in hand: asking to be erased is their later and more specific |
| | | 55 | | /// instruction, and it supersedes their wish that the record be preserved. The lift is the |
| | | 56 | | /// one UC-45 performs for a subject lifting their own — no Art. 18(3) notice, since they |
| | | 57 | | /// are the person it would inform — and it is saved with the request, so neither can |
| | | 58 | | /// happen without the other. The response and the audit trail both say it happened. |
| | | 59 | | /// </para> |
| | | 60 | | /// <para> |
| | | 61 | | /// <b>Why the deadline is stored rather than derived.</b> The obligation attaches when the |
| | | 62 | | /// request is made. Recomputing it from the current configuration on every read would let a |
| | | 63 | | /// later change move an obligation that had already attached. |
| | | 64 | | /// </para> |
| | | 65 | | /// </remarks> |
| | 39 | 66 | | public class RequestErasureCommandHandler( |
| | 39 | 67 | | IValidator<RequestErasureCommand> validator, |
| | 39 | 68 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 39 | 69 | | IAsyncRepository<Person, long> personWriter, |
| | 39 | 70 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 39 | 71 | | IAsyncRepository<GoogleUser, long> googleUserWriter, |
| | 39 | 72 | | IGoogleIdTokenVerifier tokenVerifier, |
| | 39 | 73 | | DataRetentionOptions retention, |
| | 39 | 74 | | IAtomicWrites atomicWrites) |
| | | 75 | | : ICommandHandlerAsync<RequestErasureCommand, RequestErasureCommandOutput> |
| | | 76 | | { |
| | | 77 | | public async Task<DataOutput<RequestErasureCommandOutput?>> HandleAsync(RequestErasureCommand command, CancellationT |
| | | 78 | | { |
| | 39 | 79 | | var output = DataOutput<RequestErasureCommandOutput?>.New; |
| | | 80 | | |
| | | 81 | | // NFR-10: validate input shape. |
| | 39 | 82 | | var validation = await validator.ValidateAsync(command); |
| | | 83 | | |
| | 39 | 84 | | if (!validation.IsValid) |
| | | 85 | | { |
| | 0 | 86 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | // The person table first, then the Google User table — the same order UC-36 uses, and for |
| | | 90 | | // the same reason: the two identity tables are never joined, and a PublicId belongs to one |
| | | 91 | | // of them. |
| | | 92 | | // |
| | | 93 | | // The lookups include logically deleted identities so that a subject whose first request |
| | | 94 | | // suspended them is told AF-42c — the request they are repeating exists — rather than the |
| | | 95 | | // generic AF-42b. The endpoint admits such a caller because it is a data subject right; any |
| | | 96 | | // other deleted identity is refused with AF-42b once AF-42c has been ruled out. |
| | 39 | 97 | | var person = await personReader.Query() |
| | 39 | 98 | | .Include(x => x.ScopeOwnerships) |
| | 39 | 99 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId); |
| | | 100 | | |
| | 39 | 101 | | if (person is not null) |
| | | 102 | | { |
| | 31 | 103 | | return person.ErasureRequestedAt is null && person.IsDeleted |
| | 31 | 104 | | ? output.WithError(ErasureMessages.NotEligible) |
| | 31 | 105 | | : await RequestForPersonAsync(output, command, person); |
| | | 106 | | } |
| | | 107 | | |
| | 8 | 108 | | var googleUser = await googleUserReader.Query() |
| | 8 | 109 | | .FirstOrDefaultAsync(x => x.PublicId == command.ActingPersonId); |
| | | 110 | | |
| | 8 | 111 | | return googleUser is null || (googleUser.ErasureRequestedAt is null && googleUser.IsDeleted) |
| | 8 | 112 | | ? output.WithError(ErasureMessages.NotEligible) |
| | 8 | 113 | | : await RequestForGoogleUserAsync(output, command, googleUser); |
| | 39 | 114 | | } |
| | | 115 | | |
| | | 116 | | private async Task<DataOutput<RequestErasureCommandOutput?>> RequestForPersonAsync( |
| | | 117 | | DataOutput<RequestErasureCommandOutput?> output, RequestErasureCommand command, Person person) |
| | | 118 | | { |
| | | 119 | | // AF-42c. Checked before the credential so a repeat request does not cost an Argon2id |
| | | 120 | | // derivation, and so the answer does not depend on getting the password right a second time. |
| | 29 | 121 | | if (person.ErasureRequestedAt is not null) |
| | | 122 | | { |
| | 4 | 123 | | return output.WithError(ErasureMessages.ErasureAlreadyRequested); |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | // AF-42a. The same derivation UC-11 performs, through the same gate, so this endpoint cannot |
| | | 127 | | // be used to exhaust the process's memory any more than login can (Threat Model TH-03). It |
| | | 128 | | // is therefore governed by NFR-18 rather than NFR-05, like every password-verifying |
| | | 129 | | // endpoint. |
| | | 130 | | // |
| | | 131 | | // FR-AU-13: charged to the re-authentication budget first, and refused with the message |
| | | 132 | | // every re-authentication shares. A paused budget still has the password compared, and |
| | | 133 | | // refuses whatever the result. |
| | 25 | 134 | | var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person); |
| | | 135 | | |
| | 25 | 136 | | if (string.IsNullOrWhiteSpace(command.Password) || |
| | 25 | 137 | | !await PasswordHashGate.Shared.TextMatchesAsync(command.Password, person.PasswordHash, person.Salt) || |
| | 25 | 138 | | !admitted) |
| | | 139 | | { |
| | 11 | 140 | | return output.WithError(AuthMessages.PasswordOrCodeIncorrect); |
| | | 141 | | } |
| | | 142 | | |
| | 14 | 143 | | await ReauthenticationBudget.ClearAsync(atomicWrites, person); |
| | | 144 | | |
| | 14 | 145 | | var requestedAt = DateTime.UtcNow; |
| | 14 | 146 | | var dueAt = requestedAt + retention.SubjectErasureDeadline; |
| | | 147 | | |
| | 14 | 148 | | person.ErasureRequestedAt = requestedAt; |
| | 14 | 149 | | person.ErasureDueAt = dueAt; |
| | 14 | 150 | | person.UpdatedAt = requestedAt; |
| | | 151 | | |
| | 14 | 152 | | var restrictionLifted = LiftRestriction(person); |
| | | 153 | | |
| | | 154 | | // AF-42d (NFR-12): a scope must keep an owner. Suspending the last one would leave it |
| | | 155 | | // ownerless, so the suspension waits — but the request and its deadline do not. |
| | 14 | 156 | | var blocked = await LastScopeOwnerGuard.WouldStripLastOwnerAsync(person, personReader); |
| | | 157 | | |
| | 14 | 158 | | if (blocked) |
| | | 159 | | { |
| | 4 | 160 | | person.ErasureBlockedReason = ErasureMessages.BlockedByLastScopeOwnership; |
| | | 161 | | } |
| | | 162 | | else |
| | | 163 | | { |
| | 10 | 164 | | Suspend(person, requestedAt); |
| | | 165 | | } |
| | | 166 | | |
| | 14 | 167 | | var update = await personWriter.UpdateAsync(person); |
| | | 168 | | |
| | 14 | 169 | | return !update.Success |
| | 14 | 170 | | ? output.WithErrors(update.Errors) |
| | 14 | 171 | | : Recorded( |
| | 14 | 172 | | output, person.PublicId, requestedAt, dueAt, blocked, person.ErasureBlockedReason, |
| | 14 | 173 | | restrictionLifted); |
| | 29 | 174 | | } |
| | | 175 | | |
| | | 176 | | private async Task<DataOutput<RequestErasureCommandOutput?>> RequestForGoogleUserAsync( |
| | | 177 | | DataOutput<RequestErasureCommandOutput?> output, RequestErasureCommand command, GoogleUser googleUser) |
| | | 178 | | { |
| | | 179 | | // AF-42c. |
| | 7 | 180 | | if (googleUser.ErasureRequestedAt is not null) |
| | | 181 | | { |
| | 0 | 182 | | return output.WithError(ErasureMessages.ErasureAlreadyRequested); |
| | | 183 | | } |
| | | 184 | | |
| | | 185 | | // AF-42a. A Google User has no password, so the equivalent proof of presence is a token |
| | | 186 | | // Google minted for them just now. Verifying the signature is not enough on its own: the |
| | | 187 | | // subject claim has to name this very Google User, or any valid token for any Google account |
| | | 188 | | // would erase whichever account the bearer token happened to name. |
| | 7 | 189 | | if (string.IsNullOrWhiteSpace(command.IdToken)) |
| | | 190 | | { |
| | 2 | 191 | | return output.WithError(ErasureMessages.CredentialNotAccepted); |
| | | 192 | | } |
| | | 193 | | |
| | 5 | 194 | | var payload = await tokenVerifier.VerifyAsync(command.IdToken); |
| | | 195 | | |
| | 5 | 196 | | if (payload is null || payload.Subject != googleUser.GoogleId) |
| | | 197 | | { |
| | 3 | 198 | | return output.WithError(ErasureMessages.CredentialNotAccepted); |
| | | 199 | | } |
| | | 200 | | |
| | 2 | 201 | | var requestedAt = DateTime.UtcNow; |
| | 2 | 202 | | var dueAt = requestedAt + retention.SubjectErasureDeadline; |
| | | 203 | | |
| | 2 | 204 | | googleUser.ErasureRequestedAt = requestedAt; |
| | 2 | 205 | | googleUser.ErasureDueAt = dueAt; |
| | 2 | 206 | | googleUser.UpdatedAt = requestedAt; |
| | | 207 | | |
| | 2 | 208 | | var restrictionLifted = LiftRestriction(googleUser); |
| | | 209 | | |
| | | 210 | | // Nothing can block a Google User's erasure: FR-GO-04 makes them permanently User-equivalent, |
| | | 211 | | // so they own no scope and NFR-12 has nothing to protect. |
| | 2 | 212 | | Suspend(googleUser, requestedAt); |
| | | 213 | | |
| | 2 | 214 | | var update = await googleUserWriter.UpdateAsync(googleUser); |
| | | 215 | | |
| | 2 | 216 | | return !update.Success |
| | 2 | 217 | | ? output.WithErrors(update.Errors) |
| | 2 | 218 | | : Recorded( |
| | 2 | 219 | | output, googleUser.PublicId, requestedAt, dueAt, blocked: false, blockedReason: null, |
| | 2 | 220 | | restrictionLifted); |
| | 7 | 221 | | } |
| | | 222 | | |
| | | 223 | | /// <summary> |
| | | 224 | | /// Lifts the subject's restriction on processing, if they have one, exactly as UC-45 does |
| | | 225 | | /// when a subject lifts their own: the restriction and its ground are cleared, and no |
| | | 226 | | /// notification is recorded because none is owed. |
| | | 227 | | /// </summary> |
| | | 228 | | /// <returns>Whether there was a restriction to lift.</returns> |
| | | 229 | | /// <remarks> |
| | | 230 | | /// Done whether or not NFR-12 blocks the suspension (AF-42d). A blocked subject is then |
| | | 231 | | /// neither restricted nor suspended, which is what lets them take part in the ownership |
| | | 232 | | /// transfer (UC-21) that unblocks their own request. |
| | | 233 | | /// </remarks> |
| | | 234 | | private static bool LiftRestriction(Person person) |
| | | 235 | | { |
| | 14 | 236 | | if (person.ProcessingRestrictedAt is null) |
| | | 237 | | { |
| | 8 | 238 | | return false; |
| | | 239 | | } |
| | | 240 | | |
| | 6 | 241 | | person.ProcessingRestrictedAt = null; |
| | 6 | 242 | | person.RestrictionGround = null; |
| | 6 | 243 | | person.RestrictionLiftNotifiedAt = null; |
| | | 244 | | |
| | 6 | 245 | | return true; |
| | | 246 | | } |
| | | 247 | | |
| | | 248 | | /// <inheritdoc cref="LiftRestriction(Person)" /> |
| | | 249 | | private static bool LiftRestriction(GoogleUser googleUser) |
| | | 250 | | { |
| | 2 | 251 | | if (googleUser.ProcessingRestrictedAt is null) |
| | | 252 | | { |
| | 1 | 253 | | return false; |
| | | 254 | | } |
| | | 255 | | |
| | 1 | 256 | | googleUser.ProcessingRestrictedAt = null; |
| | 1 | 257 | | googleUser.RestrictionGround = null; |
| | 1 | 258 | | googleUser.RestrictionLiftNotifiedAt = null; |
| | | 259 | | |
| | 1 | 260 | | return true; |
| | | 261 | | } |
| | | 262 | | |
| | | 263 | | /// <summary> |
| | | 264 | | /// Suspends the identity: the same logical deletion UC-09 and UC-28 perform, carrying the |
| | | 265 | | /// kind that gives it the statutory deadline rather than the administrative window. |
| | | 266 | | /// </summary> |
| | | 267 | | /// <remarks> |
| | | 268 | | /// Suspension is immediate and deliberate. A subject who has asked to be erased has |
| | | 269 | | /// withdrawn the basis on which the account operates, so continuing to authenticate them |
| | | 270 | | /// while the deadline runs would be processing they have objected to. `IsDeleted` already |
| | | 271 | | /// stops every path: login refuses it, and `ActorLivenessFilter` refuses an unexpired token. |
| | | 272 | | /// </remarks> |
| | | 273 | | private static void Suspend(Person person, DateTime at) |
| | | 274 | | { |
| | 10 | 275 | | person.IsDeleted = true; |
| | 10 | 276 | | person.DeletedAt = at; |
| | 10 | 277 | | person.DeletionKind = (int)DeletionKinds.SubjectRequested; |
| | 10 | 278 | | } |
| | | 279 | | |
| | | 280 | | /// <inheritdoc cref="Suspend(Person, DateTime)" /> |
| | | 281 | | private static void Suspend(GoogleUser googleUser, DateTime at) |
| | | 282 | | { |
| | 2 | 283 | | googleUser.IsDeleted = true; |
| | 2 | 284 | | googleUser.DeletedAt = at; |
| | 2 | 285 | | googleUser.DeletionKind = (int)DeletionKinds.SubjectRequested; |
| | 2 | 286 | | } |
| | | 287 | | |
| | | 288 | | private static DataOutput<RequestErasureCommandOutput?> Recorded( |
| | | 289 | | DataOutput<RequestErasureCommandOutput?> output, |
| | | 290 | | Guid id, |
| | | 291 | | DateTime requestedAt, |
| | | 292 | | DateTime dueAt, |
| | | 293 | | bool blocked, |
| | | 294 | | string? blockedReason, |
| | | 295 | | bool restrictionLifted) => |
| | 16 | 296 | | output |
| | 16 | 297 | | .WithData(new RequestErasureCommandOutput |
| | 16 | 298 | | { |
| | 16 | 299 | | Id = id, |
| | 16 | 300 | | RequestedAt = requestedAt, |
| | 16 | 301 | | DueAt = dueAt, |
| | 16 | 302 | | Blocked = blocked, |
| | 16 | 303 | | BlockedReason = blockedReason, |
| | 16 | 304 | | RestrictionLifted = restrictionLifted |
| | 16 | 305 | | }) |
| | 16 | 306 | | .WithMessage(blocked ? ErasureMessages.ErasureRequestedButBlocked : ErasureMessages.ErasureRequested); |
| | | 307 | | } |