< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Data.Persistence.AtomicWrites
Assembly: ArturRios.Heimdall.Data
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Infrastructure/ArturRios.Heimdall.Data/Persistence/AtomicWrites.cs
Line coverage
96%
Covered lines: 752
Uncovered lines: 29
Coverable lines: 781
Total lines: 1409
Line coverage: 96.2%
Branch coverage
90%
Covered branches: 154
Total branches: 170
Branch coverage: 90.5%
Method coverage
98%
Covered methods: 61
Fully covered methods: 55
Total methods: 62
Method coverage: 98.3%
Full method coverage: 88.7%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
TryReserveLoginAttemptAsync()100%66100%
TryChargePendingRegistrationGuessAsync()100%22100%
ClearLoginAttemptsAsync()100%11100%
TryReserveSecondFactorAttemptAsync()100%66100%
ClearSecondFactorAttemptsAsync()100%11100%
TryReserveReauthenticationAsync()100%1414100%
TryReserveAccountEmailAsync()93.75%1616100%
ClearReauthenticationAttemptsAsync()100%11100%
StartChallengeAsync()100%11100%
TryChargeChallengeGuessAsync()100%22100%
ExpireChallengeIfExhaustedAsync()100%22100%
TryRedeemChallengeAsync()100%22100%
TryChargeEmailCodeReissueAsync()100%22100%
TryAdvanceTotpStepAsync()100%22100%
TryChargeEmailCodeGuessAsync()100%22100%
RetireEmailCodeIfExhaustedAsync()100%22100%
TryConsumeEmailCodeAsync()100%22100%
TryConsumeRecoveryCodeAsync()100%22100%
TryConsumePasswordResetTokenAsync()100%22100%
TryConsumeEmailVerificationTokenAsync()100%22100%
TrySetPasswordAsync()100%22100%
TryMarkEmailVerifiedAsync()100%22100%
TryStampCredentialUseAsync()100%22100%
TryRevokeCredentialAsync()100%22100%
RotateSecurityStampAsync()100%11100%
TryChangeEmailAndRoleAsync()88.88%181896%
TryRenameAsync()100%22100%
TryUpdateScopeDetailsAsync()100%22100%
TryMarkScopeDeletedAsync()100%22100%
TrySetGoogleSignInAsync()100%22100%
TrySetSelfRegistrationAsync()100%22100%
TrySetEmailLinksAsync()100%22100%
TryMarkDeletedAsync()100%22100%
TryRestrictProcessingAsync()100%22100%
TryLiftRestrictionAsync()50%6454.54%
TryRecordErasureRequestAsync()100%22100%
TryRefreshEmailVerifiedAsync()100%22100%
TryAnonymiseAsync()100%22100%
TryReapplyErasureAsync()0%620%
TryMarkDeletedAsync()100%22100%
TryRecordErasureRequestAsync()75%4496.15%
TrySuspendBlockedErasureAsync()100%22100%
TryRestrictProcessingAsync()100%22100%
TryLiftRestrictionAsync()75%4490.9%
TryAnonymiseAsync()100%22100%
TryReapplyErasureAsync()100%22100%
TryAddScopeOwnerAsync()75%4494.44%
TryRemoveScopeOwnerAsync()100%66100%
TryRecordGoogleIdTokenUseAsync()100%11100%
SetAnonymised(...)100%11100%
SetAnonymised(...)100%11100%
AcceptAnonymised(...)100%11100%
AcceptAnonymised(...)100%11100%
DiscardPendingChanges()50%11650%
RedeemableAsRead(...)100%11100%
TouchAsync(...)100%11100%
SecondFactorLockEnd(...)100%22100%
LockEndAfter(...)75%44100%
Boxed(...)100%11100%
Boxed(...)100%11100%
Accept(...)66.66%66100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Infrastructure/ArturRios.Heimdall.Data/Persistence/AtomicWrites.cs

#LineLine coverage
 1using System.Linq.Expressions;
 2using System.Runtime.CompilerServices;
 3using ArturRios.Data.Relational.Core.Repositories;
 4using ArturRios.Heimdall.Data.Configuration;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Domain.Persistence;
 8using Microsoft.EntityFrameworkCore;
 9using Microsoft.EntityFrameworkCore.Query;
 10
 11namespace ArturRios.Heimdall.Data.Persistence;
 12
 13/// <summary>
 14///     <see cref="IAtomicWrites" /> on EF Core's <c>ExecuteUpdate</c>: each method is a single
 15///     <c>UPDATE … WHERE</c> whose condition is the rule being enforced, and whose affected-row count
 16///     says whether this caller won.
 17/// </summary>
 18/// <remarks>
 19///     <para>
 20///         PostgreSQL makes the condition and the write one step: under READ COMMITTED, an
 21///         <c>UPDATE</c> that waits on another transaction's lock re-evaluates its <c>WHERE</c>
 22///         against the row as that transaction left it. So two requests spending the same code both
 23///         reach the row, one writes, and the other finds "unused" no longer true and writes nothing.
 24///         No row version is needed, and no table changes shape for it.
 25///     </para>
 26///     <para>
 27///         <c>ExecuteUpdate</c> bypasses the change tracker, which would leave the caller's tracked
 28///         instance stale — and a later save would write the stale values back over the atomic ones.
 29///         <see cref="Accept{T,TProperty}" /> sets the instance's current and original values to what
 30///         was written, so the tracker sees nothing to save.
 31///     </para>
 32/// </remarks>
 82233public sealed class AtomicWrites(AppDbContext context) : IAtomicWrites
 34{
 35    public async Task<bool> TryReserveLoginAttemptAsync(
 36        Person person, int maxAttempts, DateTime now, DateTime lockedOutUntil)
 37    {
 38        // One statement, so the attempt that reaches the limit is also the one that locks: every
 39        // right-hand side reads the row as it was before the update. A request that dies after this
 40        // returns — shed by the hash gate, or cut off by a database fault — has still left a lock that
 41        // expires, and not a counter at the limit that nothing could ever reset. A row already at or
 42        // past the limit with no lockout date (left by an older instance, or by a lowered limit) is
 43        // admitted too: the same >= test locks it and resets the counter, so it heals itself.
 10244        var reserved = await context.Persons
 10245            .Where(x => x.Id == person.Id &&
 10246                        ((x.FailedLoginAttempts < maxAttempts &&
 10247                          (x.LockedOutUntil == null || x.LockedOutUntil <= now)) ||
 10248                         (x.FailedLoginAttempts >= maxAttempts && x.LockedOutUntil == null)))
 20449            .ExecuteUpdateAsync(set => set
 20450                .SetProperty(x => x.LockedOutUntil,
 20451                    x => x.FailedLoginAttempts + 1 >= maxAttempts ? lockedOutUntil : x.LockedOutUntil)
 20452                .SetProperty(x => x.FailedLoginAttempts,
 20453                    x => x.FailedLoginAttempts + 1 >= maxAttempts ? 0 : x.FailedLoginAttempts + 1)) == 1;
 54
 10255        if (reserved)
 56        {
 10157            var reachedLimit = person.FailedLoginAttempts + 1 >= maxAttempts;
 58
 10159            if (reachedLimit)
 60            {
 461                Accept(person, x => x.LockedOutUntil, lockedOutUntil);
 62            }
 63
 10164            Accept(person, x => x.FailedLoginAttempts, reachedLimit ? 0 : person.FailedLoginAttempts + 1);
 65        }
 66
 10267        return reserved;
 10268    }
 69
 70    public async Task<bool> TryChargePendingRegistrationGuessAsync(PendingRegistration pending, int maxGuesses)
 71    {
 1872        var charged = await context.PendingRegistrations
 1873            .Where(x => x.Id == pending.Id && x.FailedAttempts < maxGuesses)
 3674            .ExecuteUpdateAsync(set => set.SetProperty(x => x.FailedAttempts, x => x.FailedAttempts + 1)) == 1;
 75
 1876        if (charged)
 77        {
 1578            Accept(pending, x => x.FailedAttempts, pending.FailedAttempts + 1);
 79        }
 80
 1881        return charged;
 1882    }
 83
 84    public async Task ClearLoginAttemptsAsync(Person person)
 85    {
 8186        await context.Persons
 8187            .Where(x => x.Id == person.Id)
 16288            .ExecuteUpdateAsync(set => set
 16289                .SetProperty(x => x.FailedLoginAttempts, 0)
 16290                .SetProperty(x => x.LockedOutUntil, (DateTime?)null));
 91
 8192        Accept(person, x => x.FailedLoginAttempts, 0);
 8193        Accept(person, x => x.LockedOutUntil, null);
 8194    }
 95
 96    public async Task<bool> TryReserveSecondFactorAttemptAsync(
 97        Person person, int maxAttempts, DateTime now, IReadOnlyList<DateTime> lockedOutUntil)
 98    {
 5499        ArgumentOutOfRangeException.ThrowIfZero(lockedOutUntil.Count, nameof(lockedOutUntil));
 100
 101        // The login reservation's shape, for the same reasons: the reservation that reaches the limit
 102        // is the one that locks, and a row at or past the limit with no live lock — none at all, or
 103        // one that has run out, as a lowered limit would leave — is admitted and locked, so it heals
 104        // itself rather than being refused for good. What differs
 105        // is the lock's length, which depends on how many locks came before — so it is chosen by the
 106        // stored lockout count, inside the same statement, rather than from the count this request
 107        // read: two requests that both read "one lock so far" cannot both set the second lock's end.
 54108        var reserved = await context.Persons
 54109            .Where(x => x.Id == person.Id &&
 54110                        ((x.FailedSecondFactorAttempts < maxAttempts &&
 54111                          (x.SecondFactorLockedOutUntil == null || x.SecondFactorLockedOutUntil <= now)) ||
 54112                         (x.FailedSecondFactorAttempts >= maxAttempts &&
 54113                          (x.SecondFactorLockedOutUntil == null || x.SecondFactorLockedOutUntil <= now))))
 108114            .ExecuteUpdateAsync(set => set
 108115                .SetProperty(x => x.SecondFactorLockedOutUntil, SecondFactorLockEnd(maxAttempts, lockedOutUntil))
 108116                .SetProperty(x => x.SecondFactorLockouts,
 108117                    x => x.FailedSecondFactorAttempts + 1 >= maxAttempts
 108118                        ? x.SecondFactorLockouts + 1
 108119                        : x.SecondFactorLockouts)
 108120                .SetProperty(x => x.FailedSecondFactorAttempts,
 108121                    x => x.FailedSecondFactorAttempts + 1 >= maxAttempts ? 0 : x.FailedSecondFactorAttempts + 1)) == 1;
 122
 54123        if (reserved)
 124        {
 49125            var reachedLimit = person.FailedSecondFactorAttempts + 1 >= maxAttempts;
 126
 49127            if (reachedLimit)
 128            {
 4129                Accept(person, x => x.SecondFactorLockedOutUntil, LockEndAfter(person.SecondFactorLockouts, lockedOutUnt
 4130                Accept(person, x => x.SecondFactorLockouts, person.SecondFactorLockouts + 1);
 131            }
 132
 49133            Accept(person, x => x.FailedSecondFactorAttempts, reachedLimit ? 0 : person.FailedSecondFactorAttempts + 1);
 134        }
 135        else
 136        {
 5137            await TouchAsync(person);
 138        }
 139
 54140        return reserved;
 54141    }
 142
 143    public async Task ClearSecondFactorAttemptsAsync(Person person)
 144    {
 24145        await context.Persons
 24146            .Where(x => x.Id == person.Id)
 48147            .ExecuteUpdateAsync(set => set
 48148                .SetProperty(x => x.FailedSecondFactorAttempts, 0)
 48149                .SetProperty(x => x.SecondFactorLockedOutUntil, (DateTime?)null)
 48150                .SetProperty(x => x.SecondFactorLockouts, 0));
 151
 24152        Accept(person, x => x.FailedSecondFactorAttempts, 0);
 24153        Accept(person, x => x.SecondFactorLockedOutUntil, null);
 24154        Accept(person, x => x.SecondFactorLockouts, 0);
 24155    }
 156
 157    public async Task<bool> TryReserveReauthenticationAsync(
 158        Person person, int maxFailures, DateTime now, DateTime windowStartedBy, DateTime pausedUntil)
 159    {
 160        // One statement, every right-hand side reading the row as it was. The window is open when it
 161        // started after windowStartedBy (a NULL start is no window, and compares false); the failure
 162        // counted here is then the window's next, or else the first of a new window. The failure that
 163        // reaches the limit pauses and closes the window in the same write, as the login reservation
 164        // locks, so a burst cannot each read "below the limit" and pass it together.
 114165        var reserved = await context.Persons
 114166            .Where(x => x.Id == person.Id &&
 114167                        (x.ReauthenticationPausedUntil == null || x.ReauthenticationPausedUntil <= now))
 228168            .ExecuteUpdateAsync(set => set
 228169                .SetProperty(x => x.ReauthenticationPausedUntil,
 228170                    x => (x.ReauthenticationWindowStartedAt > windowStartedBy
 228171                        ? x.FailedReauthenticationAttempts + 1
 228172                        : 1) >= maxFailures
 228173                        ? (DateTime?)pausedUntil
 228174                        : x.ReauthenticationPausedUntil)
 228175                .SetProperty(x => x.ReauthenticationWindowStartedAt,
 228176                    x => (x.ReauthenticationWindowStartedAt > windowStartedBy
 228177                        ? x.FailedReauthenticationAttempts + 1
 228178                        : 1) >= maxFailures
 228179                        ? null
 228180                        : x.ReauthenticationWindowStartedAt > windowStartedBy
 228181                            ? x.ReauthenticationWindowStartedAt
 228182                            : (DateTime?)now)
 228183                .SetProperty(x => x.FailedReauthenticationAttempts,
 228184                    x => (x.ReauthenticationWindowStartedAt > windowStartedBy
 228185                        ? x.FailedReauthenticationAttempts + 1
 228186                        : 1) >= maxFailures
 228187                        ? 0
 228188                        : x.ReauthenticationWindowStartedAt > windowStartedBy
 228189                            ? x.FailedReauthenticationAttempts + 1
 228190                            : 1)) == 1;
 191
 114192        if (reserved)
 193        {
 99194            var windowOpen = person.ReauthenticationWindowStartedAt > windowStartedBy;
 99195            var failures = windowOpen ? person.FailedReauthenticationAttempts + 1 : 1;
 99196            var paused = failures >= maxFailures;
 197
 99198            if (paused)
 199            {
 3200                Accept(person, x => x.ReauthenticationPausedUntil, pausedUntil);
 201            }
 202
 99203            Accept(person, x => x.ReauthenticationWindowStartedAt,
 99204                paused ? null : windowOpen ? person.ReauthenticationWindowStartedAt : now);
 99205            Accept(person, x => x.FailedReauthenticationAttempts, paused ? 0 : failures);
 206        }
 207        else
 208        {
 15209            await TouchAsync(person);
 210        }
 211
 114212        return reserved;
 114213    }
 214
 215    public async Task<AccountEmailReservation> TryReserveAccountEmailAsync(
 216        Person person, int maxPerWindow, DateTime now, DateTime lastSentBefore, DateTime windowEndsAt)
 217    {
 218        // One statement, every right-hand side reading the row as it was. Admitted only out of the
 219        // cooldown and under the window's count; an ended (or never opened) window starts again at
 220        // this send. Concurrent reservations queue on the row lock and re-read it, so a burst gets
 221        // exactly what is left — one inside a cooldown, at most the cap with none.
 55222        var reserved = await context.Persons
 55223            .Where(x => x.Id == person.Id &&
 55224                        (x.AccountEmailLastSentAt == null || x.AccountEmailLastSentAt <= lastSentBefore) &&
 55225                        (x.AccountEmailWindowEndsAt == null || x.AccountEmailWindowEndsAt <= now ||
 55226                         x.AccountEmailsSent < maxPerWindow))
 110227            .ExecuteUpdateAsync(set => set
 110228                .SetProperty(x => x.AccountEmailsSent,
 110229                    x => x.AccountEmailWindowEndsAt == null || x.AccountEmailWindowEndsAt <= now
 110230                        ? 1
 110231                        : x.AccountEmailsSent + 1)
 110232                .SetProperty(x => x.AccountEmailWindowEndsAt,
 110233                    x => x.AccountEmailWindowEndsAt == null || x.AccountEmailWindowEndsAt <= now
 110234                        ? (DateTime?)windowEndsAt
 110235                        : x.AccountEmailWindowEndsAt)
 110236                .SetProperty(x => x.AccountEmailLastSentAt, (DateTime?)now)) == 1;
 237
 55238        if (reserved)
 239        {
 26240            var windowOpen = person.AccountEmailWindowEndsAt > now;
 241
 26242            Accept(person, x => x.AccountEmailsSent, windowOpen ? person.AccountEmailsSent + 1 : 1);
 26243            Accept(person, x => x.AccountEmailWindowEndsAt, windowOpen ? person.AccountEmailWindowEndsAt : windowEndsAt)
 26244            Accept(person, x => x.AccountEmailLastSentAt, now);
 245
 26246            return AccountEmailReservation.Reserved;
 247        }
 248
 249        // Refused: read the row as it now is, to say which bound refused it and when it ends, and bring
 250        // the instance in line with it. The daily one is the more useful answer when both apply, since
 251        // waiting out the cooldown would not help. A row gone since it was loaded (a hard delete in
 252        // between) is refused too: nothing may be sent to a person who no longer exists.
 29253        var stored = await context.Persons
 29254            .AsNoTracking()
 29255            .Where(x => x.Id == person.Id)
 29256            .Select(x => new { x.AccountEmailsSent, x.AccountEmailWindowEndsAt, x.AccountEmailLastSentAt })
 29257            .SingleOrDefaultAsync();
 258
 29259        if (stored is null)
 260        {
 1261            return AccountEmailReservation.CoolingDown;
 262        }
 263
 28264        Accept(person, x => x.AccountEmailsSent, stored.AccountEmailsSent);
 28265        Accept(person, x => x.AccountEmailWindowEndsAt, stored.AccountEmailWindowEndsAt);
 28266        Accept(person, x => x.AccountEmailLastSentAt, stored.AccountEmailLastSentAt);
 267
 28268        return stored.AccountEmailWindowEndsAt > now && stored.AccountEmailsSent >= maxPerWindow
 28269            ? AccountEmailReservation.DailyLimitReached
 28270            : AccountEmailReservation.CoolingDown;
 55271    }
 272
 273    public async Task ClearReauthenticationAttemptsAsync(Person person)
 274    {
 57275        await context.Persons
 57276            .Where(x => x.Id == person.Id)
 114277            .ExecuteUpdateAsync(set => set
 114278                .SetProperty(x => x.FailedReauthenticationAttempts, 0)
 114279                .SetProperty(x => x.ReauthenticationWindowStartedAt, (DateTime?)null)
 114280                .SetProperty(x => x.ReauthenticationPausedUntil, (DateTime?)null));
 281
 57282        Accept(person, x => x.FailedReauthenticationAttempts, 0);
 57283        Accept(person, x => x.ReauthenticationWindowStartedAt, null);
 57284        Accept(person, x => x.ReauthenticationPausedUntil, null);
 57285    }
 286
 287    public async Task StartChallengeAsync(TwoFactorAuth configuration, Guid challengeId)
 288    {
 28289        await context.TwoFactorAuths
 28290            .Where(x => x.Id == configuration.Id)
 56291            .ExecuteUpdateAsync(set => set
 56292                .SetProperty(x => x.ChallengeId, challengeId)
 56293                .SetProperty(x => x.EmailCodeReissueCount, 0)
 56294                .SetProperty(x => x.ChallengeAttempts, 0));
 295
 28296        Accept(configuration, x => x.ChallengeId, challengeId);
 28297        Accept(configuration, x => x.EmailCodeReissueCount, 0);
 28298        Accept(configuration, x => x.ChallengeAttempts, 0);
 28299    }
 300
 301    public async Task<bool> TryChargeChallengeGuessAsync(TwoFactorAuth configuration, Guid challengeId, int maxGuesses)
 302    {
 39303        var charged = await context.TwoFactorAuths
 39304            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId && x.ChallengeAttempts < maxGuesses)
 78305            .ExecuteUpdateAsync(set => set.SetProperty(x => x.ChallengeAttempts, x => x.ChallengeAttempts + 1)) == 1;
 306
 39307        if (charged)
 308        {
 39309            Accept(configuration, x => x.ChallengeAttempts, configuration.ChallengeAttempts + 1);
 310        }
 311
 39312        return charged;
 39313    }
 314
 315    public async Task ExpireChallengeIfExhaustedAsync(TwoFactorAuth configuration, Guid challengeId, int maxGuesses)
 316    {
 19317        var expired = await context.TwoFactorAuths
 19318            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId && x.ChallengeAttempts >= maxGuesses)
 38319            .ExecuteUpdateAsync(set => set.SetProperty(x => x.ChallengeId, (Guid?)null)) == 1;
 320
 19321        if (expired)
 322        {
 2323            Accept(configuration, x => x.ChallengeId, null);
 324        }
 19325    }
 326
 327    public async Task<bool> TryRedeemChallengeAsync(TwoFactorAuth configuration, Guid challengeId)
 328    {
 24329        var redeemed = await context.TwoFactorAuths
 24330            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId)
 48331            .ExecuteUpdateAsync(set => set.SetProperty(x => x.ChallengeId, (Guid?)null)) == 1;
 332
 24333        if (redeemed)
 334        {
 20335            Accept(configuration, x => x.ChallengeId, null);
 336        }
 337
 24338        return redeemed;
 24339    }
 340
 341    public async Task<bool> TryChargeEmailCodeReissueAsync(TwoFactorAuth configuration, Guid challengeId, int maxReissue
 342    {
 21343        var charged = await context.TwoFactorAuths
 21344            .Where(x => x.Id == configuration.Id && x.ChallengeId == challengeId && x.EmailCodeReissueCount < maxReissue
 42345            .ExecuteUpdateAsync(set => set.SetProperty(x => x.EmailCodeReissueCount, x => x.EmailCodeReissueCount + 1)) 
 346
 21347        if (charged)
 348        {
 16349            Accept(configuration, x => x.EmailCodeReissueCount, configuration.EmailCodeReissueCount + 1);
 350        }
 351
 21352        return charged;
 21353    }
 354
 355    public async Task<bool> TryAdvanceTotpStepAsync(TwoFactorAuth configuration, long timeStep, DateTime now)
 356    {
 22357        var advanced = await context.TwoFactorAuths
 22358            .Where(x => x.Id == configuration.Id &&
 22359                        (x.LastTotpTimeStepUsed == null || x.LastTotpTimeStepUsed < timeStep))
 44360            .ExecuteUpdateAsync(set => set
 44361                .SetProperty(x => x.LastTotpTimeStepUsed, timeStep)
 44362                .SetProperty(x => x.UpdatedAt, now)) == 1;
 363
 22364        if (advanced)
 365        {
 14366            Accept(configuration, x => x.LastTotpTimeStepUsed, timeStep);
 14367            Accept(configuration, x => x.UpdatedAt, now);
 368        }
 369
 22370        return advanced;
 22371    }
 372
 373    public async Task<bool> TryChargeEmailCodeGuessAsync(TwoFactorEmailCode code, int maxGuesses, DateTime now)
 374    {
 23375        var charged = await context.TwoFactorEmailCodes
 23376            .Where(x => x.Id == code.Id && !x.Used && x.ExpiresAt > now && x.FailedAttempts < maxGuesses)
 46377            .ExecuteUpdateAsync(set => set.SetProperty(x => x.FailedAttempts, x => x.FailedAttempts + 1)) == 1;
 378
 23379        if (charged)
 380        {
 18381            Accept(code, x => x.FailedAttempts, code.FailedAttempts + 1);
 382        }
 383
 23384        return charged;
 23385    }
 386
 387    public async Task RetireEmailCodeIfExhaustedAsync(TwoFactorEmailCode code, int maxGuesses)
 388    {
 7389        var retired = await context.TwoFactorEmailCodes
 7390            .Where(x => x.Id == code.Id && x.FailedAttempts >= maxGuesses)
 14391            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 392
 7393        if (retired)
 394        {
 5395            Accept(code, x => x.Used, true);
 396        }
 7397    }
 398
 399    public async Task<bool> TryConsumeEmailCodeAsync(TwoFactorEmailCode code)
 400    {
 9401        var consumed = await context.TwoFactorEmailCodes
 9402            .Where(x => x.Id == code.Id && !x.Used)
 18403            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 404
 9405        if (consumed)
 406        {
 9407            Accept(code, x => x.Used, true);
 408        }
 409
 9410        return consumed;
 9411    }
 412
 413    public async Task<bool> TryConsumeRecoveryCodeAsync(TwoFactorRecoveryCode code, DateTime usedAt)
 414    {
 21415        var consumed = await context.TwoFactorRecoveryCodes
 21416            .Where(x => x.Id == code.Id && !x.Used)
 42417            .ExecuteUpdateAsync(set => set
 42418                .SetProperty(x => x.Used, true)
 42419                .SetProperty(x => x.UsedAt, usedAt)) == 1;
 420
 21421        if (consumed)
 422        {
 17423            Accept(code, x => x.Used, true);
 17424            Accept(code, x => x.UsedAt, usedAt);
 425        }
 426
 21427        return consumed;
 21428    }
 429
 430    public async Task<bool> TryConsumePasswordResetTokenAsync(PasswordResetToken token, DateTime now)
 431    {
 17432        var consumed = await context.PasswordResetTokens
 17433            .Where(x => x.Id == token.Id && !x.Used && x.ExpiresAt > now)
 34434            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 435
 17436        if (consumed)
 437        {
 13438            Accept(token, x => x.Used, true);
 439        }
 440
 17441        return consumed;
 17442    }
 443
 444    public async Task<bool> TryConsumeEmailVerificationTokenAsync(EmailVerificationToken token, DateTime now)
 445    {
 19446        var consumed = await context.EmailVerificationTokens
 19447            .Where(x => x.Id == token.Id && !x.Used && x.ExpiresAt > now)
 38448            .ExecuteUpdateAsync(set => set.SetProperty(x => x.Used, true)) == 1;
 449
 19450        if (consumed)
 451        {
 12452            Accept(token, x => x.Used, true);
 453        }
 454
 19455        return consumed;
 19456    }
 457
 458    public async Task<bool> TrySetPasswordAsync(Person person, byte[] passwordHash, byte[] salt, DateTime now)
 459    {
 17460        var stamp = Guid.NewGuid();
 461
 17462        var written = await context.Persons
 17463            .Where(RedeemableAsRead(person))
 34464            .ExecuteUpdateAsync(set => set
 34465                .SetProperty(x => x.PasswordHash, passwordHash)
 34466                .SetProperty(x => x.Salt, salt)
 34467                .SetProperty(x => x.SecurityStamp, stamp)
 34468                .SetProperty(x => x.UpdatedAt, now)) == 1;
 469
 16470        if (written)
 471        {
 11472            Accept(person, x => x.PasswordHash, passwordHash);
 11473            Accept(person, x => x.Salt, salt);
 11474            Accept(person, x => x.SecurityStamp, stamp);
 11475            Accept(person, x => x.UpdatedAt, now);
 476        }
 477
 16478        return written;
 16479    }
 480
 481    public async Task<bool> TryMarkEmailVerifiedAsync(Person person, DateTime now)
 482    {
 15483        var written = await context.Persons
 15484            .Where(RedeemableAsRead(person))
 30485            .ExecuteUpdateAsync(set => set
 30486                .SetProperty(x => x.EmailVerified, true)
 30487                .SetProperty(x => x.UpdatedAt, now)) == 1;
 488
 14489        if (written)
 490        {
 10491            Accept(person, x => x.EmailVerified, true);
 10492            Accept(person, x => x.UpdatedAt, now);
 493        }
 494
 14495        return written;
 14496    }
 497
 498    public async Task<bool> TryStampCredentialUseAsync(ApplicationCredential credential, DateTime now)
 499    {
 12500        var stamped = await context.ApplicationCredentials
 12501            .Where(x => x.Id == credential.Id)
 12502            .Where(ApplicationCredential.LiveAt(now))
 24503            .ExecuteUpdateAsync(set => set.SetProperty(x => x.LastUsedAt, now)) == 1;
 504
 12505        if (stamped)
 506        {
 11507            Accept(credential, x => x.LastUsedAt, now);
 508        }
 509
 12510        return stamped;
 12511    }
 512
 513    public async Task<bool> TryRevokeCredentialAsync(ApplicationCredential credential, DateTime revokedAt)
 514    {
 6515        var revoked = await context.ApplicationCredentials
 6516            .Where(x => x.Id == credential.Id && x.RevokedAt == null)
 12517            .ExecuteUpdateAsync(set => set.SetProperty(x => x.RevokedAt, revokedAt)) == 1;
 518
 6519        if (revoked)
 520        {
 4521            Accept(credential, x => x.RevokedAt, revokedAt);
 522        }
 523
 6524        return revoked;
 6525    }
 526
 527    public async Task RotateSecurityStampAsync(Person person)
 528    {
 43529        var stamp = Guid.NewGuid();
 530
 43531        await context.Persons
 43532            .Where(x => x.Id == person.Id)
 86533            .ExecuteUpdateAsync(set => set.SetProperty(x => x.SecurityStamp, stamp));
 534
 43535        Accept(person, x => x.SecurityStamp, stamp);
 43536    }
 537
 538    public async Task<AccountChangeOutcome> TryChangeEmailAndRoleAsync(
 539        Person person, string email, long roleId, DateTime now, Func<Task<bool>> saveAlongside)
 540    {
 24541        var id = person.Id;
 24542        var readEmail = person.Email;
 24543        var readRoleId = person.RoleId;
 24544        var emailChanged = !string.Equals(email, readEmail, StringComparison.Ordinal);
 24545        var roleChanged = roleId != readRoleId;
 546
 547        // A change of case alone names the same mailbox — sign-in, recovery and uniqueness all match
 548        // addresses case-insensitively — so the verification it earned stands (#225).
 24549        var mailboxChanged = emailChanged &&
 24550                             !string.Equals(email.ToLower(), readEmail.ToLower(), StringComparison.Ordinal);
 551
 552        // The caller's save has to land with the change or not at all, so the two share a
 553        // transaction — one of their own, unless the caller already runs in one.
 24554        await using var transaction = context.Database.CurrentTransaction is null
 24555            ? await context.Database.BeginTransactionAsync()
 24556            : null;
 557
 558        int written;
 559
 560        try
 561        {
 562            // Only the columns that change are set: an address change leaves the role and scope as
 563            // they are, and a role change the address and its flag.
 24564            written = await context.Persons
 24565                .Where(x => x.Id == id && x.Email == readEmail && x.RoleId == readRoleId &&
 24566                            !x.IsDeleted && x.AnonymisedAt == null)
 24567                .ExecuteUpdateAsync(set =>
 24568                {
 24569                    set.SetProperty(x => x.UpdatedAt, now);
 24570
 24571                    if (emailChanged)
 24572                    {
 15573                        set.SetProperty(x => x.Email, email);
 24574
 15575                        if (mailboxChanged)
 24576                        {
 14577                            set.SetProperty(x => x.EmailVerified, false);
 24578                        }
 24579                    }
 24580
 24581                    if (roleChanged)
 24582                    {
 9583                        set.SetProperty(x => x.RoleId, roleId);
 9584                        set.SetProperty(x => x.ScopeId, (long?)null);
 24585                    }
 48586                });
 23587        }
 1588        catch (Exception exception) when (RelationalErrors.IsUniqueViolation(exception))
 589        {
 590            // FR-PE-09's partial unique indexes: another identity took the address in the namespace
 591            // the new role puts it in, between the caller's check and this write.
 1592            DiscardPendingChanges();
 593
 1594            return AccountChangeOutcome.AddressTaken;
 595        }
 596
 23597        if (written != 1)
 598        {
 2599            DiscardPendingChanges();
 600
 2601            return AccountChangeOutcome.Stale;
 602        }
 603
 21604        Accept(person, x => x.UpdatedAt, now);
 605
 21606        if (emailChanged)
 607        {
 13608            Accept(person, x => x.Email, email);
 609
 13610            if (mailboxChanged)
 611            {
 12612                Accept(person, x => x.EmailVerified, false);
 613            }
 614        }
 615
 21616        if (roleChanged)
 617        {
 8618            Accept(person, x => x.RoleId, roleId);
 8619            Accept(person, x => x.ScopeId, null);
 620
 621            // FR-PE-10: the membership goes with the role, as its copy in ScopeId just did. Detached before
 622            // the navigation is cleared: severed while still tracked, the row would be deleted again by the
 623            // next save, and that delete would find nothing.
 8624            await context.ScopeUsers.Where(x => x.PersonId == id).ExecuteDeleteAsync();
 625
 30626            foreach (var membership in context.ChangeTracker.Entries<ScopeUser>()
 15627                         .Where(entry => entry.Entity.PersonId == id).ToList())
 628            {
 7629                membership.State = EntityState.Detached;
 630            }
 631
 8632            person.ScopeMembership = null;
 633        }
 634
 21635        if (!await saveAlongside())
 636        {
 637            // Rolled back when the transaction is disposed. The instance keeps the values accepted
 638            // above, but nothing the mapping lets a whole-row save write, so nothing writes them.
 0639            DiscardPendingChanges();
 640
 0641            return AccountChangeOutcome.SaveFailed;
 642        }
 643
 21644        if (transaction is not null)
 645        {
 21646            await transaction.CommitAsync();
 647        }
 648
 21649        return AccountChangeOutcome.Changed;
 24650    }
 651
 652    public async Task<bool> TryRenameAsync(Person person, string name, DateTime now)
 653    {
 22654        var readRoleId = person.RoleId;
 655
 22656        var written = await context.Persons
 22657            .Where(x => x.Id == person.Id && x.RoleId == readRoleId && !x.IsDeleted)
 44658            .ExecuteUpdateAsync(set => set
 44659                .SetProperty(x => x.Name, name)
 44660                .SetProperty(x => x.UpdatedAt, now)) == 1;
 661
 22662        if (written)
 663        {
 20664            Accept(person, x => x.Name, name);
 20665            Accept(person, x => x.UpdatedAt, now);
 666        }
 667
 22668        return written;
 22669    }
 670
 671    public async Task<ScopeUpdateOutcome> TryUpdateScopeDetailsAsync(Scope scope, string name, string? description,
 672        int? defaultLegalBasis, string? privacyNoticeUri, DateTime now)
 673    {
 674        bool written;
 675
 676        try
 677        {
 4678            written = await context.Scopes
 4679                .Where(x => x.Id == scope.Id && !x.IsDeleted)
 8680                .ExecuteUpdateAsync(set => set
 8681                    .SetProperty(x => x.Name, name)
 8682                    .SetProperty(x => x.Description, description)
 8683                    .SetProperty(x => x.DefaultLegalBasis, defaultLegalBasis)
 8684                    .SetProperty(x => x.PrivacyNoticeUri, privacyNoticeUri)
 8685                    .SetProperty(x => x.UpdatedAt, now)) == 1;
 3686        }
 1687        catch (Exception exception) when (RelationalErrors.IsUniqueViolation(exception))
 688        {
 689            // AF-03b's unique index on the name: another scope took it between the caller's check and this
 690            // write.
 1691            return ScopeUpdateOutcome.NameTaken;
 692        }
 693
 3694        if (!written)
 695        {
 1696            return ScopeUpdateOutcome.NotFound;
 697        }
 698
 2699        Accept(scope, x => x.Name, name);
 2700        Accept(scope, x => x.Description, description);
 2701        Accept(scope, x => x.DefaultLegalBasis, defaultLegalBasis);
 2702        Accept(scope, x => x.PrivacyNoticeUri, privacyNoticeUri);
 2703        Accept(scope, x => x.UpdatedAt, now);
 704
 2705        return ScopeUpdateOutcome.Updated;
 4706    }
 707
 708    public async Task<bool> TryMarkScopeDeletedAsync(Scope scope, DateTime at)
 709    {
 6710        var written = await context.Scopes
 6711            .Where(x => x.Id == scope.Id && !x.IsDeleted)
 12712            .ExecuteUpdateAsync(set => set
 12713                .SetProperty(x => x.IsDeleted, true)
 12714                .SetProperty(x => x.UpdatedAt, at)) == 1;
 715
 6716        if (written)
 717        {
 6718            Accept(scope, x => x.IsDeleted, true);
 6719            Accept(scope, x => x.UpdatedAt, at);
 720        }
 721
 6722        return written;
 6723    }
 724
 725    public async Task<bool> TrySetGoogleSignInAsync(Scope scope, bool enabled, DateTime now)
 726    {
 6727        var written = await context.Scopes
 6728            .Where(x => x.Id == scope.Id && !x.IsDeleted)
 12729            .ExecuteUpdateAsync(set => set
 12730                .SetProperty(x => x.GoogleSignInEnabled, enabled)
 12731                .SetProperty(x => x.UpdatedAt, now)) == 1;
 732
 6733        if (written)
 734        {
 5735            Accept(scope, x => x.GoogleSignInEnabled, enabled);
 5736            Accept(scope, x => x.UpdatedAt, now);
 737        }
 738
 6739        return written;
 6740    }
 741
 742    public async Task<bool> TrySetSelfRegistrationAsync(Scope scope, bool enabled, DateTime now)
 743    {
 3744        var written = await context.Scopes
 3745            .Where(x => x.Id == scope.Id && !x.IsDeleted)
 6746            .ExecuteUpdateAsync(set => set
 6747                .SetProperty(x => x.SelfRegistrationEnabled, enabled)
 6748                .SetProperty(x => x.UpdatedAt, now)) == 1;
 749
 3750        if (written)
 751        {
 2752            Accept(scope, x => x.SelfRegistrationEnabled, enabled);
 2753            Accept(scope, x => x.UpdatedAt, now);
 754        }
 755
 3756        return written;
 3757    }
 758
 759    public async Task<bool> TrySetEmailLinksAsync(
 760        Scope scope, string? emailVerificationUrl, string? passwordResetUrl, DateTime now)
 761    {
 5762        var written = await context.Scopes
 5763            .Where(x => x.Id == scope.Id && !x.IsDeleted)
 10764            .ExecuteUpdateAsync(set => set
 10765                .SetProperty(x => x.EmailVerificationUrl, emailVerificationUrl)
 10766                .SetProperty(x => x.PasswordResetUrl, passwordResetUrl)
 10767                .SetProperty(x => x.UpdatedAt, now)) == 1;
 768
 5769        if (written)
 770        {
 4771            Accept(scope, x => x.EmailVerificationUrl, emailVerificationUrl);
 4772            Accept(scope, x => x.PasswordResetUrl, passwordResetUrl);
 4773            Accept(scope, x => x.UpdatedAt, now);
 774        }
 775
 5776        return written;
 5777    }
 778
 779    public async Task<bool> TryMarkDeletedAsync(GoogleUser googleUser, DeletionKinds kind, DateTime at)
 780    {
 6781        var deletionKind = (int)kind;
 782
 6783        var written = await context.GoogleUsers
 6784            .Where(x => x.Id == googleUser.Id && !x.IsDeleted)
 12785            .ExecuteUpdateAsync(set => set
 12786                .SetProperty(x => x.IsDeleted, true)
 12787                .SetProperty(x => x.DeletedAt, at)
 12788                .SetProperty(x => x.DeletionKind, deletionKind)
 12789                .SetProperty(x => x.UpdatedAt, at)) == 1;
 790
 6791        if (written)
 792        {
 5793            Accept(googleUser, x => x.IsDeleted, true);
 5794            Accept(googleUser, x => x.DeletedAt, at);
 5795            Accept(googleUser, x => x.DeletionKind, deletionKind);
 5796            Accept(googleUser, x => x.UpdatedAt, at);
 797        }
 798
 6799        return written;
 6800    }
 801
 802    public async Task<bool> TryRestrictProcessingAsync(GoogleUser googleUser, int ground, DateTime at)
 803    {
 2804        var written = await context.GoogleUsers
 2805            .Where(x => x.Id == googleUser.Id && x.ProcessingRestrictedAt == null && x.AnonymisedAt == null)
 4806            .ExecuteUpdateAsync(set => set
 4807                .SetProperty(x => x.ProcessingRestrictedAt, at)
 4808                .SetProperty(x => x.RestrictionGround, ground)
 4809                .SetProperty(x => x.RestrictionLiftNotifiedAt, (DateTime?)null)
 4810                .SetProperty(x => x.UpdatedAt, at)) == 1;
 811
 2812        if (written)
 813        {
 1814            Accept(googleUser, x => x.ProcessingRestrictedAt, at);
 1815            Accept(googleUser, x => x.RestrictionGround, ground);
 1816            Accept(googleUser, x => x.RestrictionLiftNotifiedAt, null);
 1817            Accept(googleUser, x => x.UpdatedAt, at);
 818        }
 819
 2820        return written;
 2821    }
 822
 823    public async Task<bool> TryLiftRestrictionAsync(GoogleUser googleUser, DateTime? notifiedAt, DateTime now)
 824    {
 1825        if (googleUser.ProcessingRestrictedAt is not { } restrictedAt)
 826        {
 0827            return false;
 828        }
 829
 830        // The restriction read, by when it began, as for a person.
 1831        var written = await context.GoogleUsers
 1832            .Where(x => x.Id == googleUser.Id && x.ProcessingRestrictedAt == restrictedAt)
 2833            .ExecuteUpdateAsync(set => set
 2834                .SetProperty(x => x.ProcessingRestrictedAt, (DateTime?)null)
 2835                .SetProperty(x => x.RestrictionGround, (int?)null)
 2836                .SetProperty(x => x.RestrictionLiftNotifiedAt, notifiedAt)
 2837                .SetProperty(x => x.UpdatedAt, now)) == 1;
 838
 1839        if (written)
 840        {
 0841            Accept(googleUser, x => x.ProcessingRestrictedAt, null);
 0842            Accept(googleUser, x => x.RestrictionGround, null);
 0843            Accept(googleUser, x => x.RestrictionLiftNotifiedAt, notifiedAt);
 0844            Accept(googleUser, x => x.UpdatedAt, now);
 845        }
 846
 1847        return written;
 1848    }
 849
 850    public async Task<bool> TryRecordErasureRequestAsync(GoogleUser googleUser, DateTime requestedAt, DateTime dueAt)
 851    {
 852        const int subjectRequested = (int)DeletionKinds.SubjectRequested;
 853
 854        // One statement: the request, the lifted restriction and the suspension, as for a person (UC-42).
 1855        var written = await context.GoogleUsers
 1856            .Where(x => x.Id == googleUser.Id && x.ErasureRequestedAt == null && !x.IsDeleted &&
 1857                        x.AnonymisedAt == null)
 2858            .ExecuteUpdateAsync(set => set
 2859                .SetProperty(x => x.ErasureRequestedAt, requestedAt)
 2860                .SetProperty(x => x.ErasureDueAt, dueAt)
 2861                .SetProperty(x => x.ProcessingRestrictedAt, (DateTime?)null)
 2862                .SetProperty(x => x.RestrictionGround, (int?)null)
 2863                .SetProperty(x => x.RestrictionLiftNotifiedAt, (DateTime?)null)
 2864                .SetProperty(x => x.IsDeleted, true)
 2865                .SetProperty(x => x.DeletedAt, requestedAt)
 2866                .SetProperty(x => x.DeletionKind, subjectRequested)
 2867                .SetProperty(x => x.UpdatedAt, requestedAt)) == 1;
 868
 1869        if (written)
 870        {
 1871            Accept(googleUser, x => x.ErasureRequestedAt, requestedAt);
 1872            Accept(googleUser, x => x.ErasureDueAt, dueAt);
 1873            Accept(googleUser, x => x.ProcessingRestrictedAt, null);
 1874            Accept(googleUser, x => x.RestrictionGround, null);
 1875            Accept(googleUser, x => x.RestrictionLiftNotifiedAt, null);
 1876            Accept(googleUser, x => x.IsDeleted, true);
 1877            Accept(googleUser, x => x.DeletedAt, requestedAt);
 1878            Accept(googleUser, x => x.DeletionKind, subjectRequested);
 1879            Accept(googleUser, x => x.UpdatedAt, requestedAt);
 880        }
 881
 1882        return written;
 1883    }
 884
 885    public async Task<bool> TryRefreshEmailVerifiedAsync(GoogleUser googleUser, bool emailVerified, DateTime now)
 886    {
 3887        var written = await context.GoogleUsers
 3888            .Where(x => x.Id == googleUser.Id && !x.IsDeleted && x.ProcessingRestrictedAt == null &&
 3889                        x.AnonymisedAt == null)
 6890            .ExecuteUpdateAsync(set => set
 6891                .SetProperty(x => x.EmailVerified, emailVerified)
 6892                .SetProperty(x => x.UpdatedAt, now)) == 1;
 893
 3894        if (written)
 895        {
 2896            Accept(googleUser, x => x.EmailVerified, emailVerified);
 2897            Accept(googleUser, x => x.UpdatedAt, now);
 898        }
 899
 3900        return written;
 3901    }
 902
 903    public async Task<bool> TryAnonymiseAsync(
 904        GoogleUser googleUser, string name, string email, string googleId, DateTime at)
 905    {
 8906        var written = await context.GoogleUsers
 8907            .Where(x => x.Id == googleUser.Id && x.IsDeleted && x.AnonymisedAt == null &&
 8908                        x.ProcessingRestrictedAt == null)
 16909            .ExecuteUpdateAsync(set => SetAnonymised(set, name, email, googleId, at)) == 1;
 910
 8911        if (written)
 912        {
 7913            AcceptAnonymised(googleUser, name, email, googleId, at);
 914        }
 915
 8916        return written;
 8917    }
 918
 919    public async Task<bool> TryReapplyErasureAsync(
 920        GoogleUser googleUser, string name, string email, string googleId, DateTime at)
 921    {
 922        const int subjectRequested = (int)DeletionKinds.SubjectRequested;
 923
 0924        var written = await context.GoogleUsers
 0925            .Where(x => x.Id == googleUser.Id && x.AnonymisedAt == null)
 0926            .ExecuteUpdateAsync(set =>
 0927            {
 0928                set.SetProperty(x => x.IsDeleted, true);
 0929                set.SetProperty(x => x.DeletedAt, x => x.DeletedAt ?? at);
 0930                set.SetProperty(x => x.DeletionKind, subjectRequested);
 0931                SetAnonymised(set, name, email, googleId, at);
 0932            }) == 1;
 933
 0934        if (written)
 935        {
 0936            Accept(googleUser, x => x.IsDeleted, true);
 0937            Accept(googleUser, x => x.DeletedAt, googleUser.DeletedAt ?? at);
 0938            Accept(googleUser, x => x.DeletionKind, subjectRequested);
 0939            AcceptAnonymised(googleUser, name, email, googleId, at);
 940        }
 941
 0942        return written;
 0943    }
 944
 945    public async Task<bool> TryMarkDeletedAsync(Person person, DeletionKinds kind, DateTime at)
 946    {
 9947        var deletionKind = (int)kind;
 948
 9949        var written = await context.Persons
 9950            .Where(x => x.Id == person.Id && !x.IsDeleted)
 18951            .ExecuteUpdateAsync(set => set
 18952                .SetProperty(x => x.IsDeleted, true)
 18953                .SetProperty(x => x.DeletedAt, at)
 18954                .SetProperty(x => x.DeletionKind, deletionKind)
 18955                .SetProperty(x => x.UpdatedAt, at)) == 1;
 956
 9957        if (written)
 958        {
 9959            Accept(person, x => x.IsDeleted, true);
 9960            Accept(person, x => x.DeletedAt, at);
 9961            Accept(person, x => x.DeletionKind, deletionKind);
 9962            Accept(person, x => x.UpdatedAt, at);
 963        }
 964
 9965        return written;
 9966    }
 967
 968    public async Task<bool> TryRecordErasureRequestAsync(
 969        Person person, DateTime requestedAt, DateTime dueAt, string? blockedReason)
 970    {
 971        const int subjectRequested = (int)DeletionKinds.SubjectRequested;
 16972        var suspend = blockedReason is null;
 973
 974        // One statement: the request, the lifted restriction, and either the suspension or the
 975        // reason it waits, so none of them can be written without the others (UC-42).
 16976        var written = await context.Persons
 16977            .Where(x => x.Id == person.Id && x.ErasureRequestedAt == null && !x.IsDeleted && x.AnonymisedAt == null)
 16978            .ExecuteUpdateAsync(set =>
 16979            {
 16980                set.SetProperty(x => x.ErasureRequestedAt, requestedAt);
 16981                set.SetProperty(x => x.ErasureDueAt, dueAt);
 16982                set.SetProperty(x => x.ProcessingRestrictedAt, (DateTime?)null);
 16983                set.SetProperty(x => x.RestrictionGround, (int?)null);
 16984                set.SetProperty(x => x.RestrictionLiftNotifiedAt, (DateTime?)null);
 16985                set.SetProperty(x => x.UpdatedAt, requestedAt);
 16986
 16987                if (suspend)
 16988                {
 13989                    set.SetProperty(x => x.IsDeleted, true);
 13990                    set.SetProperty(x => x.DeletedAt, requestedAt);
 13991                    set.SetProperty(x => x.DeletionKind, subjectRequested);
 16992                }
 16993                else
 16994                {
 3995                    set.SetProperty(x => x.ErasureBlockedReason, blockedReason);
 16996                }
 19997            }) == 1;
 998
 16999        if (!written)
 1000        {
 01001            return false;
 1002        }
 1003
 161004        Accept(person, x => x.ErasureRequestedAt, requestedAt);
 161005        Accept(person, x => x.ErasureDueAt, dueAt);
 161006        Accept(person, x => x.ProcessingRestrictedAt, null);
 161007        Accept(person, x => x.RestrictionGround, null);
 161008        Accept(person, x => x.RestrictionLiftNotifiedAt, null);
 161009        Accept(person, x => x.UpdatedAt, requestedAt);
 1010
 161011        if (suspend)
 1012        {
 131013            Accept(person, x => x.IsDeleted, true);
 131014            Accept(person, x => x.DeletedAt, requestedAt);
 131015            Accept(person, x => x.DeletionKind, subjectRequested);
 1016        }
 1017        else
 1018        {
 31019            Accept(person, x => x.ErasureBlockedReason, blockedReason);
 1020        }
 1021
 161022        return true;
 161023    }
 1024
 1025    public async Task<bool> TrySuspendBlockedErasureAsync(Person person, DateTime now)
 1026    {
 1027        const int subjectRequested = (int)DeletionKinds.SubjectRequested;
 1028
 1029        // Dated from the request, read from the row itself rather than from the instance.
 21030        var written = await context.Persons
 21031            .Where(x => x.Id == person.Id && !x.IsDeleted && x.ErasureRequestedAt != null &&
 21032                        x.ErasureBlockedReason != null && x.ProcessingRestrictedAt == null)
 41033            .ExecuteUpdateAsync(set => set
 41034                .SetProperty(x => x.IsDeleted, true)
 41035                .SetProperty(x => x.DeletedAt, x => x.ErasureRequestedAt)
 41036                .SetProperty(x => x.DeletionKind, subjectRequested)
 41037                .SetProperty(x => x.ErasureBlockedReason, (string?)null)
 41038                .SetProperty(x => x.UpdatedAt, now)) == 1;
 1039
 21040        if (written)
 1041        {
 21042            Accept(person, x => x.IsDeleted, true);
 21043            Accept(person, x => x.DeletedAt, person.ErasureRequestedAt);
 21044            Accept(person, x => x.DeletionKind, subjectRequested);
 21045            Accept(person, x => x.ErasureBlockedReason, null);
 21046            Accept(person, x => x.UpdatedAt, now);
 1047        }
 1048
 21049        return written;
 21050    }
 1051
 1052    public async Task<bool> TryRestrictProcessingAsync(Person person, int ground, DateTime at)
 1053    {
 121054        var written = await context.Persons
 121055            .Where(x => x.Id == person.Id && x.ProcessingRestrictedAt == null && x.AnonymisedAt == null)
 241056            .ExecuteUpdateAsync(set => set
 241057                .SetProperty(x => x.ProcessingRestrictedAt, at)
 241058                .SetProperty(x => x.RestrictionGround, ground)
 241059                .SetProperty(x => x.RestrictionLiftNotifiedAt, (DateTime?)null)
 241060                .SetProperty(x => x.UpdatedAt, at)) == 1;
 1061
 121062        if (written)
 1063        {
 111064            Accept(person, x => x.ProcessingRestrictedAt, at);
 111065            Accept(person, x => x.RestrictionGround, ground);
 111066            Accept(person, x => x.RestrictionLiftNotifiedAt, null);
 111067            Accept(person, x => x.UpdatedAt, at);
 1068        }
 1069
 121070        return written;
 121071    }
 1072
 1073    public async Task<bool> TryLiftRestrictionAsync(Person person, DateTime? notifiedAt, DateTime now)
 1074    {
 41075        if (person.ProcessingRestrictedAt is not { } restrictedAt)
 1076        {
 01077            return false;
 1078        }
 1079
 1080        // The restriction read, by when it began: one lifted meanwhile is not there to lift, and
 1081        // one imposed again meanwhile is not the one the subject was told about.
 41082        var written = await context.Persons
 41083            .Where(x => x.Id == person.Id && x.ProcessingRestrictedAt == restrictedAt)
 81084            .ExecuteUpdateAsync(set => set
 81085                .SetProperty(x => x.ProcessingRestrictedAt, (DateTime?)null)
 81086                .SetProperty(x => x.RestrictionGround, (int?)null)
 81087                .SetProperty(x => x.RestrictionLiftNotifiedAt, notifiedAt)
 81088                .SetProperty(x => x.UpdatedAt, now)) == 1;
 1089
 41090        if (written)
 1091        {
 41092            Accept(person, x => x.ProcessingRestrictedAt, null);
 41093            Accept(person, x => x.RestrictionGround, null);
 41094            Accept(person, x => x.RestrictionLiftNotifiedAt, notifiedAt);
 41095            Accept(person, x => x.UpdatedAt, now);
 1096        }
 1097
 41098        return written;
 41099    }
 1100
 1101    public async Task<bool> TryAnonymiseAsync(Person person, string name, string email, DateTime at)
 1102    {
 161103        var written = await context.Persons
 161104            .Where(x => x.Id == person.Id && x.IsDeleted && x.AnonymisedAt == null && x.ProcessingRestrictedAt == null)
 321105            .ExecuteUpdateAsync(set => SetAnonymised(set, name, email, at)) == 1;
 1106
 161107        if (written)
 1108        {
 161109            AcceptAnonymised(person, name, email, at);
 1110        }
 1111
 161112        return written;
 161113    }
 1114
 1115    public async Task<bool> TryReapplyErasureAsync(Person person, string name, string email, DateTime at)
 1116    {
 1117        const int subjectRequested = (int)DeletionKinds.SubjectRequested;
 1118
 31119        var written = await context.Persons
 31120            .Where(x => x.Id == person.Id && x.AnonymisedAt == null)
 31121            .ExecuteUpdateAsync(set =>
 31122            {
 31123                set.SetProperty(x => x.IsDeleted, true);
 31124                set.SetProperty(x => x.DeletedAt, x => x.DeletedAt ?? at);
 31125                set.SetProperty(x => x.DeletionKind, subjectRequested);
 31126                SetAnonymised(set, name, email, at);
 61127            }) == 1;
 1128
 31129        if (written)
 1130        {
 31131            Accept(person, x => x.IsDeleted, true);
 31132            Accept(person, x => x.DeletedAt, person.DeletedAt ?? at);
 31133            Accept(person, x => x.DeletionKind, subjectRequested);
 31134            AcceptAnonymised(person, name, email, at);
 1135        }
 1136
 31137        return written;
 31138    }
 1139
 1140    public async Task<bool> TryAddScopeOwnerAsync(Person person, long scopeId)
 1141    {
 1142        // Table names come from the model, never from input, so building them into the SQL is safe;
 1143        // the values stay parameters. The insert selects from the person's own row, so it lands only
 1144        // while that row is a live administrator, and ON CONFLICT answers AF-21d without an error.
 121145        var ownerTable = context.Model.FindEntityType(typeof(ScopeOwner))!.GetSchemaQualifiedTableName();
 121146        var personTable = context.Model.FindEntityType(typeof(Person))!.GetSchemaQualifiedTableName();
 1147
 121148        var sql =
 121149            $$"""
 121150              INSERT INTO {{ownerTable}} (scope_id, person_id)
 121151              SELECT {0}, person.id FROM {{personTable}} AS person
 121152              WHERE person.id = {1} AND NOT person.is_deleted AND person.role_id IN ({2}, {3})
 121153              ON CONFLICT DO NOTHING
 121154              """;
 1155
 121156        var added = await context.Database.ExecuteSqlRawAsync(
 121157            sql, scopeId, person.Id, (long)Roles.ScopeAdmin, (long)Roles.SystemAdmin) == 1;
 1158
 121159        if (added)
 1160        {
 101161            var ownership = new ScopeOwner { ScopeId = scopeId, PersonId = person.Id };
 1162
 1163            // Tracked as already stored, which also adds it to a tracked person's collection, so a
 1164            // later save in the same unit of work does not insert it a second time.
 101165            if (context.Entry(person).State is EntityState.Detached)
 1166            {
 01167                person.ScopeOwnerships.Add(ownership);
 1168            }
 1169            else
 1170            {
 101171                context.Entry(ownership).State = EntityState.Unchanged;
 1172            }
 1173        }
 1174
 121175        return added;
 121176    }
 1177
 1178    public async Task<bool> TryRemoveScopeOwnerAsync(Person person, long scopeId)
 1179    {
 61180        var personId = person.Id;
 1181
 1182        // An active co-owner, by the one definition of active (Person.Active, #211).
 61183        var activeCoOwners = context.Persons
 61184            .Where(Person.Active)
 61185            .Where(other => other.Id != personId && other.ScopeOwnerships.Any(ownership => ownership.ScopeId == scopeId)
 1186
 61187        var removed = await context.ScopeOwners
 61188            .Where(x => x.ScopeId == scopeId && x.PersonId == personId && activeCoOwners.Any())
 61189            .ExecuteDeleteAsync() == 1;
 1190
 61191        if (removed)
 1192        {
 1193            // Detached before it leaves the collection: removed from a tracked person's collection
 1194            // while still tracked, it would be deleted again by the next save.
 251195            foreach (var ownership in person.ScopeOwnerships.Where(x => x.ScopeId == scopeId).ToList())
 1196            {
 51197                context.Entry(ownership).State = EntityState.Detached;
 51198                person.ScopeOwnerships.Remove(ownership);
 1199            }
 1200        }
 1201
 61202        return removed;
 61203    }
 1204
 1205    public async Task<bool> TryRecordGoogleIdTokenUseAsync(string tokenHash, DateTime expiresAt)
 1206    {
 1207        // The table name comes from the model, never from input, so building it into the SQL is safe;
 1208        // the values stay parameters. A competing insert of the same key waits for the first to commit
 1209        // and then inserts nothing, so the affected-row count says which request recorded the use.
 501210        var table = context.Model.FindEntityType(typeof(GoogleIdTokenUse))!.GetSchemaQualifiedTableName();
 1211
 501212        var sql =
 501213            $$"""
 501214              INSERT INTO {{table}} (token_hash, expires_at)
 501215              VALUES ({0}, {1})
 501216              ON CONFLICT (token_hash) DO NOTHING
 501217              """;
 1218
 501219        return await context.Database.ExecuteSqlRawAsync(sql, tokenHash, expiresAt) == 1;
 501220    }
 1221
 1222    /// <summary>The columns anonymisation overwrites (NFR-20), for both of its writes.</summary>
 1223    private static void SetAnonymised(UpdateSettersBuilder<Person> set, string name, string email, DateTime at)
 1224    {
 191225        byte[] empty = [];
 1226
 191227        set.SetProperty(x => x.Name, name);
 191228        set.SetProperty(x => x.Email, email);
 191229        set.SetProperty(x => x.PasswordHash, empty);
 191230        set.SetProperty(x => x.Salt, empty);
 191231        set.SetProperty(x => x.FailedSecondFactorAttempts, 0);
 191232        set.SetProperty(x => x.SecondFactorLockedOutUntil, (DateTime?)null);
 191233        set.SetProperty(x => x.SecondFactorLockouts, 0);
 191234        set.SetProperty(x => x.FailedReauthenticationAttempts, 0);
 191235        set.SetProperty(x => x.ReauthenticationWindowStartedAt, (DateTime?)null);
 191236        set.SetProperty(x => x.ReauthenticationPausedUntil, (DateTime?)null);
 191237        set.SetProperty(x => x.AccountEmailsSent, 0);
 191238        set.SetProperty(x => x.AccountEmailWindowEndsAt, (DateTime?)null);
 191239        set.SetProperty(x => x.AccountEmailLastSentAt, (DateTime?)null);
 191240        set.SetProperty(x => x.EmailVerified, false);
 191241        set.SetProperty(x => x.FailedLoginAttempts, 0);
 191242        set.SetProperty(x => x.LockedOutUntil, (DateTime?)null);
 191243        set.SetProperty(x => x.ErasureBlockedReason, (string?)null);
 191244        set.SetProperty(x => x.AnonymisedAt, at);
 191245        set.SetProperty(x => x.UpdatedAt, at);
 191246    }
 1247
 1248    private static void SetAnonymised(
 1249        UpdateSettersBuilder<GoogleUser> set, string name, string email, string googleId, DateTime at)
 1250    {
 81251        set.SetProperty(x => x.Name, name);
 81252        set.SetProperty(x => x.Email, email);
 81253        set.SetProperty(x => x.GoogleId, googleId);
 81254        set.SetProperty(x => x.ProfilePictureUrl, (string?)null);
 81255        set.SetProperty(x => x.EmailVerified, false);
 81256        set.SetProperty(x => x.AnonymisedAt, at);
 81257        set.SetProperty(x => x.UpdatedAt, at);
 81258    }
 1259
 1260    private void AcceptAnonymised(GoogleUser googleUser, string name, string email, string googleId, DateTime at)
 1261    {
 71262        Accept(googleUser, x => x.Name, name);
 71263        Accept(googleUser, x => x.Email, email);
 71264        Accept(googleUser, x => x.GoogleId, googleId);
 71265        Accept(googleUser, x => x.ProfilePictureUrl, null);
 71266        Accept(googleUser, x => x.EmailVerified, false);
 71267        Accept(googleUser, x => x.AnonymisedAt, at);
 71268        Accept(googleUser, x => x.UpdatedAt, at);
 71269    }
 1270
 1271    private void AcceptAnonymised(Person person, string name, string email, DateTime at)
 1272    {
 191273        Accept(person, x => x.Name, name);
 191274        Accept(person, x => x.Email, email);
 191275        Accept(person, x => x.PasswordHash, []);
 191276        Accept(person, x => x.Salt, []);
 191277        Accept(person, x => x.FailedSecondFactorAttempts, 0);
 191278        Accept(person, x => x.SecondFactorLockedOutUntil, null);
 191279        Accept(person, x => x.SecondFactorLockouts, 0);
 191280        Accept(person, x => x.FailedReauthenticationAttempts, 0);
 191281        Accept(person, x => x.ReauthenticationWindowStartedAt, null);
 191282        Accept(person, x => x.ReauthenticationPausedUntil, null);
 191283        Accept(person, x => x.AccountEmailsSent, 0);
 191284        Accept(person, x => x.AccountEmailWindowEndsAt, null);
 191285        Accept(person, x => x.AccountEmailLastSentAt, null);
 191286        Accept(person, x => x.EmailVerified, false);
 191287        Accept(person, x => x.FailedLoginAttempts, 0);
 191288        Accept(person, x => x.LockedOutUntil, null);
 191289        Accept(person, x => x.ErasureBlockedReason, null);
 191290        Accept(person, x => x.AnonymisedAt, at);
 191291        Accept(person, x => x.UpdatedAt, at);
 191292    }
 1293
 1294    /// <summary>
 1295    ///     After a refused or rolled-back <see cref="TryChangeEmailAndRoleAsync" />, drops whatever the
 1296    ///     caller had staged on its tracked entities — added rows, removed rows, changed columns — so
 1297    ///     the next save in the same unit of work, the audit entry's among them, cannot write part of a
 1298    ///     change that was refused.
 1299    /// </summary>
 1300    private void DiscardPendingChanges()
 1301    {
 161302        foreach (var entry in context.ChangeTracker.Entries().ToList())
 1303        {
 51304            switch (entry.State)
 1305            {
 1306                case EntityState.Added:
 1307                case EntityState.Deleted:
 01308                    entry.State = EntityState.Detached;
 01309                    break;
 1310                case EntityState.Modified:
 01311                    entry.State = EntityState.Unchanged;
 1312                    break;
 1313            }
 1314        }
 31315    }
 1316
 1317    /// <summary>
 1318    ///     The row <paramref name="person" /> was read from, if it still holds the address it was
 1319    ///     read with and is not deleted, restricted or anonymised: the condition a single-use token
 1320    ///     is redeemed under (FR-PR-08, FR-EV-08). The address is captured now, so the comparison is
 1321    ///     against the value the caller read, not whatever the row holds by the time it runs.
 1322    /// </summary>
 1323    private static Expression<Func<Person, bool>> RedeemableAsRead(Person person)
 1324    {
 321325        var id = person.Id;
 321326        var email = person.Email;
 1327
 321328        return x => x.Id == id && x.Email == email && !x.IsDeleted &&
 321329                    x.ProcessingRestrictedAt == null && x.AnonymisedAt == null;
 1330    }
 1331
 1332    /// <summary>
 1333    ///     Writes the person's row without changing it, after a factor reservation was refused.
 1334    /// </summary>
 1335    /// <remarks>
 1336    ///     A refused reservation's <c>UPDATE</c> matches no row, so PostgreSQL assigns it no transaction
 1337    ///     and flushes no commit, and it answers measurably sooner than an admitted one. That would
 1338    ///     tell a caller the second factor is locked, or re-authentication paused, without a word of
 1339    ///     the answer differing (FR-AU-12, FR-AU-13). Setting a column to itself writes a new row
 1340    ///     version and commits like the admitted write does; what remains is one more round trip.
 1341    /// </remarks>
 1342    private Task TouchAsync(Person person) =>
 201343        context.Persons
 201344            .Where(x => x.Id == person.Id)
 401345            .ExecuteUpdateAsync(set => set.SetProperty(x => x.FailedSecondFactorAttempts, x => x.FailedSecondFactorAttem
 1346
 1347    /// <summary>
 1348    ///     The value <see cref="TryReserveSecondFactorAttemptAsync" /> writes to the lock: unchanged,
 1349    ///     unless this reservation reaches <paramref name="maxAttempts" />, and then the entry of
 1350    ///     <paramref name="lockedOutUntil" /> the stored lockout count selects — a <c>CASE</c> over the
 1351    ///     count, with the last entry for every count from its own on.
 1352    /// </summary>
 1353    /// <remarks>
 1354    ///     Built as an expression tree because the number of entries is the caller's. Each end is read
 1355    ///     from a box rather than inlined, so EF Core sends it as a parameter, and the statement — and
 1356    ///     its cached plan — is the same on every call.
 1357    /// </remarks>
 1358    private static Expression<Func<Person, DateTime?>> SecondFactorLockEnd(
 1359        int maxAttempts, IReadOnlyList<DateTime> lockedOutUntil)
 1360    {
 541361        var row = Expression.Parameter(typeof(Person), "x");
 541362        var lockouts = Expression.Property(row, nameof(Person.SecondFactorLockouts));
 1363
 541364        Expression end = Boxed(lockedOutUntil[^1]);
 1365
 8641366        for (var lockout = lockedOutUntil.Count - 2; lockout >= 0; lockout--)
 1367        {
 3781368            end = Expression.Condition(
 3781369                Expression.Equal(lockouts, Expression.Constant(lockout)), Boxed(lockedOutUntil[lockout]), end);
 1370        }
 1371
 541372        var reachesLimit = Expression.GreaterThanOrEqual(
 541373            Expression.Add(Expression.Property(row, nameof(Person.FailedSecondFactorAttempts)), Expression.Constant(1)),
 541374            Boxed(maxAttempts));
 1375
 541376        return Expression.Lambda<Func<Person, DateTime?>>(
 541377            Expression.Condition(reachesLimit, end, Expression.Property(row, nameof(Person.SecondFactorLockedOutUntil)))
 541378            row);
 1379    }
 1380
 1381    /// <summary>The lock end <see cref="SecondFactorLockEnd" /> selects, for the instance.</summary>
 1382    private static DateTime LockEndAfter(int lockouts, IReadOnlyList<DateTime> lockedOutUntil) =>
 41383        lockouts >= 0 && lockouts < lockedOutUntil.Count - 1 ? lockedOutUntil[lockouts] : lockedOutUntil[^1];
 1384
 1385    private static MemberExpression Boxed(DateTime value) =>
 4321386        Expression.Field(Expression.Constant(new StrongBox<DateTime?>(value)), nameof(StrongBox<DateTime?>.Value));
 1387
 1388    private static MemberExpression Boxed(int value) =>
 541389        Expression.Field(Expression.Constant(new StrongBox<int>(value)), nameof(StrongBox<int>.Value));
 1390
 1391    /// <summary>
 1392    ///     Brings the caller's instance in line with a write the change tracker did not see: the new
 1393    ///     value becomes both current and original, so the property is not saved again later.
 1394    /// </summary>
 1395    private void Accept<T, TProperty>(T entity, Expression<Func<T, TProperty>> property, TProperty value)
 1396        where T : class
 1397    {
 21971398        var entry = context.Entry(entity);
 21971399        var member = entry.Property(property);
 1400
 21971401        member.CurrentValue = value;
 1402
 21971403        if (entry.State is EntityState.Unchanged or EntityState.Modified)
 1404        {
 21971405            member.OriginalValue = value;
 21971406            member.IsModified = false;
 1407        }
 21971408    }
 1409}

Methods/Properties

.ctor(ArturRios.Heimdall.Data.Configuration.AppDbContext)
TryReserveLoginAttemptAsync()
TryChargePendingRegistrationGuessAsync()
ClearLoginAttemptsAsync()
TryReserveSecondFactorAttemptAsync()
ClearSecondFactorAttemptsAsync()
TryReserveReauthenticationAsync()
TryReserveAccountEmailAsync()
ClearReauthenticationAttemptsAsync()
StartChallengeAsync()
TryChargeChallengeGuessAsync()
ExpireChallengeIfExhaustedAsync()
TryRedeemChallengeAsync()
TryChargeEmailCodeReissueAsync()
TryAdvanceTotpStepAsync()
TryChargeEmailCodeGuessAsync()
RetireEmailCodeIfExhaustedAsync()
TryConsumeEmailCodeAsync()
TryConsumeRecoveryCodeAsync()
TryConsumePasswordResetTokenAsync()
TryConsumeEmailVerificationTokenAsync()
TrySetPasswordAsync()
TryMarkEmailVerifiedAsync()
TryStampCredentialUseAsync()
TryRevokeCredentialAsync()
RotateSecurityStampAsync()
TryChangeEmailAndRoleAsync()
TryRenameAsync()
TryUpdateScopeDetailsAsync()
TryMarkScopeDeletedAsync()
TrySetGoogleSignInAsync()
TrySetSelfRegistrationAsync()
TrySetEmailLinksAsync()
TryMarkDeletedAsync()
TryRestrictProcessingAsync()
TryLiftRestrictionAsync()
TryRecordErasureRequestAsync()
TryRefreshEmailVerifiedAsync()
TryAnonymiseAsync()
TryReapplyErasureAsync()
TryMarkDeletedAsync()
TryRecordErasureRequestAsync()
TrySuspendBlockedErasureAsync()
TryRestrictProcessingAsync()
TryLiftRestrictionAsync()
TryAnonymiseAsync()
TryReapplyErasureAsync()
TryAddScopeOwnerAsync()
TryRemoveScopeOwnerAsync()
TryRecordGoogleIdTokenUseAsync()
SetAnonymised(Microsoft.EntityFrameworkCore.Query.UpdateSettersBuilder`1<ArturRios.Heimdall.Domain.Entities.Person>,System.String,System.String,System.DateTime)
SetAnonymised(Microsoft.EntityFrameworkCore.Query.UpdateSettersBuilder`1<ArturRios.Heimdall.Domain.Entities.GoogleUser>,System.String,System.String,System.String,System.DateTime)
AcceptAnonymised(ArturRios.Heimdall.Domain.Entities.GoogleUser,System.String,System.String,System.String,System.DateTime)
AcceptAnonymised(ArturRios.Heimdall.Domain.Entities.Person,System.String,System.String,System.DateTime)
DiscardPendingChanges()
RedeemableAsRead(ArturRios.Heimdall.Domain.Entities.Person)
TouchAsync(ArturRios.Heimdall.Domain.Entities.Person)
SecondFactorLockEnd(System.Int32,System.Collections.Generic.IReadOnlyList`1<System.DateTime>)
LockEndAfter(System.Int32,System.Collections.Generic.IReadOnlyList`1<System.DateTime>)
Boxed(System.DateTime)
Boxed(System.Int32)
Accept(T,System.Linq.Expressions.Expression`1<System.Func`2<T,TProperty>>,TProperty)