< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.UpdatePersonCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/UpdatePersonCommandHandler.cs
Line coverage
95%
Covered lines: 160
Uncovered lines: 7
Coverable lines: 167
Total lines: 527
Line coverage: 95.8%
Branch coverage
93%
Covered branches: 116
Total branches: 124
Branch coverage: 93.5%
Method coverage
100%
Covered methods: 14
Fully covered methods: 12
Total methods: 14
Method coverage: 100%
Full method coverage: 85.7%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()90.32%636293.97%
RetireAddressLinksAsync()100%22100%
get_Error()100%11100%
.cctor()100%11100%
Refused(...)100%11100%
ReauthenticateAsync()100%2222100%
SpendFactorAsync()75%141275%
MayUpdateAsync()100%1010100%
get_Applies()100%11100%
None(...)100%11100%
Refused(...)100%11100%
ValidateRoleChange(...)100%88100%
EmailTakenAsync()100%66100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/UpdatePersonCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Data.Relational.Core.Repositories;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Command.Services;
 6using ArturRios.Heimdall.Domain.Entities;
 7using ArturRios.Heimdall.Domain.Enums;
 8using ArturRios.Heimdall.Domain.Persistence;
 9using ArturRios.Heimdall.Shared.Messages;
 10using ArturRios.Heimdall.Shared.Services;
 11using ArturRios.Mediator.Command.Interfaces;
 12using ArturRios.Output;
 13using FluentValidation;
 14using Microsoft.EntityFrameworkCore;
 15
 16namespace ArturRios.Heimdall.Command.Handlers;
 17
 18/// <summary>
 19///     Handles <see cref="UpdatePersonCommand" /> (UC-08): validates the request, loads the person
 20///     (AF-08a), enforces the per-actor rule, applies an optional role change (AF-08c, AF-08f), then
 21///     the name and email — re-checking uniqueness per FR-PE-09 and clearing <c>EmailVerified</c>
 22///     when the address changes (AF-08b). All failures are returned as errors on the output rather
 23///     than thrown.
 24/// </summary>
 25/// <remarks>
 26///     <para>
 27///         <b>Why an own email change needs the password.</b> An address change requires proof of
 28///         identity, because recovery follows the address, and a bearer token is not proof that the
 29///         account holder is present. So a caller changing their own address presents their
 30///         current password, and their second factor when it is active (FR-PE-14), checked as UC-39
 31///         checks them, charged to the same re-authentication budget, and answered with UC-39's one
 32///         refusal (AF-08h, AF-08i). Both are checked before
 33///         anything that depends on the new address, so a caller without them cannot even learn
 34///         whether it is taken. An administrator changing someone else's address presents nothing of
 35///         theirs: the administrator's own authorisation covers it, as it always has.
 36///     </para>
 37///     <para>
 38///         <b>What follows any email change.</b> Whoever made it, the person's security stamp is
 39///         replaced, which revokes every token they hold (FR-AU-11), every live verification and
 40///         password-reset link is retired (FR-PE-17), and the previous address is told (FR-PE-15) —
 41///         so a change its owner did not make does not go unnoticed, and nothing mailed to the old
 42///         address still acts on the account.
 43///     </para>
 44/// </remarks>
 10745public class UpdatePersonCommandHandler(
 10746    IValidator<UpdatePersonCommand> validator,
 10747    IAsyncReadOnlyRepository<Person, long> personReader,
 10748    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 10749    IScopeOwnershipChecker scopeOwnership,
 10750    IAsyncRepository<EmailVerificationToken, long> verificationTokenWriter,
 10751    IAsyncRepository<PasswordResetToken, long> resetTokenWriter,
 10752    IAtomicWrites atomicWrites,
 10753    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 10754    ITwoFactorFactorVerifier factorVerifier,
 10755    IEmailChangeNoticeSender emailChangeNoticeSender,
 10756    AdminEmailOptions emailOptions,
 10757    IRetryAfterAdvisor retryAfter)
 58    : ICommandHandlerAsync<UpdatePersonCommand, UpdatePersonCommandOutput>
 59{
 60    /// <summary>The length of an authenticator-app or email code, which is all digits; a recovery code is not.</summary
 61    private const int CodeLength = 6;
 62
 63    public async Task<DataOutput<UpdatePersonCommandOutput?>> HandleAsync(UpdatePersonCommand command, CancellationToken
 64    {
 11765        var output = DataOutput<UpdatePersonCommandOutput?>.New;
 66
 67        // Step 2: validate input shape.
 11768        var validation = await validator.ValidateAsync(command);
 69
 11770        if (!validation.IsValid)
 71        {
 272            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 73        }
 74
 75        // AF-08a: the person must exist and not be logically deleted. The membership is included
 76        // because severing it is how its join row gets deleted (see the design doc), the ownerships
 77        // because the response reports them, and each join row's Scope because the response reports
 78        // scope PublicIds.
 11679        var person = await personReader.Query()
 11680            .Include(x => x.ScopeMembership)
 11681            .ThenInclude(membership => membership!.Scope)
 11682            .Include(x => x.ScopeOwnerships)
 11683            .ThenInclude(ownership => ownership.Scope)
 11684            .FirstOrDefaultAsync(x => x.PublicId == command.Id && !x.IsDeleted);
 85
 11686        if (person is null)
 87        {
 388            return output.WithError(PersonMessages.PersonNotFound);
 89        }
 90
 91        // UC-08 step 3: a System Admin may update anyone; anyone may update themselves; a Scope Admin
 92        // may update a User of a scope they own.
 11393        if (!await MayUpdateAsync(command, person))
 94        {
 495            return output.WithError(PersonMessages.NotAuthorizedToUpdatePerson);
 96        }
 97
 98        // UC-08 step 5, decided but not yet applied: every refusal below has to leave the loaded
 99        // person untouched, so the role change is validated first and written further down, once
 100        // nothing else can reject the request.
 109101        var roleChange = ValidateRoleChange(command, person);
 102
 109103        if (roleChange.Error is not null)
 104        {
 5105            return output.WithError(roleChange.Error);
 106        }
 107
 108        // UC-08 step 4: an email change re-checks uniqueness and clears the verification flag.
 109        //
 110        // Detected ordinally, on the trimmed values as stored, and nothing else decides it: this one
 111        // flag gates re-authentication, the verification flag, the stamp rotation, the notice and the
 112        // token retirement below. A case-insensitive comparison folds distinct characters together
 113        // (final and medial sigma, for one), and two such addresses can name different mailboxes.
 114        // Read ordinally, a change of case is a change too, which asks for the password and sends a
 115        // notice: it fails closed.
 104116        var emailChanged = !string.Equals(person.Email.Trim(), command.Email.Trim(), StringComparison.Ordinal);
 117
 118        // What is written when it did change: trimmed, because sign-in and recovery match the stored
 119        // address exactly, case aside, and a padded copy would no longer be found. When it did not,
 120        // the stored value is left exactly as it is.
 104121        var newEmail = emailChanged ? command.Email.Trim() : person.Email;
 122
 123        // UC-08 step 4 (FR-PE-14, AF-08h, AF-08i): changing your own address needs your password, and
 124        // your second factor when it is active. Before the uniqueness check, so without them a caller
 125        // learns nothing about the new address either.
 104126        var reauthentication = emailChanged && command.ActingPersonId == person.PublicId
 104127            ? await ReauthenticateAsync(command, person)
 104128            : Reauthentication.NotRequired;
 129
 104130        if (reauthentication.Error is not null)
 131        {
 19132            return output.WithError(reauthentication.Error);
 133        }
 134
 135        // FR-PE-18 (AF-08k): not while the email sign-in method is enabled, active or pending — every
 136        // sign-in would then mail a code to an address nobody has proved is theirs. The person turns
 137        // it off, changes the address, and sets it up again there, which proves it. Only once the
 138        // credentials are accepted, so it tells nobody without them anything, and before the budget
 139        // or the address are touched and before a recovery or email code is spent. An authenticator
 140        // code accepted on the way has had its time step recorded (FR-2F-14), as any accepted app
 141        // code does, so a retry needs the next one. An administrator's change is their authority and
 142        // is not held back.
 85143        if (emailChanged && command.ActingPersonId == person.PublicId &&
 85144            await twoFactorReader.Query().AnyAsync(x => x.PersonId == person.Id && x.EmailEnabled))
 145        {
 3146            return output.WithError(PersonMessages.EmailSignInMethodEnabled);
 147        }
 148
 149        // A role change re-checks it too, even on an unchanged address (FR-PE-09). The namespace the
 150        // address has to be unique in is chosen by the role, so promoting a User to System Admin
 151        // moves their address out of their scope's space and into the system-wide admin one, where a
 152        // different set of people may already hold it. Checking only on an email change let that
 153        // promotion create two live admins sharing an address — after which UC-11's admin lookup
 154        // (FirstOrDefaultAsync) resolves to one of them and the other can never log in again.
 82155        if ((emailChanged || roleChange.Applies) &&
 82156            await EmailTakenAsync(command, person, roleChange.TargetRoleId))
 157        {
 5158            return output.WithError(PersonMessages.EmailAlreadyExists);
 159        }
 160
 161        // FR-PE-16 (AF-08j): an own change mails the previous address (FR-PE-15), so it is charged to
 162        // the person's account-email budget — after the password and factor were accepted, so a token
 163        // without them cannot spend it, and before a recovery or email code is spent or anything is
 164        // written, so a refusal spends neither and changes nothing. An accepted authenticator code has
 165        // had its time step recorded already (FR-2F-14), as any accepted app code does. Switching an
 166        // address back and forth therefore mails the address it leaves at most the budget's day. An
 167        // administrator's change is not charged: it is their authority, not the person's own request.
 77168        if (emailChanged && command.ActingPersonId == person.PublicId &&
 77169            AccountEmailBudget.RefusalOf(
 77170                await AccountEmailBudget.TryReserveAsync(atomicWrites, person, emailOptions),
 77171                person, emailOptions, retryAfter) is { } mailRefusal)
 172        {
 9173            return output.WithError(mailRefusal);
 174        }
 175
 176        // The second factor is spent only now, once nothing else can refuse the request — and by a
 177        // write only one request can win, as everywhere a code is accepted.
 68178        var spending = await SpendFactorAsync(reauthentication.Verification);
 179
 68180        if (spending is not null)
 181        {
 0182            return output.WithError(spending);
 183        }
 184
 185        // FR-PE-17: the links that act on the account through its address — password reset and
 186        // verification — were mailed to the address being replaced, and go with it. Retired here,
 187        // before the new address is written, so none can be followed once it is; and again after
 188        // the save, for any mailed to the old address in between. Here also means before the person
 189        // is modified below, since each retirement saves. A reset or verification that read the
 190        // person before the change cannot write after it: both write only while the address is the
 191        // one they read (IAtomicWrites.TrySetPasswordAsync, TryMarkEmailVerifiedAsync).
 68192        if (emailChanged && await RetireAddressLinksAsync(person.Id) is { } earlyRetirement)
 193        {
 0194            return output.WithErrors(earlyRetirement);
 195        }
 196
 68197        var previousEmail = person.Email;
 198
 199        // FR-AU-11: the address is what account recovery is sent to, so a new one is a credential
 200        // change and revokes every token the person holds — whoever makes the change, and when they
 201        // make it themselves, the token making it too. Before the save, in its own targeted write: if
 202        // the save then fails, the cost is one extra sign-in, where the other order could leave a
 203        // changed address with its old sessions live.
 68204        if (emailChanged)
 205        {
 36206            await atomicWrites.RotateSecurityStampAsync(person);
 207        }
 208
 209        // UC-08 step 6: apply and stamp UpdatedAt (no DB trigger maintains it). The name is written by a
 210        // targeted write of its own, and only while the person is not deleted and holds the role read
 211        // (#205): a whole-row save of the copy read above would write every other column back as it was
 212        // read, and AF-08a and step 3's authorisation, decided at the read, would not be asked again.
 68213        var now = DateTime.UtcNow;
 214
 68215        if (emailChanged || roleChange.Applies)
 216        {
 217            // UC-08 steps 4 and 5 (#237): the address — with EmailVerified cleared beside it (AF-08b)
 218            // — and the role are written by one targeted write, only while the person still holds
 219            // the address and role they were read with, so a change that landed since is never
 220            // overwritten. The role change removes the membership row with it (FR-PE-10); the
 221            // ownership rows stay (#224): a System Admin may be a named owner (FR-SC-08), and dropping
 222            // them would leave a scope the person alone owned, and the applications they own, without
 223            // a valid owner. The rename runs in its transaction, once the change is written.
 42224            var change = await atomicWrites.TryChangeEmailAndRoleAsync(
 42225                person, newEmail, roleChange.TargetRoleId, now,
 83226                () => atomicWrites.TryRenameAsync(person, command.Name, now));
 227
 228            switch (change)
 229            {
 230                // Not expected — the change has just written the row, live, in the same transaction —
 231                // but a refused rename means the person is deleted, which is AF-08a, as on the path
 232                // below.
 233                case AccountChangeOutcome.SaveFailed:
 0234                    return output.WithError(PersonMessages.PersonNotFound);
 235                case AccountChangeOutcome.AddressTaken:
 0236                    return output.WithError(RelationalErrors.UniqueViolationMessage);
 237                case AccountChangeOutcome.Stale:
 1238                    return output.WithError(RelationalErrors.ConcurrencyMessage);
 239            }
 240        }
 26241        else if (!await atomicWrites.TryRenameAsync(person, command.Name, now))
 242        {
 243            // Deleted — suspended for erasure, say — since the read above is AF-08a; still live but no
 244            // longer holding the role read, which decided who may rename them, is AF-08l. The row is
 245            // read again, past the tracked copy, to tell which.
 3246            var deleted = await personReader.Query()
 3247                .Where(x => x.Id == person.Id)
 3248                .Select(x => (bool?)x.IsDeleted)
 3249                .FirstOrDefaultAsync();
 250
 3251            return output.WithError(deleted == false
 3252                ? RelationalErrors.ConcurrencyMessage
 3253                : PersonMessages.PersonNotFound);
 254        }
 255
 256        // FR-AU-11 again, once the new address is saved: a sign-in between the first rotation and
 257        // the save read the first new stamp beside the old address, and its token must not outlive
 258        // the change. The first rotation is what makes a failed save safe; this one closes that gap.
 64259        if (emailChanged)
 260        {
 35261            await atomicWrites.RotateSecurityStampAsync(person);
 262
 263            // UC-08 step 4, continued (FR-PE-17), the second pass of the two, made the moment the
 264            // new address is saved and before anything slower, such as the notice below. A
 265            // verification token is proof of control of the address it was mailed to, and that is
 266            // no longer the person's address: left live, one mailed to the old address would mark
 267            // the new one verified through UC-14 without anybody having read anything sent to it.
 268            // A reset token mailed there would let that mailbox set the password. This pass catches
 269            // any mailed to the old address after the first one; a link followed from now on finds
 270            // it spent.
 35271            var retirement = await RetireAddressLinksAsync(person.Id);
 272
 273            // FR-PE-15: the previous address is told. Only that address is handed over, so the
 274            // notice cannot disclose the new one; a failed delivery is the sender's to log, and does
 275            // not fail an update that has already been made. Sent even when the retirement failed:
 276            // the address has changed either way, and its owner is owed the notice.
 35277            await emailChangeNoticeSender.SendAsync(previousEmail);
 278
 35279            if (retirement is not null)
 280            {
 0281                return output.WithErrors(retirement);
 282            }
 35283        }
 284
 285        // UC-08 step 7: return the updated person. Its ownerships as FR-PE-19 lets the actor see
 286        // them: every one to a System Admin or the person themselves, otherwise only those of scopes
 287        // the actor owns too. A Scope Admin can update only a User, who owns none (FR-SC-08), so the
 288        // trim normally removes nothing; it keeps the output from saying more than a read would.
 64289        var visibleScopeIds = command.ActingPersonId == person.PublicId
 64290            ? null
 64291            : await OwnedScopeVisibility.ScopesVisibleToAsync(personReader, command.ActingRole, command.ActingPersonId);
 292
 64293        return output
 64294            .WithData(new UpdatePersonCommandOutput
 64295            {
 64296                Id = person.PublicId,
 64297                Name = person.Name,
 64298                Email = person.Email,
 64299                Role = (int)person.RoleId,
 64300                EmailVerified = person.EmailVerified,
 64301                ScopeId = person.ScopeMembership?.Scope.PublicId,
 64302                OwnedScopeIds = person.ScopeOwnerships
 11303                    .Where(ownership => visibleScopeIds is null || visibleScopeIds.Contains(ownership.ScopeId))
 9304                    .Select(ownership => ownership.Scope.PublicId)
 64305                    .ToList(),
 64306                CreatedAt = person.CreatedAt,
 64307                UpdatedAt = person.UpdatedAt
 64308            })
 64309            .WithMessage(PersonMessages.PersonUpdatedSuccessfully);
 117310    }
 311
 312    /// <summary>
 313    ///     FR-PE-17: retires every live verification and password-reset link the person holds.
 314    ///     Returns the persistence errors of the first failed write, or <c>null</c>.
 315    /// </summary>
 316    private async Task<IEnumerable<string>?> RetireAddressLinksAsync(long personId)
 317    {
 71318        var now = DateTime.UtcNow;
 319
 71320        return await EmailVerificationTokenRetirement.RetireLiveAsync(
 71321                   verificationTokenWriter, verificationTokenWriter, personId, now)
 71322               ?? await PasswordResetTokenRetirement.RetireLiveAsync(
 71323                   resetTokenWriter, resetTokenWriter, personId, now);
 71324    }
 325
 326    /// <summary>
 327    ///     The outcome of re-authenticating a caller who changes their own email (FR-PE-14).
 328    /// </summary>
 329    /// <param name="Error">The canonical refusal, or <c>null</c> when the caller proved who they are.</param>
 330    /// <param name="Verification">
 331    ///     The second factor that matched, still to be spent, or <c>null</c> when none was required.
 332    /// </param>
 242333    private sealed record Reauthentication(string? Error, TwoFactorFactorVerificationResult? Verification)
 334    {
 2335        public static readonly Reauthentication NotRequired = new(null, null);
 336
 19337        public static Reauthentication Refused(string error) => new(error, null);
 338    }
 339
 340    /// <summary>
 341    ///     FR-PE-14: the current password, through <see cref="PasswordHashGate" /> as every password
 342    ///     check is (Threat Model TH-03), then — when the person's two-factor authentication is
 343    ///     active — a current second factor, through the same <see cref="ITwoFactorFactorVerifier" />
 344    ///     UC-38, UC-39 and UC-40 use. Charged, once, to the account's re-authentication budget
 345    ///     (<see cref="ReauthenticationBudget" />, FR-AU-13), and refused as UC-39 refuses: one
 346    ///     message for a wrong password, a wrong factor and a paused budget alike.
 347    /// </summary>
 348    /// <remarks>
 349    ///     One field carries the second factor, so its shape decides how it is checked: six digits are
 350    ///     an authenticator-app or email code, anything else a recovery code, which is never six
 351    ///     characters long (<c>XXXX-XXXX-XXXX-XXXX</c>, or <c>XXXX-XXXX</c> for one issued before
 352    ///     those). A missing one is checked as a code and so refused like a wrong one.
 353    /// </remarks>
 354    private async Task<Reauthentication> ReauthenticateAsync(UpdatePersonCommand command, Person person)
 355    {
 356        // FR-AU-13: charged before anything is compared. A paused budget still has the credentials
 357        // compared, and refuses whatever the result.
 49358        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 359
 360        // AF-08h: missing or wrong alike. Decided below, once the factor has been checked too, so a
 361        // wrong password takes as long to refuse as a wrong factor does.
 49362        var passwordMatches = !string.IsNullOrWhiteSpace(command.CurrentPassword) &&
 49363                              await PasswordHashGate.Shared.TextMatchesAsync(
 49364                                  command.CurrentPassword, person.PasswordHash, person.Salt);
 365
 49366        var twoFactorAuth = await twoFactorReader.Query()
 49367            .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive);
 368
 49369        TwoFactorFactorVerificationResult? verification = null;
 370
 49371        if (twoFactorAuth is not null)
 372        {
 13373            var factor = command.TwoFactorCode?.Trim();
 13374            var isCode = factor is null || (factor.Length == CodeLength && factor.All(char.IsAsciiDigit));
 375
 13376            verification = await factorVerifier.VerifyAsync(
 13377                twoFactorAuth, isCode ? factor : null, isCode ? null : factor);
 378        }
 379
 380        // AF-08h and AF-08i, answered alike, and the pause with them.
 49381        if (!passwordMatches || verification is { Matched: false } || !admitted)
 382        {
 19383            return Reauthentication.Refused(AuthMessages.PasswordOrCodeIncorrect);
 384        }
 385
 30386        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 387
 30388        return new Reauthentication(null, verification);
 49389    }
 390
 391    /// <summary>
 392    ///     Spends the email code or recovery code that re-authenticated the caller, exactly as UC-40
 393    ///     spends the one that authorised it. An app code needs nothing more: the verifier already
 394    ///     recorded its time step (FR-2F-14). Returns the refusal when another request spent the code
 395    ///     first, or <c>null</c>.
 396    /// </summary>
 397    private async Task<string?> SpendFactorAsync(TwoFactorFactorVerificationResult? verification)
 398    {
 68399        if (verification?.ConsumedEmailCode is { } emailCode &&
 68400            !await atomicWrites.TryConsumeEmailCodeAsync(emailCode))
 401        {
 0402            return AuthMessages.PasswordOrCodeIncorrect;
 403        }
 404
 68405        if (verification?.ConsumedRecoveryCode is { } recoveryCode &&
 68406            !await atomicWrites.TryConsumeRecoveryCodeAsync(recoveryCode, DateTime.UtcNow))
 407        {
 0408            return AuthMessages.PasswordOrCodeIncorrect;
 409        }
 410
 68411        return null;
 68412    }
 413
 414    /// <summary>
 415    ///     UC-08 step 3. A System Admin may update any person; any actor may update their own record;
 416    ///     a Scope Admin may update a <c>User</c> belonging to a scope they own. Everything else is
 417    ///     denied.
 418    /// </summary>
 419    private async Task<bool> MayUpdateAsync(UpdatePersonCommand command, Person person)
 420    {
 113421        if (command.ActingRole == (int)Roles.SystemAdmin || command.ActingPersonId == person.PublicId)
 422        {
 103423            return true;
 424        }
 425
 10426        if (command.ActingRole != (int)Roles.ScopeAdmin ||
 10427            person.RoleId != (long)Roles.User ||
 10428            person.ScopeMembership is null)
 429        {
 2430            return false;
 431        }
 432
 8433        return await scopeOwnership.ActorMayManageScopeAsync(
 8434            command.ActingRole, command.ActingPersonId, person.ScopeMembership.ScopeId);
 113435    }
 436
 437    /// <summary>
 438    ///     The outcome of examining UC-08's optional role change, before anything is written.
 439    /// </summary>
 440    /// <param name="Applies">Whether a role change was requested and is allowed to go ahead.</param>
 441    /// <param name="TargetRoleId">
 442    ///     The role the person will hold after this update — their current one when
 443    ///     <paramref name="Applies" /> is <see langword="false" />. The email-uniqueness rule is
 444    ///     chosen by this value, not by the role they hold now.
 445    /// </param>
 446    /// <param name="Error">The canonical message describing a refusal, or <c>null</c>.</param>
 386447    private sealed record RoleChange(bool Applies, long TargetRoleId, string? Error)
 448    {
 97449        public static RoleChange None(long currentRoleId) => new(false, currentRoleId, null);
 450
 451        public static RoleChange Refused(long currentRoleId, string error) =>
 5452            new(false, currentRoleId, error);
 453    }
 454
 455    /// <summary>
 456    ///     UC-08 step 5, as a decision rather than a mutation. Only a change to <c>SystemAdmin</c> is
 457    ///     supported: every other target role would need a scope the request does not carry
 458    ///     (FR-PE-02, FR-PE-11). So no change made here demotes a System Admin, and none can leave the
 459    ///     system without one (#203).
 460    /// </summary>
 461    /// <remarks>
 462    ///     Nothing is written here, deliberately. The uniqueness check that follows it can still
 463    ///     refuse the request, and a handler that had already rewritten the loaded person's role
 464    ///     would be relying on nothing in the same unit of work saving it — true today, since the person
 465    ///     is written only by targeted writes, but true only by accident and invisible from here.
 466    /// </remarks>
 467    private static RoleChange ValidateRoleChange(UpdatePersonCommand command, Person person)
 468    {
 109469        if (command.RoleId is null || command.RoleId == (int)person.RoleId)
 470        {
 97471            return RoleChange.None(person.RoleId);
 472        }
 473
 474        // AF-08c: only a System Admin may change a role.
 12475        if (command.ActingRole != (int)Roles.SystemAdmin)
 476        {
 2477            return RoleChange.Refused(person.RoleId, PersonMessages.RoleChangeRequiresSystemAdmin);
 478        }
 479
 10480        if (command.RoleId != (int)Roles.SystemAdmin)
 481        {
 3482            return RoleChange.Refused(person.RoleId, PersonMessages.UnsupportedRoleTransition);
 483        }
 484
 485        // NFR-12 is not at stake: the promotion keeps the person's ownerships (#224), and a System Admin
 486        // owner counts as one like any other.
 7487        return new RoleChange(true, (long)Roles.SystemAdmin, null);
 488    }
 489
 490    /// <summary>
 491    ///     FR-PE-09, evaluated against the role the person will have after this update: a
 492    ///     <c>User</c>'s email is unique within their scope — jointly with that scope's Google Users,
 493    ///     per FR-GO-07 — and an admin's is unique system-wide. The person being updated is excluded
 494    ///     so resubmitting their own address is not a conflict. Compared case-insensitively
 495    ///     (<c>LOWER()</c> in SQL), as UC-06 does.
 496    /// </summary>
 497    private async Task<bool> EmailTakenAsync(UpdatePersonCommand command, Person person, long targetRoleId)
 498    {
 56499        var email = command.Email.Trim().ToLower();
 500
 56501        if (targetRoleId == (long)Roles.User && person.ScopeMembership is not null)
 502        {
 38503            var scopeId = person.ScopeMembership.ScopeId;
 504
 38505            var takenByPerson = await personReader.Query().AnyAsync(other =>
 38506                other.Id != person.Id && !other.IsDeleted && other.Email.ToLower() == email &&
 38507                other.ScopeMembership != null && other.ScopeMembership.ScopeId == scopeId);
 508
 38509            if (takenByPerson)
 510            {
 2511                return true;
 512            }
 513
 514            // The scope's address space is shared with its Google Users (FR-GO-07), so moving a
 515            // User onto an address a Google User already holds is the same conflict as moving them
 516            // onto another User's — the same second read CreateUserCommandHandler makes.
 36517            return await googleUserReader.Query().AnyAsync(googleUser =>
 36518                googleUser.ScopeId == scopeId && googleUser.Email.ToLower() == email);
 519        }
 520
 521        // An admin belongs to no scope, so no Google User can share their namespace: FR-GO-04 makes
 522        // every Google account User-equivalent within one scope.
 18523        return await personReader.Query().AnyAsync(other =>
 18524            other.Id != person.Id && !other.IsDeleted && other.Email.ToLower() == email &&
 18525            (other.RoleId == (long)Roles.SystemAdmin || other.RoleId == (long)Roles.ScopeAdmin));
 56526    }
 527}