From 7e5ca36716772872a8c4bb0a2d995b5f67a68fd9 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 7 Apr 2026 19:17:42 -0400 Subject: [PATCH 1/4] PM-34130 - Fix DeviceAuthDetails constructor and stored procedure for EDD compliance Replace positional 14-arg Dapper constructor with parameterless constructor and property-setter mapping; rename AuthRequestCreatedAt to AuthRequestCreationDate; convert IsTrusted to a computed property; update stored procedure to use explicit column list instead of SELECT D.* for EDD-safe name-based Dapper mapping; add migration script; expand integration tests for full field mapping, IsTrusted logic, Unlock type eligibility, inactive device exclusion, and empty device list. --- .../DeviceAuthRequestResponseModel.cs | 4 +- .../Auth/Models/Data/DeviceAuthDetails.cs | 61 +---- ...dActiveWithPendingAuthRequestsByUserId.sql | 50 ++-- .../Repositories/DeviceRepositoryTests.cs | 236 ++++++++++++++++-- ...dActiveWithPendingAuthRequestsByUserId.sql | 46 ++++ 5 files changed, 307 insertions(+), 90 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql diff --git a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs index 47a308b28d39..b5f0c8189def 100644 --- a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs @@ -27,12 +27,12 @@ public static DeviceAuthRequestResponseModel From(DeviceAuthDetails deviceAuthDe EncryptedUserKey = deviceAuthDetails.EncryptedUserKey }; - if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null) + if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreationDate != null) { converted.DevicePendingAuthRequest = new PendingAuthRequest { Id = (Guid)deviceAuthDetails.AuthRequestId, - CreationDate = (DateTime)deviceAuthDetails.AuthRequestCreatedAt + CreationDate = (DateTime)deviceAuthDetails.AuthRequestCreationDate }; } diff --git a/src/Core/Auth/Models/Data/DeviceAuthDetails.cs b/src/Core/Auth/Models/Data/DeviceAuthDetails.cs index f9f799c30824..38e6ebfd181a 100644 --- a/src/Core/Auth/Models/Data/DeviceAuthDetails.cs +++ b/src/Core/Auth/Models/Data/DeviceAuthDetails.cs @@ -1,14 +1,18 @@ using Bit.Core.Auth.Utilities; using Bit.Core.Entities; -using Bit.Core.Enums; namespace Bit.Core.Auth.Models.Data; public class DeviceAuthDetails : Device { - public bool IsTrusted { get; set; } + public bool IsTrusted => DeviceExtensions.IsTrusted(this); public Guid? AuthRequestId { get; set; } - public DateTime? AuthRequestCreatedAt { get; set; } + public DateTime? AuthRequestCreationDate { get; set; } + + /** + * Parameterless constructor for Dapper name-based mapping. + */ + public DeviceAuthDetails() { } /** * Constructor for EF response. @@ -28,58 +32,9 @@ public DeviceAuthDetails( Type = device.Type; Identifier = device.Identifier; CreationDate = device.CreationDate; - IsTrusted = device.IsTrusted(); EncryptedPublicKey = device.EncryptedPublicKey; EncryptedUserKey = device.EncryptedUserKey; AuthRequestId = authRequestId; - AuthRequestCreatedAt = authRequestCreationDate; - } - - /** - * Constructor for dapper response. - * Note: if the authRequestId or authRequestCreationDate is null it comes back as - * an empty guid and a min value for datetime. That could change if the stored - * procedure runs on a different kind of db. - */ - public DeviceAuthDetails( - Guid id, - Guid userId, - string name, - short type, - string identifier, - string pushToken, - DateTime creationDate, - DateTime revisionDate, - string encryptedUserKey, - string encryptedPublicKey, - string encryptedPrivateKey, - bool active, - Guid authRequestId, - DateTime authRequestCreationDate) - { - Id = id; - Name = name; - Type = (DeviceType)type; - Identifier = identifier; - CreationDate = creationDate; - IsTrusted = new Device - { - Id = id, - UserId = userId, - Name = name, - Type = (DeviceType)type, - Identifier = identifier, - PushToken = pushToken, - RevisionDate = revisionDate, - EncryptedUserKey = encryptedUserKey, - EncryptedPublicKey = encryptedPublicKey, - EncryptedPrivateKey = encryptedPrivateKey, - Active = active - }.IsTrusted(); - EncryptedPublicKey = encryptedPublicKey; - EncryptedUserKey = encryptedUserKey; - AuthRequestId = authRequestId != Guid.Empty ? authRequestId : null; - AuthRequestCreatedAt = - authRequestCreationDate != DateTime.MinValue ? authRequestCreationDate : null; + AuthRequestCreationDate = authRequestCreationDate; } } diff --git a/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql b/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql index f40e9149c0e2..e1b461b9ec1e 100644 --- a/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql +++ b/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql @@ -6,25 +6,35 @@ BEGIN SET NOCOUNT ON; SELECT - D.*, - AR.Id as AuthRequestId, - AR.CreationDate as AuthRequestCreationDate - FROM dbo.DeviceView D - LEFT JOIN ( - SELECT - Id, - CreationDate, - RequestDeviceIdentifier, - Approved, - ROW_NUMBER() OVER (PARTITION BY RequestDeviceIdentifier ORDER BY CreationDate DESC) as rn - FROM dbo.AuthRequestView - WHERE Type IN (0, 1) -- AuthenticateAndUnlock and Unlock types only - AND CreationDate >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired - AND UserId = @UserId -- Requests for this user only - ) AR -- This join will get the most recent request per device, regardless of approval status - ON D.Identifier = AR.RequestDeviceIdentifier AND AR.rn = 1 AND AR.Approved IS NULL -- Get only the most recent unapproved request per device + D.[Id], + D.[UserId], + D.[Name], + D.[Type], + D.[Identifier], + D.[PushToken], + D.[CreationDate], + D.[RevisionDate], + D.[EncryptedUserKey], + D.[EncryptedPublicKey], + D.[EncryptedPrivateKey], + D.[Active], + AR.[Id] AS [AuthRequestId], + AR.[CreationDate] AS [AuthRequestCreationDate] + FROM [dbo].[DeviceView] D + LEFT OUTER JOIN ( + SELECT + [Id], + [CreationDate], + [RequestDeviceIdentifier], + [Approved], + ROW_NUMBER() OVER (PARTITION BY [RequestDeviceIdentifier] ORDER BY [CreationDate] DESC) AS rn + FROM [dbo].[AuthRequestView] + WHERE [Type] IN (0,1) -- AuthenticateAndUnlock and Unlock types only + AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired + AND [UserId] = @UserId -- Requests for this user only + ) AR -- This join will get the most recent request per device, regardless of approval status + ON D.[Identifier] = AR.[RequestDeviceIdentifier] AND AR.[rn] = 1 AND AR.[Approved] IS NULL -- Get only the most recent unapproved request per device WHERE - D.UserId = @UserId -- Include only devices for this user - AND D.Active = 1; -- Include only active devices + D.[UserId] = @UserId -- Include only devices for this user + AND D.[Active] = 1; -- Include only active devices END; - diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/DeviceRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/DeviceRepositoryTests.cs index 95b88d5662af..a428f12610b5 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/DeviceRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/DeviceRepositoryTests.cs @@ -9,6 +9,11 @@ namespace Bit.Infrastructure.IntegrationTest.Auth.Repositories; public class DeviceRepositoryTests { + /// + /// Verifies that all DeviceAuthDetails fields are correctly populated from the database, + /// and that when multiple pending auth requests exist for the same device, only the most + /// recent one is returned. + /// [DatabaseTheory] [DatabaseData] public async Task GetManyByUserIdWithDeviceAuth_Works_ReturnsExpectedResults( @@ -32,6 +37,10 @@ public async Task GetManyByUserIdWithDeviceAuth_Works_ReturnsExpectedResults( UserId = user.Id, Type = DeviceType.ChromeBrowser, Identifier = Guid.NewGuid().ToString(), + PushToken = "push-token", + EncryptedUserKey = "encrypted-user-key", + EncryptedPublicKey = "encrypted-public-key", + EncryptedPrivateKey = "encrypted-private-key", }); var staleAuthRequest = await authRequestRepository.CreateAsync(new AuthRequest @@ -66,13 +75,30 @@ public async Task GetManyByUserIdWithDeviceAuth_Works_ReturnsExpectedResults( // Act var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id); + var result = response.First(); - // Assert - Assert.NotNull(response.First().AuthRequestId); - Assert.NotNull(response.First().AuthRequestCreatedAt); - Assert.Equal(response.First().AuthRequestId, freshAuthRequest.Id); + // Assert — device fields + Assert.Equal(device.Id, result.Id); + Assert.Equal(device.UserId, result.UserId); + Assert.Equal(device.Name, result.Name); + Assert.Equal(device.Type, result.Type); + Assert.Equal(device.Identifier, result.Identifier); + Assert.Equal(device.PushToken, result.PushToken); + Assert.NotEqual(default, result.CreationDate); + Assert.NotEqual(default, result.RevisionDate); + Assert.Equal(device.EncryptedUserKey, result.EncryptedUserKey); + Assert.Equal(device.EncryptedPublicKey, result.EncryptedPublicKey); + Assert.Equal(device.EncryptedPrivateKey, result.EncryptedPrivateKey); + Assert.Equal(device.Active, result.Active); + // Assert — most recent pending auth request is returned, not the stale one + Assert.Equal(freshAuthRequest.Id, result.AuthRequestId); + Assert.NotNull(result.AuthRequestCreationDate); } + /// + /// Verifies that when two users share the same device identifier, a pending auth request + /// belonging to one user does not appear on the other user's device results. + /// [DatabaseTheory] [DatabaseData] public async Task GetManyByUserIdWithDeviceAuth_WorksWithMultipleUsersOnSameDevice_ReturnsExpectedResults( @@ -135,9 +161,13 @@ public async Task GetManyByUserIdWithDeviceAuth_WorksWithMultipleUsersOnSameDevi // Assert Assert.Null(response.First().AuthRequestId); - Assert.Null(response.First().AuthRequestCreatedAt); + Assert.Null(response.First().AuthRequestCreationDate); } + /// + /// Verifies that all active devices for a user are returned even when none have + /// a pending auth request, and that AuthRequestId is null in that case. + /// [DatabaseTheory] [DatabaseData] public async Task GetManyByUserIdWithDeviceAuth_WorksWithNoAuthRequestAndMultipleDevices_ReturnsExpectedResults( @@ -175,14 +205,66 @@ await sutRepository.CreateAsync(new Device var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id); // Assert - Assert.NotNull(response.First()); Assert.Null(response.First().AuthRequestId); - Assert.True(response.Count == 2); + Assert.Equal(2, response.Count); } + /// + /// Verifies that IsTrusted is computed from the presence of all three encrypted keys + /// (EncryptedUserKey, EncryptedPublicKey, EncryptedPrivateKey) and is not a stored column. + /// [DatabaseTheory] [DatabaseData] - public async Task GetManyByUserIdWithDeviceAuth_FailsToRespondWithAnyAuthData_ReturnsEmptyResults( + public async Task GetManyByUserIdWithDeviceAuth_IsTrustedComputedCorrectlyAsync( + IDeviceRepository sutRepository, + IUserRepository userRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var trustedDevice = await sutRepository.CreateAsync(new Device + { + Active = true, + Name = "trusted-device", + UserId = user.Id, + Type = DeviceType.ChromeBrowser, + Identifier = Guid.NewGuid().ToString(), + EncryptedUserKey = "encrypted-user-key", + EncryptedPublicKey = "encrypted-public-key", + EncryptedPrivateKey = "encrypted-private-key", + }); + + var untrustedDevice = await sutRepository.CreateAsync(new Device + { + Active = true, + Name = "untrusted-device", + UserId = user.Id, + Type = DeviceType.ChromeBrowser, + Identifier = Guid.NewGuid().ToString(), + }); + + // Act + var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id); + + // Assert — IsTrusted is computed from encrypted keys, not stored as a database column + Assert.True(response.First(d => d.Id == trustedDevice.Id).IsTrusted); + Assert.False(response.First(d => d.Id == untrustedDevice.Id).IsTrusted); + } + + /// + /// Verifies that auth requests which are ineligible — wrong type (AdminApproval), + /// already approved, or expired — do not populate AuthRequestId or AuthRequestCreationDate. + /// The device itself is still returned; only the auth request fields are null. + /// + [DatabaseTheory] + [DatabaseData] + public async Task GetManyByUserIdWithDeviceAuth_IneligibleAuthRequests_ReturnsDeviceWithNullAuthDataAsync( IDeviceRepository sutRepository, IUserRepository userRepository, IAuthRequestRepository authRequestRepository) @@ -191,21 +273,23 @@ public async Task GetManyByUserIdWithDeviceAuth_FailsToRespondWithAnyAuthData_Re { new { - authRequestType = AuthRequestType.AdminApproval, // Device typing is wrong + // Only AuthenticateAndUnlock and Unlock types are eligible for pending auth request matching + // AdminApproval is not eligible, even if it's pending + authRequestType = AuthRequestType.AdminApproval, authRequestApproved = (bool?)null, - expirey = DateTime.UtcNow.AddMinutes(0), + expiry = DateTime.UtcNow.AddMinutes(0), }, new { authRequestType = AuthRequestType.AuthenticateAndUnlock, authRequestApproved = (bool?)true, // Auth request is already approved - expirey = DateTime.UtcNow.AddMinutes(0), + expiry = DateTime.UtcNow.AddMinutes(0), }, new { authRequestType = AuthRequestType.AuthenticateAndUnlock, authRequestApproved = (bool?)null, - expirey = DateTime.UtcNow.AddMinutes(-30), // Past the point of expiring + expiry = DateTime.UtcNow.AddMinutes(-30), // Past the point of expiring } }; @@ -242,15 +326,137 @@ public async Task GetManyByUserIdWithDeviceAuth_FailsToRespondWithAnyAuthData_Re PublicKey = "PublicKey_1234" }); - authRequest.CreationDate = testCase.expirey; + authRequest.CreationDate = testCase.expiry; await authRequestRepository.ReplaceAsync(authRequest); // Act var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id); - // Assert + // Assert — device is returned but with no auth request data + Assert.Single(response); Assert.Null(response.First().AuthRequestId); - Assert.Null(response.First().AuthRequestCreatedAt); + Assert.Null(response.First().AuthRequestCreationDate); } } + + /// + /// Verifies that the Unlock auth request type is treated as a valid pending request + /// and populates AuthRequestId and AuthRequestCreationDate on the device response. + /// + [DatabaseTheory] + [DatabaseData] + public async Task GetManyByUserIdWithDeviceAuth_UnlockAuthRequestType_ReturnsAuthDataAsync( + IDeviceRepository sutRepository, + IUserRepository userRepository, + IAuthRequestRepository authRequestRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var device = await sutRepository.CreateAsync(new Device + { + Active = true, + Name = "chrome-test", + UserId = user.Id, + Type = DeviceType.ChromeBrowser, + Identifier = Guid.NewGuid().ToString(), + }); + + var authRequest = await authRequestRepository.CreateAsync(new AuthRequest + { + ResponseDeviceId = null, + Approved = null, + Type = AuthRequestType.Unlock, + OrganizationId = null, + UserId = user.Id, + RequestIpAddress = ":1", + RequestDeviceIdentifier = device.Identifier, + AccessCode = "AccessCode_1234", + PublicKey = "PublicKey_1234", + }); + + // Act + var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id); + + // Assert — Unlock type (1) is treated as a valid pending auth request type and populates auth data on the device response + Assert.Equal(authRequest.Id, response.First().AuthRequestId); + Assert.NotNull(response.First().AuthRequestCreationDate); + } + + /// + /// Verifies that devices with Active = false are excluded from results. Only active + /// devices should be returned, regardless of whether they have a pending auth request. + /// + [Theory] + [DatabaseData] + public async Task GetManyByUserIdWithDeviceAuth_InactiveDevice_IsExcludedFromResultsAsync( + IDeviceRepository sutRepository, + IUserRepository userRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var activeDevice = await sutRepository.CreateAsync(new Device + { + Active = true, + Name = "active-device", + UserId = user.Id, + Type = DeviceType.ChromeBrowser, + Identifier = Guid.NewGuid().ToString(), + }); + + await sutRepository.CreateAsync(new Device + { + Active = false, + Name = "inactive-device", + UserId = user.Id, + Type = DeviceType.ChromeBrowser, + Identifier = Guid.NewGuid().ToString(), + }); + + // Act + var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id); + + // Assert — only the active device is returned + Assert.Single(response); + Assert.Equal(activeDevice.Id, response.First().Id); + } + + /// + /// Verifies that a user with no registered devices receives an empty collection, + /// not null or an error. + /// + [Theory] + [DatabaseData] + public async Task GetManyByUserIdWithDeviceAuth_UserWithNoDevices_ReturnsEmptyListAsync( + IDeviceRepository sutRepository, + IUserRepository userRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Act + var response = await sutRepository.GetManyByUserIdWithDeviceAuth(user.Id); + + // Assert + Assert.Empty(response); + } } diff --git a/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql b/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql new file mode 100644 index 000000000000..2088ca064a45 --- /dev/null +++ b/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql @@ -0,0 +1,46 @@ +-- PM-34130: Replace SELECT D.* with an explicit column list. +-- Previously, Dapper mapped results by column position into a 14-parameter constructor. +-- A column addition, removal, or reorder in DeviceView would silently assign wrong values +-- with no compile or runtime error. Explicit columns enable name-based mapping via property +-- setters, eliminating the positional dependency and restoring EDD backwards compatibility. +CREATE OR ALTER PROCEDURE [dbo].[Device_ReadActiveWithPendingAuthRequestsByUserId] + @UserId UNIQUEIDENTIFIER, + @ExpirationMinutes INT +AS +BEGIN + SET NOCOUNT ON; + + SELECT + D.[Id], + D.[UserId], + D.[Name], + D.[Type], + D.[Identifier], + D.[PushToken], + D.[CreationDate], + D.[RevisionDate], + D.[EncryptedUserKey], + D.[EncryptedPublicKey], + D.[EncryptedPrivateKey], + D.[Active], + AR.[Id] AS [AuthRequestId], + AR.[CreationDate] AS [AuthRequestCreationDate] + FROM [dbo].[DeviceView] D + LEFT OUTER JOIN ( + SELECT + [Id], + [CreationDate], + [RequestDeviceIdentifier], + [Approved], + ROW_NUMBER() OVER (PARTITION BY [RequestDeviceIdentifier] ORDER BY [CreationDate] DESC) AS rn + FROM [dbo].[AuthRequestView] + WHERE [Type] IN (0,1) -- AuthenticateAndUnlock and Unlock types only + AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired + AND [UserId] = @UserId -- Requests for this user only + ) AR -- This join will get the most recent request per device, regardless of approval status + ON D.[Identifier] = AR.[RequestDeviceIdentifier] AND AR.[rn] = 1 AND AR.[Approved] IS NULL -- Get only the most recent unapproved request per device + WHERE + D.[UserId] = @UserId -- Include only devices for this user + AND D.[Active] = 1; -- Include only active devices +END; +GO From b2161e53277e640094227240312e0aa98d0933f7 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Tue, 7 Apr 2026 19:44:12 -0400 Subject: [PATCH 2/4] PM-34130 - Fix EF constructor in DeviceAuthDetails to copy all Device fields Copy UserId, PushToken, RevisionDate, EncryptedPrivateKey, and Active from the source Device in the EF constructor. Previously these fields were omitted, causing IsTrusted to always return false for EF-sourced results. --- src/Core/Auth/Models/Data/DeviceAuthDetails.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Core/Auth/Models/Data/DeviceAuthDetails.cs b/src/Core/Auth/Models/Data/DeviceAuthDetails.cs index 38e6ebfd181a..be4e17eb448e 100644 --- a/src/Core/Auth/Models/Data/DeviceAuthDetails.cs +++ b/src/Core/Auth/Models/Data/DeviceAuthDetails.cs @@ -28,12 +28,17 @@ public DeviceAuthDetails( } Id = device.Id; + UserId = device.UserId; Name = device.Name; Type = device.Type; Identifier = device.Identifier; + PushToken = device.PushToken; CreationDate = device.CreationDate; - EncryptedPublicKey = device.EncryptedPublicKey; + RevisionDate = device.RevisionDate; EncryptedUserKey = device.EncryptedUserKey; + EncryptedPublicKey = device.EncryptedPublicKey; + EncryptedPrivateKey = device.EncryptedPrivateKey; + Active = device.Active; AuthRequestId = authRequestId; AuthRequestCreationDate = authRequestCreationDate; } From 17980b0e397db3357aa519ecd124b9b2bb1dcb53 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Wed, 8 Apr 2026 18:53:20 -0400 Subject: [PATCH 3/4] PM-34130 - PR feedback resolution --- ...dActiveWithPendingAuthRequestsByUserId.sql | 9 ++++++--- ...dActiveWithPendingAuthRequestsByUserId.sql | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql b/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql index e1b461b9ec1e..f995eeec94f3 100644 --- a/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql +++ b/src/Sql/dbo/Auth/Stored Procedures/Device_ReadActiveWithPendingAuthRequestsByUserId.sql @@ -20,7 +20,8 @@ BEGIN D.[Active], AR.[Id] AS [AuthRequestId], AR.[CreationDate] AS [AuthRequestCreationDate] - FROM [dbo].[DeviceView] D + FROM + [dbo].[DeviceView] D LEFT OUTER JOIN ( SELECT [Id], @@ -28,8 +29,10 @@ BEGIN [RequestDeviceIdentifier], [Approved], ROW_NUMBER() OVER (PARTITION BY [RequestDeviceIdentifier] ORDER BY [CreationDate] DESC) AS rn - FROM [dbo].[AuthRequestView] - WHERE [Type] IN (0,1) -- AuthenticateAndUnlock and Unlock types only + FROM + [dbo].[AuthRequestView] + WHERE + [Type] IN (0,1) -- AuthenticateAndUnlock and Unlock types only AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired AND [UserId] = @UserId -- Requests for this user only ) AR -- This join will get the most recent request per device, regardless of approval status diff --git a/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql b/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql index 2088ca064a45..bff09141715a 100644 --- a/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql +++ b/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql @@ -1,8 +1,10 @@ -- PM-34130: Replace SELECT D.* with an explicit column list. --- Previously, Dapper mapped results by column position into a 14-parameter constructor. --- A column addition, removal, or reorder in DeviceView would silently assign wrong values --- with no compile or runtime error. Explicit columns enable name-based mapping via property --- setters, eliminating the positional dependency and restoring EDD backwards compatibility. +-- Previously, Dapper selected the 14-parameter constructor and mapped columns +-- positionally. A column addition, removal, or reorder in DeviceView would +-- silently assign wrong values with no compile or runtime error. +-- DeviceAuthDetails now has a parameterless constructor, so Dapper maps by +-- property name instead. The explicit column list documents intent and prevents +-- unexpected columns from DeviceView leaking into the result. CREATE OR ALTER PROCEDURE [dbo].[Device_ReadActiveWithPendingAuthRequestsByUserId] @UserId UNIQUEIDENTIFIER, @ExpirationMinutes INT @@ -25,7 +27,8 @@ BEGIN D.[Active], AR.[Id] AS [AuthRequestId], AR.[CreationDate] AS [AuthRequestCreationDate] - FROM [dbo].[DeviceView] D + FROM + [dbo].[DeviceView] D LEFT OUTER JOIN ( SELECT [Id], @@ -33,8 +36,10 @@ BEGIN [RequestDeviceIdentifier], [Approved], ROW_NUMBER() OVER (PARTITION BY [RequestDeviceIdentifier] ORDER BY [CreationDate] DESC) AS rn - FROM [dbo].[AuthRequestView] - WHERE [Type] IN (0,1) -- AuthenticateAndUnlock and Unlock types only + FROM + [dbo].[AuthRequestView] + WHERE + [Type] IN (0,1) -- AuthenticateAndUnlock and Unlock types only AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) -- Ensure the request hasn't expired AND [UserId] = @UserId -- Requests for this user only ) AR -- This join will get the most recent request per device, regardless of approval status From dcb1300261c88561e24a286c5376d98a7367e7c6 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 10 Apr 2026 20:25:33 -0400 Subject: [PATCH 4/4] PM-34130 - Fix migration sort from main merge --- ...01_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename util/Migrator/DbScripts/{2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql => 2026-04-10_01_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql} (100%) diff --git a/util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql b/util/Migrator/DbScripts/2026-04-10_01_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql similarity index 100% rename from util/Migrator/DbScripts/2026-04-07_00_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql rename to util/Migrator/DbScripts/2026-04-10_01_Alter_Device_ReadActiveWithPendingAuthRequestsByUserId.sql