NSA-9931: Fix Entra registration race condition - #238
Conversation
Documents the baselining hazard for the first-ever Directories migration in-repo (migration file comment + new migrations/README.md), fixes the audit message on Entra link failure to not overstate CannotCreateNewUserException as permanent (it can self-heal via AutoLinkEntraUserToDsiUseCase on retry), guards the audit dispatch so it can never mask the original exception it's meant to record, and wires migrations.csproj into dsi-platform.sln (with a fresh project GUID to resolve a collision with Dfe.SignIn.Core.Public) so CI actually builds it.
There was a problem hiding this comment.
Pull request overview
Fixes a production race in Entra-driven registration/linking by enforcing email uniqueness at the database level and making the registration flow resilient to concurrent creates, while adding audit visibility for link failures.
Changes:
- Added a unique index on
user.emailvia EF Core model configuration and a new Directories migration. - Updated
CreateUserUseCaseto recover from unique-constraintDbUpdateExceptionby resolving the “race winner” user ID. - Added audit logging in
TokenIssuanceStartHandlerfor non-success linking outcomes, plus unit tests covering the new paths.
Reviewed changes
Copilot reviewed 15 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Dfe.SignIn.Gateways.EntityFramework.UnitTests/UserEntityConfigurationTests.cs | Adds SQLite-backed test to prove the EF unique email index is enforced. |
| tests/Dfe.SignIn.Gateways.EntityFramework.UnitTests/Dfe.SignIn.Gateways.EntityFramework.UnitTests.csproj | Adds EF Core SQLite provider dependency for relational constraint testing. |
| tests/Dfe.SignIn.Fn.AuthExtensions.UnitTests/TokenIssuanceStartHandlerTests.cs | Adds tests verifying audit write/rethrow behavior and audit isolation on failure. |
| tests/Dfe.SignIn.Core.UseCases.UnitTests/Users/CreateUserUseCaseTests.cs | Adds tests for “race winner” recovery and rethrow behavior when recovery isn’t possible. |
| src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Directories/UserEntityConfiguration.cs | Adds unique index on UserEntity.Email. |
| src/Dfe.SignIn.Fn.AuthExtensions/OnTokenIssuanceStart/TokenIssuanceStartHandler.cs | Adds audit logging for specific linking failures and ensures audit errors don’t mask the original exception. |
| src/Dfe.SignIn.Core.UseCases/Users/CreateUserUseCase.cs | Implements post-DbUpdateException recovery by querying for the now-existing user row. |
| src/Dfe.SignIn.Core.Contracts/Audit/AuditConstants.cs | Introduces AuditAuthEventNames.LinkFailed. |
| migrations/README.md | Documents baselining requirements for first migrations on pre-existing databases. |
| migrations/migrations.csproj | Updates migrations project metadata (including ProjectGuid). |
| migrations/Directories/DbDirectoriesContextModelSnapshot.cs | Adds email unique index to the Directories model snapshot. |
| migrations/Directories/20260803140316_AddUniqueIndexToUserEmail.Designer.cs | Adds designer output for the unique email index migration. |
| migrations/Directories/20260803140316_AddUniqueIndexToUserEmail.cs | Adds migration creating the unique email index. |
| migrations/Directories/20260803140253_InitialCreate.Designer.cs | Adds baseline “InitialCreate” migration designer for Directories context. |
| migrations/Directories/20260803140253_InitialCreate.cs | Adds baseline “InitialCreate” migration with safety warnings. |
| dsi-platform.sln | Adds the migrations project to the solution. |
| Directory.Packages.props | Pins Microsoft.EntityFrameworkCore.Sqlite for test usage. |
Files not reviewed (2)
- migrations/Directories/20260803140253_InitialCreate.Designer.cs: Generated file
- migrations/Directories/20260803140316_AddUniqueIndexToUserEmail.Designer.cs: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…nceStartHandler The prior commit (Copilot Autofix suggestion applied via GitHub UI) introduced a second outcomeGuidance declaration and a repeated comment block, causing CS0128 (variable already defined) and breaking the build. Kept the more complete switch expression, which handles the two new exception types with a default case; merged the trailing audit explanation into the single remaining comment.
DbDirectoriesContext had never had a migration generated before, so adding one required baselining (InitialCreate capturing the current schema) before the real change (AddUniqueIndexToUserEmail) could be layered on top - a first-migration hazard needing DBA sign-off just to land a single index. Since Fn.AuthExtensions never actually wires up the local EF-backed CreateUserUseCase for the live token-issuance path (it dispatches to Node instead - see login.dfe.directories PR #383), the only thing this migration needs to change for real is the physical index in the Directories database. That's now a plain SQL script instead: login.dfe.db-scripts/schema/dirs-db/NSA-9931_create_unique_index_user_email.sql UserEntityConfiguration.cs's HasIndex(...).IsUnique() and its SQLite unit test are kept as-is - they document the constraint for dsi-platform's own EF model, for whenever CreateUserUseCase's path goes live.
|
Update: removed the EF Core migrations (InitialCreate baseline + AddUniqueIndexToUserEmail) from this PR. They required baselining
Also fixed a build break: a Copilot Autofix suggestion applied directly to this branch (commit 0c90c77) left a duplicated |
…ace recovery Two issues Copilot flagged in the DbUpdateException catch block: 1. It caught any DbUpdateException (FK violations, timeouts, etc.) and treated it as a race, not just unique-constraint violations. Now gated on IsUniqueConstraintViolation, matched against the well-known SQL Server unique-violation message text (can't inspect SqlException by type/Number without a hard Microsoft.Data.SqlClient dependency in this layer). 2. The winner lookup queried Email == X || EntraOid == Y with SingleOrDefaultAsync, which throws InvalidOperationException (masking the original exception) if a different row matches each condition - e.g. the email is already used by a different Entra identity. Changed to Email == X && EntraOid == Y: the only unambiguous race winner is a row matching both, since a genuine race is the same Entra identity's own concurrent request. A partial match is a real conflict and must surface as the original exception rather than silently resolving to someone else's account. Updated RecoversWhenConcurrentRequestWinsTheRace to model a true race (same email and entra_oid, not just email); added tests for the two new safety cases.
|
Addressed the other Copilot finding too (CreateUserUseCase.cs race-recovery): the catch is now gated to actual unique-constraint violations only (not any DbUpdateException), and the winner lookup requires matching both email and entra_oid — a partial match (email reused by a different Entra identity) now surfaces the original exception instead of risking an ambiguous SingleOrDefaultAsync throw or a silent cross-identity resolution. Two new tests cover both cases; existing race test corrected to model a genuine same-identity race. |
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Dfe.SignIn.Core.UseCases/Users/CreateUserUseCase.cs:90
- In the unique-constraint recovery path,
newUseris still tracked asAdded. If thisDbContextis reused later in the same scope, a subsequentSaveChangesAsyncwill try to insert it again and can re-trigger the unique constraint violation. Detach/remove the pending entity before returning the race-winner.
return new CreateUserResponse {
UserId = raceWinner.Sub
};
}
src/Dfe.SignIn.Core.Contracts/Audit/AuditConstants.cs:75
AuditAuthEventNames.LinkFailedis used for both permanent linking conflicts and potentially self-healing failures (e.g., a registration race). The XML summary currently states it is a permanent failure, which is misleading for consumers of the audit taxonomy.
/// <summary>
/// Indicates that linking an Entra account to a DfE Sign-In user permanently failed.
/// </summary>
public const string LinkFailed = "link-failed";



Summary
Fixes NSA-9931 (Unlinked Entra accounts): two concurrent Entra registration attempts for the same email could both pass
CreateUserUseCase's duplicate check before either committed, creating orphaned duplicate[user]rows. Confirmed live in production (a real duplicate pair found during investigation).[user].email(IDX__user__email__unique) — the first EF Core migration ever scaffolded forDbDirectoriesContext. Seemigrations/README.mdfor the baselining requirement before this can be applied to the live database.CreateUserUseCaserecover from the resultingDbUpdateExceptionby resolving to the concurrent winner's user ID instead of failing.TokenIssuanceStartHandlerfor permanent (non-self-healing) linking failures, with wording that distinguishes genuinely permanent failures from ones that may self-heal on retry.Not included in this PR
user.subwith no FK constraint) — see the plan's Rollout order section for detail.__EFMigrationsHistorybaselining for this database has not been performed — seemigrations/README.md.email/given_namemodeled asnvarcharvs the livevarchar) is flagged for a separate follow-up ticket, not fixed here.Design & implementation record
Full investigation, design decisions, and implementation plan: see
docs/superpowers/specs/2026-08-03-nsa-9931-unlinked-entra-accounts-design.mdanddocs/superpowers/plans/2026-08-03-nsa-9931-fix-registration-race.mdin thelogin.dfe.nodeworkspace repo.Test plan
dotnet build dsi-platform.sln— clean, including the migrations projectdotnet testsuite passes, including new tests for the race-recovery and audit-failure-isolation paths