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