< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.UpdatePersonCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/UpdatePersonCommandHandler.cs
Line coverage
96%
Covered lines: 170
Uncovered lines: 6
Coverable lines: 176
Total lines: 523
Line coverage: 96.5%
Branch coverage
94%
Covered branches: 115
Total branches: 122
Branch coverage: 94.2%
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()92.85%565695.06%
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%
ValidateRoleChangeAsync()100%1414100%
EmailTakenAsync()100%66100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/UpdatePersonCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Heimdall.Shared.Services;
 10using ArturRios.Mediator.Command.Interfaces;
 11using ArturRios.Output;
 12using FluentValidation;
 13using Microsoft.EntityFrameworkCore;
 14
 15namespace ArturRios.Heimdall.Command.Handlers;
 16
 17/// <summary>
 18///     Handles <see cref="UpdatePersonCommand" /> (UC-08): validates the request, loads the person
 19///     (AF-08a), enforces the per-actor rule, applies an optional role change (AF-08c, NFR-12), then
 20///     the name and email — re-checking uniqueness per FR-PE-09 and clearing <c>EmailVerified</c>
 21///     when the address changes (AF-08b). All failures are returned as errors on the output rather
 22///     than thrown.
 23/// </summary>
 24/// <remarks>
 25///     <para>
 26///         <b>Why an own email change needs the password.</b> An address change requires proof of
 27///         identity, because recovery follows the address, and a bearer token is not proof that the
 28///         account holder is present. So a caller changing their own address presents their
 29///         current password, and their second factor when it is active (FR-PE-14), checked as UC-39
 30///         checks them, charged to the same re-authentication budget, and answered with UC-39's one
 31///         refusal (AF-08h, AF-08i). Both are checked before
 32///         anything that depends on the new address, so a caller without them cannot even learn
 33///         whether it is taken. An administrator changing someone else's address presents nothing of
 34///         theirs: the administrator's own authorisation covers it, as it always has.
 35///     </para>
 36///     <para>
 37///         <b>What follows any email change.</b> Whoever made it, the person's security stamp is
 38///         replaced, which revokes every token they hold (FR-AU-11), every live verification and
 39///         password-reset link is retired (FR-PE-17), and the previous address is told (FR-PE-15) —
 40///         so a change its owner did not make does not go unnoticed, and nothing mailed to the old
 41///         address still acts on the account.
 42///     </para>
 43/// </remarks>
 10144public class UpdatePersonCommandHandler(
 10145    IValidator<UpdatePersonCommand> validator,
 10146    IAsyncReadOnlyRepository<Person, long> personReader,
 10147    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 10148    IAsyncRepository<Person, long> personWriter,
 10149    IScopeOwnershipChecker scopeOwnership,
 10150    IAsyncRepository<EmailVerificationToken, long> verificationTokenWriter,
 10151    IAsyncRepository<PasswordResetToken, long> resetTokenWriter,
 10152    IAtomicWrites atomicWrites,
 10153    IAsyncReadOnlyRepository<TwoFactorAuth, long> twoFactorReader,
 10154    ITwoFactorFactorVerifier factorVerifier,
 10155    IEmailChangeNoticeSender emailChangeNoticeSender,
 10156    AdminEmailOptions emailOptions,
 10157    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    {
 11165        var output = DataOutput<UpdatePersonCommandOutput?>.New;
 66
 67        // Step 2: validate input shape.
 11168        var validation = await validator.ValidateAsync(command);
 69
 11170        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. Both scope navigations are
 76        // included because severing them is how the join rows get deleted (see the design doc), and
 77        // each join row's Scope is included too because the response reports scope PublicIds.
 11078        var person = await personReader.Query()
 11079            .Include(x => x.ScopeMembership)
 11080            .ThenInclude(membership => membership!.Scope)
 11081            .Include(x => x.ScopeOwnerships)
 11082            .ThenInclude(ownership => ownership.Scope)
 11083            .FirstOrDefaultAsync(x => x.PublicId == command.Id && !x.IsDeleted);
 84
 11085        if (person is null)
 86        {
 387            return output.WithError(PersonMessages.PersonNotFound);
 88        }
 89
 90        // UC-08 step 3: a System Admin may update anyone; anyone may update themselves; a Scope Admin
 91        // may update a User of a scope they own.
 10792        if (!await MayUpdateAsync(command, person))
 93        {
 494            return output.WithError(PersonMessages.NotAuthorizedToUpdatePerson);
 95        }
 96
 97        // UC-08 step 5, decided but not yet applied: every refusal below has to leave the loaded
 98        // person untouched, so the role change is validated first and written further down, once
 99        // nothing else can reject the request.
 103100        var roleChange = await ValidateRoleChangeAsync(command, person);
 101
 103102        if (roleChange.Error is not null)
 103        {
 7104            return output.WithError(roleChange.Error);
 105        }
 106
 107        // UC-08 step 4: an email change re-checks uniqueness and clears the verification flag.
 108        //
 109        // Detected ordinally, on the trimmed values as stored, and nothing else decides it: this one
 110        // flag gates re-authentication, the verification flag, the stamp rotation, the notice and the
 111        // token retirement below. A case-insensitive comparison folds distinct characters together
 112        // (final and medial sigma, for one), and two such addresses can name different mailboxes.
 113        // Read ordinally, a change of case is a change too, which asks for the password and sends a
 114        // notice: it fails closed.
 96115        var emailChanged = !string.Equals(person.Email.Trim(), command.Email.Trim(), StringComparison.Ordinal);
 116
 117        // What is written when it did change: trimmed, because sign-in and recovery match the stored
 118        // address exactly, case aside, and a padded copy would no longer be found. When it did not,
 119        // the stored value is left exactly as it is.
 96120        var newEmail = emailChanged ? command.Email.Trim() : person.Email;
 121
 122        // UC-08 step 4 (FR-PE-14, AF-08h, AF-08i): changing your own address needs your password, and
 123        // your second factor when it is active. Before the uniqueness check, so without them a caller
 124        // learns nothing about the new address either.
 96125        var reauthentication = emailChanged && command.ActingPersonId == person.PublicId
 96126            ? await ReauthenticateAsync(command, person)
 96127            : Reauthentication.NotRequired;
 128
 96129        if (reauthentication.Error is not null)
 130        {
 19131            return output.WithError(reauthentication.Error);
 132        }
 133
 134        // FR-PE-18 (AF-08k): not while the email sign-in method is enabled, active or pending — every
 135        // sign-in would then mail a code to an address nobody has proved is theirs. The person turns
 136        // it off, changes the address, and sets it up again there, which proves it. Only once the
 137        // credentials are accepted, so it tells nobody without them anything, and before the budget
 138        // or the address are touched and before a recovery or email code is spent. An authenticator
 139        // code accepted on the way has had its time step recorded (FR-2F-14), as any accepted app
 140        // code does, so a retry needs the next one. An administrator's change is their authority and
 141        // is not held back.
 77142        if (emailChanged && command.ActingPersonId == person.PublicId &&
 77143            await twoFactorReader.Query().AnyAsync(x => x.PersonId == person.Id && x.EmailEnabled))
 144        {
 3145            return output.WithError(PersonMessages.EmailSignInMethodEnabled);
 146        }
 147
 148        // A role change re-checks it too, even on an unchanged address (FR-PE-09). The namespace the
 149        // address has to be unique in is chosen by the role, so promoting a User to System Admin
 150        // moves their address out of their scope's space and into the system-wide admin one, where a
 151        // different set of people may already hold it. Checking only on an email change let that
 152        // promotion create two live admins sharing an address — after which UC-11's admin lookup
 153        // (FirstOrDefaultAsync) resolves to one of them and the other can never log in again.
 74154        if ((emailChanged || roleChange.Applies) &&
 74155            await EmailTakenAsync(command, person, roleChange.TargetRoleId))
 156        {
 5157            return output.WithError(PersonMessages.EmailAlreadyExists);
 158        }
 159
 160        // FR-PE-16 (AF-08j): an own change mails the previous address (FR-PE-15), so it is charged to
 161        // the person's account-email budget — after the password and factor were accepted, so a token
 162        // without them cannot spend it, and before a recovery or email code is spent or anything is
 163        // written, so a refusal spends neither and changes nothing. An accepted authenticator code has
 164        // had its time step recorded already (FR-2F-14), as any accepted app code does. Switching an
 165        // address back and forth therefore mails the address it leaves at most the budget's day. An
 166        // administrator's change is not charged: it is their authority, not the person's own request.
 69167        if (emailChanged && command.ActingPersonId == person.PublicId &&
 69168            AccountEmailBudget.RefusalOf(
 69169                await AccountEmailBudget.TryReserveAsync(atomicWrites, person, emailOptions),
 69170                person, emailOptions, retryAfter) is { } mailRefusal)
 171        {
 9172            return output.WithError(mailRefusal);
 173        }
 174
 175        // The second factor is spent only now, once nothing else can refuse the request — and by a
 176        // write only one request can win, as everywhere a code is accepted.
 60177        var spending = await SpendFactorAsync(reauthentication.Verification);
 178
 60179        if (spending is not null)
 180        {
 0181            return output.WithError(spending);
 182        }
 183
 184        // FR-PE-17: the links that act on the account through its address — password reset and
 185        // verification — were mailed to the address being replaced, and go with it. Retired here,
 186        // before the new address is written, so none can be followed once it is; and again after
 187        // the save, for any mailed to the old address in between. Here also means before the person
 188        // is modified below, since each retirement saves. A reset or verification that read the
 189        // person before the change cannot write after it: both write only while the address is the
 190        // one they read (IAtomicWrites.TrySetPasswordAsync, TryMarkEmailVerifiedAsync).
 60191        if (emailChanged && await RetireAddressLinksAsync(person.Id) is { } earlyRetirement)
 192        {
 0193            return output.WithErrors(earlyRetirement);
 194        }
 195
 60196        var previousEmail = person.Email;
 197
 60198        if (emailChanged)
 199        {
 34200            person.EmailVerified = false;
 201        }
 202
 203        // FR-PE-10: a System Admin belongs to no scope, so the membership and ownership rows are
 204        // severed — which deletes them, since both relationships are required and cascade.
 60205        if (roleChange.Applies)
 206        {
 4207            person.RoleId = roleChange.TargetRoleId;
 4208            person.ScopeMembership = null;
 4209            person.ScopeId = null;
 4210            person.ScopeOwnerships.Clear();
 211        }
 212
 213        // FR-AU-11: the address is what account recovery is sent to, so a new one is a credential
 214        // change and revokes every token the person holds — whoever makes the change, and when they
 215        // make it themselves, the token making it too. Before the save, in its own targeted write: if
 216        // the save then fails, the cost is one extra sign-in, where the other order could leave a
 217        // changed address with its old sessions live.
 60218        if (emailChanged)
 219        {
 34220            await atomicWrites.RotateSecurityStampAsync(person);
 221        }
 222
 223        // UC-08 step 6: apply and stamp UpdatedAt (no DB trigger maintains it).
 60224        person.Name = command.Name;
 60225        person.Email = newEmail;
 60226        person.UpdatedAt = DateTime.UtcNow;
 227
 60228        var update = await personWriter.UpdateAsync(person);
 229
 60230        if (!update.Success)
 231        {
 0232            return output.WithErrors(update.Errors);
 233        }
 234
 235        // FR-AU-11 again, once the new address is saved: a sign-in between the first rotation and
 236        // the save read the first new stamp beside the old address, and its token must not outlive
 237        // the change. The first rotation is what makes a failed save safe; this one closes that gap.
 60238        if (emailChanged)
 239        {
 34240            await atomicWrites.RotateSecurityStampAsync(person);
 241
 242            // UC-08 step 4, continued (FR-PE-17), the second pass of the two, made the moment the
 243            // new address is saved and before anything slower, such as the notice below. A
 244            // verification token is proof of control of the address it was mailed to, and that is
 245            // no longer the person's address: left live, one mailed to the old address would mark
 246            // the new one verified through UC-14 without anybody having read anything sent to it.
 247            // A reset token mailed there would let that mailbox set the password. This pass catches
 248            // any mailed to the old address after the first one; a link followed from now on finds
 249            // it spent.
 34250            var retirement = await RetireAddressLinksAsync(person.Id);
 251
 252            // FR-PE-15: the previous address is told. Only that address is handed over, so the
 253            // notice cannot disclose the new one; a failed delivery is the sender's to log, and does
 254            // not fail an update that has already been made. Sent even when the retirement failed:
 255            // the address has changed either way, and its owner is owed the notice.
 34256            await emailChangeNoticeSender.SendAsync(previousEmail);
 257
 34258            if (retirement is not null)
 259            {
 0260                return output.WithErrors(retirement);
 261            }
 34262        }
 263
 264        // UC-08 step 7: return the updated person. Its ownerships as FR-PE-19 lets the actor see
 265        // them: every one to a System Admin or the person themselves, otherwise only those of scopes
 266        // the actor owns too. A Scope Admin can update only a User, who owns none (FR-SC-08), so the
 267        // trim normally removes nothing; it keeps the output from saying more than a read would.
 60268        var visibleScopeIds = command.ActingPersonId == person.PublicId
 60269            ? null
 60270            : await OwnedScopeVisibility.ScopesVisibleToAsync(personReader, command.ActingRole, command.ActingPersonId);
 271
 60272        return output
 60273            .WithData(new UpdatePersonCommandOutput
 60274            {
 60275                Id = person.PublicId,
 60276                Name = person.Name,
 60277                Email = person.Email,
 60278                Role = (int)person.RoleId,
 60279                EmailVerified = person.EmailVerified,
 60280                ScopeId = person.ScopeMembership?.Scope.PublicId,
 60281                OwnedScopeIds = person.ScopeOwnerships
 8282                    .Where(ownership => visibleScopeIds is null || visibleScopeIds.Contains(ownership.ScopeId))
 6283                    .Select(ownership => ownership.Scope.PublicId)
 60284                    .ToList(),
 60285                CreatedAt = person.CreatedAt,
 60286                UpdatedAt = person.UpdatedAt
 60287            })
 60288            .WithMessage(PersonMessages.PersonUpdatedSuccessfully);
 111289    }
 290
 291    /// <summary>
 292    ///     FR-PE-17: retires every live verification and password-reset link the person holds.
 293    ///     Returns the persistence errors of the first failed write, or <c>null</c>.
 294    /// </summary>
 295    private async Task<IEnumerable<string>?> RetireAddressLinksAsync(long personId)
 296    {
 68297        var now = DateTime.UtcNow;
 298
 68299        return await EmailVerificationTokenRetirement.RetireLiveAsync(
 68300                   verificationTokenWriter, verificationTokenWriter, personId, now)
 68301               ?? await PasswordResetTokenRetirement.RetireLiveAsync(
 68302                   resetTokenWriter, resetTokenWriter, personId, now);
 68303    }
 304
 305    /// <summary>
 306    ///     The outcome of re-authenticating a caller who changes their own email (FR-PE-14).
 307    /// </summary>
 308    /// <param name="Error">The canonical refusal, or <c>null</c> when the caller proved who they are.</param>
 309    /// <param name="Verification">
 310    ///     The second factor that matched, still to be spent, or <c>null</c> when none was required.
 311    /// </param>
 226312    private sealed record Reauthentication(string? Error, TwoFactorFactorVerificationResult? Verification)
 313    {
 2314        public static readonly Reauthentication NotRequired = new(null, null);
 315
 19316        public static Reauthentication Refused(string error) => new(error, null);
 317    }
 318
 319    /// <summary>
 320    ///     FR-PE-14: the current password, through <see cref="PasswordHashGate" /> as every password
 321    ///     check is (Threat Model TH-03), then — when the person's two-factor authentication is
 322    ///     active — a current second factor, through the same <see cref="ITwoFactorFactorVerifier" />
 323    ///     UC-38, UC-39 and UC-40 use. Charged, once, to the account's re-authentication budget
 324    ///     (<see cref="ReauthenticationBudget" />, FR-AU-13), and refused as UC-39 refuses: one
 325    ///     message for a wrong password, a wrong factor and a paused budget alike.
 326    /// </summary>
 327    /// <remarks>
 328    ///     One field carries the second factor, so its shape decides how it is checked: six digits are
 329    ///     an authenticator-app or email code, anything else a recovery code, which is never six
 330    ///     characters long (<c>XXXX-XXXX-XXXX-XXXX</c>, or <c>XXXX-XXXX</c> for one issued before
 331    ///     those). A missing one is checked as a code and so refused like a wrong one.
 332    /// </remarks>
 333    private async Task<Reauthentication> ReauthenticateAsync(UpdatePersonCommand command, Person person)
 334    {
 335        // FR-AU-13: charged before anything is compared. A paused budget still has the credentials
 336        // compared, and refuses whatever the result.
 49337        var admitted = await ReauthenticationBudget.TryChargeAsync(atomicWrites, person);
 338
 339        // AF-08h: missing or wrong alike. Decided below, once the factor has been checked too, so a
 340        // wrong password takes as long to refuse as a wrong factor does.
 49341        var passwordMatches = !string.IsNullOrWhiteSpace(command.CurrentPassword) &&
 49342                              await PasswordHashGate.Shared.TextMatchesAsync(
 49343                                  command.CurrentPassword, person.PasswordHash, person.Salt);
 344
 49345        var twoFactorAuth = await twoFactorReader.Query()
 49346            .FirstOrDefaultAsync(x => x.PersonId == person.Id && x.IsActive);
 347
 49348        TwoFactorFactorVerificationResult? verification = null;
 349
 49350        if (twoFactorAuth is not null)
 351        {
 13352            var factor = command.TwoFactorCode?.Trim();
 13353            var isCode = factor is null || (factor.Length == CodeLength && factor.All(char.IsAsciiDigit));
 354
 13355            verification = await factorVerifier.VerifyAsync(
 13356                twoFactorAuth, isCode ? factor : null, isCode ? null : factor);
 357        }
 358
 359        // AF-08h and AF-08i, answered alike, and the pause with them.
 49360        if (!passwordMatches || verification is { Matched: false } || !admitted)
 361        {
 19362            return Reauthentication.Refused(AuthMessages.PasswordOrCodeIncorrect);
 363        }
 364
 30365        await ReauthenticationBudget.ClearAsync(atomicWrites, person);
 366
 30367        return new Reauthentication(null, verification);
 49368    }
 369
 370    /// <summary>
 371    ///     Spends the email code or recovery code that re-authenticated the caller, exactly as UC-40
 372    ///     spends the one that authorised it. An app code needs nothing more: the verifier already
 373    ///     recorded its time step (FR-2F-14). Returns the refusal when another request spent the code
 374    ///     first, or <c>null</c>.
 375    /// </summary>
 376    private async Task<string?> SpendFactorAsync(TwoFactorFactorVerificationResult? verification)
 377    {
 60378        if (verification?.ConsumedEmailCode is { } emailCode &&
 60379            !await atomicWrites.TryConsumeEmailCodeAsync(emailCode))
 380        {
 0381            return AuthMessages.PasswordOrCodeIncorrect;
 382        }
 383
 60384        if (verification?.ConsumedRecoveryCode is { } recoveryCode &&
 60385            !await atomicWrites.TryConsumeRecoveryCodeAsync(recoveryCode, DateTime.UtcNow))
 386        {
 0387            return AuthMessages.PasswordOrCodeIncorrect;
 388        }
 389
 60390        return null;
 60391    }
 392
 393    /// <summary>
 394    ///     UC-08 step 3. A System Admin may update any person; any actor may update their own record;
 395    ///     a Scope Admin may update a <c>User</c> belonging to a scope they own. Everything else is
 396    ///     denied.
 397    /// </summary>
 398    private async Task<bool> MayUpdateAsync(UpdatePersonCommand command, Person person)
 399    {
 107400        if (command.ActingRole == (int)Roles.SystemAdmin || command.ActingPersonId == person.PublicId)
 401        {
 97402            return true;
 403        }
 404
 10405        if (command.ActingRole != (int)Roles.ScopeAdmin ||
 10406            person.RoleId != (long)Roles.User ||
 10407            person.ScopeMembership is null)
 408        {
 2409            return false;
 410        }
 411
 8412        return await scopeOwnership.ActorMayManageScopeAsync(
 8413            command.ActingRole, command.ActingPersonId, person.ScopeMembership.ScopeId);
 107414    }
 415
 416    /// <summary>
 417    ///     The outcome of examining UC-08's optional role change, before anything is written.
 418    /// </summary>
 419    /// <param name="Applies">Whether a role change was requested and is allowed to go ahead.</param>
 420    /// <param name="TargetRoleId">
 421    ///     The role the person will hold after this update — their current one when
 422    ///     <paramref name="Applies" /> is <see langword="false" />. The email-uniqueness rule is
 423    ///     chosen by this value, not by the role they hold now.
 424    /// </param>
 425    /// <param name="Error">The canonical message describing a refusal, or <c>null</c>.</param>
 356426    private sealed record RoleChange(bool Applies, long TargetRoleId, string? Error)
 427    {
 91428        public static RoleChange None(long currentRoleId) => new(false, currentRoleId, null);
 429
 430        public static RoleChange Refused(long currentRoleId, string error) =>
 7431            new(false, currentRoleId, error);
 432    }
 433
 434    /// <summary>
 435    ///     UC-08 step 5, as a decision rather than a mutation. Only a change to <c>SystemAdmin</c> is
 436    ///     supported: every other target role would need a scope the request does not carry
 437    ///     (FR-PE-02, FR-PE-11).
 438    /// </summary>
 439    /// <remarks>
 440    ///     Nothing is written here, deliberately. The uniqueness check that follows it can still
 441    ///     refuse the request, and a handler that had already rewritten the loaded person's role
 442    ///     would be relying on the caller never saving — true today, since a refusal returns before
 443    ///     <c>UpdateAsync</c>, but true only by accident and invisible from here.
 444    /// </remarks>
 445    private async Task<RoleChange> ValidateRoleChangeAsync(UpdatePersonCommand command, Person person)
 446    {
 103447        if (command.RoleId is null || command.RoleId == (int)person.RoleId)
 448        {
 91449            return RoleChange.None(person.RoleId);
 450        }
 451
 452        // AF-08c: only a System Admin may change a role.
 12453        if (command.ActingRole != (int)Roles.SystemAdmin)
 454        {
 2455            return RoleChange.Refused(person.RoleId, PersonMessages.RoleChangeRequiresSystemAdmin);
 456        }
 457
 10458        if (command.RoleId != (int)Roles.SystemAdmin)
 459        {
 2460            return RoleChange.Refused(person.RoleId, PersonMessages.UnsupportedRoleTransition);
 461        }
 462
 463        // NFR-12: a scope must always retain at least one owner. Gather the scopes somebody *other*
 464        // than this person owns; refuse if any scope this person owns is not among them. Persons
 465        // already logically deleted are excluded, since they can no longer authenticate and so do
 466        // not keep a scope owned — the same guard UC-09 and UC-10 apply.
 8467        if (person.RoleId == (long)Roles.ScopeAdmin && person.ScopeOwnerships.Count > 0)
 468        {
 8469            var ownedScopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList();
 470
 4471            var coOwnedScopeIds = await personReader.Query()
 4472                .Where(other => other.Id != person.Id && !other.IsDeleted)
 4473                .SelectMany(other => other.ScopeOwnerships.Select(ownership => ownership.ScopeId))
 4474                .Distinct()
 4475                .ToListAsync();
 476
 8477            if (ownedScopeIds.Any(scopeId => !coOwnedScopeIds.Contains(scopeId)))
 478            {
 3479                return RoleChange.Refused(person.RoleId, PersonMessages.ScopeWouldLoseLastOwner);
 480            }
 1481        }
 482
 5483        return new RoleChange(true, (long)Roles.SystemAdmin, null);
 103484    }
 485
 486    /// <summary>
 487    ///     FR-PE-09, evaluated against the role the person will have after this update: a
 488    ///     <c>User</c>'s email is unique within their scope — jointly with that scope's Google Users,
 489    ///     per FR-GO-07 — and an admin's is unique system-wide. The person being updated is excluded
 490    ///     so resubmitting their own address is not a conflict. Compared case-insensitively
 491    ///     (<c>LOWER()</c> in SQL), as UC-06 does.
 492    /// </summary>
 493    private async Task<bool> EmailTakenAsync(UpdatePersonCommand command, Person person, long targetRoleId)
 494    {
 52495        var email = command.Email.Trim().ToLower();
 496
 52497        if (targetRoleId == (long)Roles.User && person.ScopeMembership is not null)
 498        {
 36499            var scopeId = person.ScopeMembership.ScopeId;
 500
 36501            var takenByPerson = await personReader.Query().AnyAsync(other =>
 36502                other.Id != person.Id && !other.IsDeleted && other.Email.ToLower() == email &&
 36503                other.ScopeMembership != null && other.ScopeMembership.ScopeId == scopeId);
 504
 36505            if (takenByPerson)
 506            {
 2507                return true;
 508            }
 509
 510            // The scope's address space is shared with its Google Users (FR-GO-07), so moving a
 511            // User onto an address a Google User already holds is the same conflict as moving them
 512            // onto another User's — the same second read CreateUserCommandHandler makes.
 34513            return await googleUserReader.Query().AnyAsync(googleUser =>
 34514                googleUser.ScopeId == scopeId && googleUser.Email.ToLower() == email);
 515        }
 516
 517        // An admin belongs to no scope, so no Google User can share their namespace: FR-GO-04 makes
 518        // every Google account User-equivalent within one scope.
 16519        return await personReader.Query().AnyAsync(other =>
 16520            other.Id != person.Id && !other.IsDeleted && other.Email.ToLower() == email &&
 16521            (other.RoleId == (long)Roles.SystemAdmin || other.RoleId == (long)Roles.ScopeAdmin));
 52522    }
 523}