mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
Allow for bulk updating AuthRequest
database objects (#4053)
* Declare a new repository interface method To facilitate a new bulk device login request approval workflow in the admin console we need to update `IAuthRequestRepisitory` (owned by Auth team) to include an`UpdateManyAsync()` method. It should accept a list of `AuthRequest` table objects, and implementations will do a very simple 1:1 update of the passed in data. This commit adds an `UpdateManyAsync()` method to the `AuthRequestRepository` interface. * Stub out method implementations to enable unit testing This commit stubs out implementations of `IAuthRequestRepository.UpdateManyAsync()` so the method signature can be called in unit tests. At this stage the methods are not implemented. * Assert a happy path integration test * Establish a user defined SQL type for Auth Requests To facilitate a bulk update operation for auth requests a new user defined type will need to be written that can be used as a table input to the stored procedure. This will follow a similar pattern to how the `OragnizationSponsorshipType` works and is used by the stored procedure `OrganizationSponsorship_UpdateMany`. * Establish a new stored procedure To facilitate the bulk updating of auth request table objects this commit adds a new stored procedure to update a collection of entities on `AuthRequest` table by their primary key. It updates all properties, for convention, but the endpoint created later will only change the `Approved`, `ResponseDate`, `Key`, `MasterPasswordHash`, and `AuthenticationDate` properties. * Apply a SQL server migration script This commit simply applies a migration script containing the new user defined type and stored procedure comitted previously. * Enable converting an `IEnumerable<AuthRequest>` to a `DataTable` The current pattern in place for bulk update stored procedures is to pass a `DataTable` through Dapper as an input for the update stored procedure being run. In order to facilitate the new bulk update procedure for the`AuthRequest` type we need a function added that can convert an `IEnumerable<AuthRequest>` to a `DataTable`. This is commit follows the convention of having a static class with a conversion method in a `Helpers` folder: `AuthRequestHelpers.ToDataTable()`. * Implement `Dapper/../AuthRequestRepository.UpdateMany()` This commit implements `AuthRequestRepository.UpdateMany()` for the Dapper implementation of `AuthRequestRepository`. It connects the stored procedure, `DataTable` converter, and Dapper-focused unit test commits written previously into one exposed method that can be referenced by service callers. * Implement `EntityFramework/../AuthRequestRepository.UpdateMany()` This commit implements the new `IAuthRequestRepository.UpdateManyAsync()`method in the Entity Framework skew of the repository layer. It checks to make sure the passed in list has auth requests, converts them all to an Entity Framework entity, and then uses `UpdateRange` to apply the whole thing over in the database context. * Assert that `UpdateManyAsync` can not create any new auth requests * Use a json object as stored procedure input * Fix the build * Continuing to troubleshoot the build * Move `AuthRequest_UpdateMany` to the Auth folder * Remove extra comment * Delete type that never got used * intentionally break a test * Unbreak it
This commit is contained in:
parent
e3f3392ec6
commit
56c523f76f
@ -9,4 +9,5 @@ public interface IAuthRequestRepository : IRepository<AuthRequest, Guid>
|
||||
Task<ICollection<AuthRequest>> GetManyByUserIdAsync(Guid userId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyPendingByOrganizationIdAsync(Guid organizationId);
|
||||
Task<ICollection<OrganizationAdminAuthRequest>> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable<Guid> ids);
|
||||
Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
@ -74,4 +75,20 @@ public class AuthRequestRepository : Repository<AuthRequest, Guid>, IAuthRequest
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateManyAsync(IEnumerable<AuthRequest> authRequests)
|
||||
{
|
||||
if (!authRequests.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.ExecuteAsync(
|
||||
$"[dbo].[AuthRequest_UpdateMany]",
|
||||
new { jsonData = JsonSerializer.Serialize(authRequests) },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -69,4 +69,29 @@ public class AuthRequestRepository : Repository<Core.Auth.Entities.AuthRequest,
|
||||
return orgUserAuthRequests;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateManyAsync(IEnumerable<Core.Auth.Entities.AuthRequest> authRequests)
|
||||
{
|
||||
if (!authRequests.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entities = new List<AuthRequest>();
|
||||
foreach (var authRequest in authRequests)
|
||||
{
|
||||
if (!authRequest.Id.Equals(default))
|
||||
{
|
||||
var entity = Mapper.Map<AuthRequest>(authRequest);
|
||||
entities.Add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
dbContext.UpdateRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,45 @@
|
||||
CREATE PROCEDURE AuthRequest_UpdateMany
|
||||
@jsonData NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
UPDATE AR
|
||||
SET
|
||||
[Id] = ARI.[Id],
|
||||
[UserId] = ARI.[UserId],
|
||||
[Type] = ARI.[Type],
|
||||
[RequestDeviceIdentifier] = ARI.[RequestDeviceIdentifier],
|
||||
[RequestDeviceType] = ARI.[RequestDeviceType],
|
||||
[RequestIpAddress] = ARI.[RequestIpAddress],
|
||||
[ResponseDeviceId] = ARI.[ResponseDeviceId],
|
||||
[AccessCode] = ARI.[AccessCode],
|
||||
[PublicKey] = ARI.[PublicKey],
|
||||
[Key] = ARI.[Key],
|
||||
[MasterPasswordHash] = ARI.[MasterPasswordHash],
|
||||
[Approved] = ARI.[Approved],
|
||||
[CreationDate] = ARI.[CreationDate],
|
||||
[ResponseDate] = ARI.[ResponseDate],
|
||||
[AuthenticationDate] = ARI.[AuthenticationDate],
|
||||
[OrganizationId] = ARI.[OrganizationId]
|
||||
FROM
|
||||
[dbo].[AuthRequest] AR
|
||||
INNER JOIN
|
||||
OPENJSON(@jsonData)
|
||||
WITH (
|
||||
Id UNIQUEIDENTIFIER '$.Id',
|
||||
UserId UNIQUEIDENTIFIER '$.UserId',
|
||||
Type SMALLINT '$.Type',
|
||||
RequestDeviceIdentifier NVARCHAR(50) '$.RequestDeviceIdentifier',
|
||||
RequestDeviceType SMALLINT '$.RequestDeviceType',
|
||||
RequestIpAddress VARCHAR(50) '$.RequestIpAddress',
|
||||
ResponseDeviceId UNIQUEIDENTIFIER '$.ResponseDeviceId',
|
||||
AccessCode VARCHAR(25) '$.AccessCode',
|
||||
PublicKey VARCHAR(MAX) '$.PublicKey',
|
||||
[Key] VARCHAR(MAX) '$.Key',
|
||||
MasterPasswordHash VARCHAR(MAX) '$.MasterPasswordHash',
|
||||
Approved BIT '$.Approved',
|
||||
CreationDate DATETIME2 '$.CreationDate',
|
||||
ResponseDate DATETIME2 '$.ResponseDate',
|
||||
AuthenticationDate DATETIME2 '$.AuthenticationDate',
|
||||
OrganizationId UNIQUEIDENTIFIER '$.OrganizationId'
|
||||
) ARI ON AR.Id = ARI.Id;
|
||||
END
|
@ -72,6 +72,113 @@ public class AuthRequestRepositoryTests
|
||||
Assert.Equal(4, numberOfDeleted);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task UpdateManyAsync_Works(
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
// Create two distinct real users for foreign key requirements
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "First Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Second Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var user3 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Third Test User",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
// Create two different and still valid (not expired or responded to) auth requests
|
||||
var authRequests = new List<AuthRequest>
|
||||
{
|
||||
await authRequestRepository.CreateAsync(CreateAuthRequest(user1.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-5))),
|
||||
await authRequestRepository.CreateAsync(CreateAuthRequest(user3.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-7))),
|
||||
await authRequestRepository.CreateAsync(CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-10))),
|
||||
// This last auth request is not created manually, and will be
|
||||
// used to make sure entity framework's `UpdateRange` method
|
||||
// doesn't create requests too.
|
||||
CreateAuthRequest(user2.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddMinutes(-11))
|
||||
};
|
||||
|
||||
// Update some properties on two auth request, but leave the other one
|
||||
// alone to be a control value
|
||||
var authRequestToBeUpdated1 = authRequests[0];
|
||||
var authRequestToBeUpdated2 = authRequests[1];
|
||||
var authRequestNotToBeUpdated = authRequests[2];
|
||||
authRequests[0].Approved = true;
|
||||
authRequests[0].ResponseDate = DateTime.UtcNow.AddMinutes(-1);
|
||||
authRequests[0].Key = "UPDATED_KEY_1";
|
||||
authRequests[0].MasterPasswordHash = "UPDATED_MASTERPASSWORDHASH_1";
|
||||
|
||||
authRequests[1].Approved = false;
|
||||
authRequests[1].ResponseDate = DateTime.UtcNow.AddMinutes(-2);
|
||||
|
||||
// Run the method being tested
|
||||
await authRequestRepository.UpdateManyAsync(authRequests);
|
||||
|
||||
// Define what "Equality" really means in this context
|
||||
// This includes stripping milliseconds off of dates, because we can't
|
||||
// reliably compare that deep
|
||||
static DateTime? TrimMilliseconds(DateTime? dt)
|
||||
{
|
||||
if (!dt.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new DateTime(dt.Value.Year, dt.Value.Month, dt.Value.Day, dt.Value.Hour, dt.Value.Minute, dt.Value.Second, 0, dt.Value.Kind);
|
||||
}
|
||||
|
||||
bool AuthRequestEquals(AuthRequest x, AuthRequest y)
|
||||
{
|
||||
return
|
||||
x.Id == y.Id &&
|
||||
x.UserId == y.UserId &&
|
||||
x.Type == y.Type &&
|
||||
x.RequestDeviceIdentifier == y.RequestDeviceIdentifier &&
|
||||
x.RequestDeviceType == y.RequestDeviceType &&
|
||||
x.RequestIpAddress == y.RequestIpAddress &&
|
||||
x.ResponseDeviceId == y.ResponseDeviceId &&
|
||||
x.AccessCode == y.AccessCode &&
|
||||
x.PublicKey == y.PublicKey &&
|
||||
x.Key == y.Key &&
|
||||
x.MasterPasswordHash == y.MasterPasswordHash &&
|
||||
x.Approved == y.Approved &&
|
||||
TrimMilliseconds(x.CreationDate) == TrimMilliseconds(y.CreationDate) &&
|
||||
TrimMilliseconds(x.ResponseDate) == TrimMilliseconds(y.ResponseDate) &&
|
||||
TrimMilliseconds(x.AuthenticationDate) == TrimMilliseconds(y.AuthenticationDate) &&
|
||||
x.OrganizationId == y.OrganizationId;
|
||||
}
|
||||
|
||||
// Assert that the unchanged auth request is still unchanged
|
||||
var skippedAuthRequest = await authRequestRepository.GetByIdAsync(authRequestNotToBeUpdated.Id);
|
||||
Assert.True(AuthRequestEquals(skippedAuthRequest, authRequestNotToBeUpdated));
|
||||
|
||||
// Assert that the values updated on the changed auth requests were updated, and no others
|
||||
var updatedAuthRequest1 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated1.Id);
|
||||
Assert.True(AuthRequestEquals(authRequestToBeUpdated1, updatedAuthRequest1));
|
||||
var updatedAuthRequest2 = await authRequestRepository.GetByIdAsync(authRequestToBeUpdated2.Id);
|
||||
Assert.True(AuthRequestEquals(authRequestToBeUpdated2, updatedAuthRequest2));
|
||||
|
||||
// Assert that the auth request we never created is not created by
|
||||
// the update method.
|
||||
var uncreatedAuthRequest = await authRequestRepository.GetByIdAsync(authRequests[3].Id);
|
||||
Assert.Null(uncreatedAuthRequest);
|
||||
}
|
||||
|
||||
private static AuthRequest CreateAuthRequest(Guid userId, AuthRequestType authRequestType, DateTime creationDate, bool? approved = null, DateTime? responseDate = null)
|
||||
{
|
||||
return new AuthRequest
|
||||
|
@ -0,0 +1,45 @@
|
||||
CREATE PROCEDURE AuthRequest_UpdateMany
|
||||
@jsonData NVARCHAR(MAX)
|
||||
AS
|
||||
BEGIN
|
||||
UPDATE AR
|
||||
SET
|
||||
[Id] = ARI.[Id],
|
||||
[UserId] = ARI.[UserId],
|
||||
[Type] = ARI.[Type],
|
||||
[RequestDeviceIdentifier] = ARI.[RequestDeviceIdentifier],
|
||||
[RequestDeviceType] = ARI.[RequestDeviceType],
|
||||
[RequestIpAddress] = ARI.[RequestIpAddress],
|
||||
[ResponseDeviceId] = ARI.[ResponseDeviceId],
|
||||
[AccessCode] = ARI.[AccessCode],
|
||||
[PublicKey] = ARI.[PublicKey],
|
||||
[Key] = ARI.[Key],
|
||||
[MasterPasswordHash] = ARI.[MasterPasswordHash],
|
||||
[Approved] = ARI.[Approved],
|
||||
[CreationDate] = ARI.[CreationDate],
|
||||
[ResponseDate] = ARI.[ResponseDate],
|
||||
[AuthenticationDate] = ARI.[AuthenticationDate],
|
||||
[OrganizationId] = ARI.[OrganizationId]
|
||||
FROM
|
||||
[dbo].[AuthRequest] AR
|
||||
INNER JOIN
|
||||
OPENJSON(@jsonData)
|
||||
WITH (
|
||||
Id UNIQUEIDENTIFIER '$.Id',
|
||||
UserId UNIQUEIDENTIFIER '$.UserId',
|
||||
Type SMALLINT '$.Type',
|
||||
RequestDeviceIdentifier NVARCHAR(50) '$.RequestDeviceIdentifier',
|
||||
RequestDeviceType SMALLINT '$.RequestDeviceType',
|
||||
RequestIpAddress VARCHAR(50) '$.RequestIpAddress',
|
||||
ResponseDeviceId UNIQUEIDENTIFIER '$.ResponseDeviceId',
|
||||
AccessCode VARCHAR(25) '$.AccessCode',
|
||||
PublicKey VARCHAR(MAX) '$.PublicKey',
|
||||
[Key] VARCHAR(MAX) '$.Key',
|
||||
MasterPasswordHash VARCHAR(MAX) '$.MasterPasswordHash',
|
||||
Approved BIT '$.Approved',
|
||||
CreationDate DATETIME2 '$.CreationDate',
|
||||
ResponseDate DATETIME2 '$.ResponseDate',
|
||||
AuthenticationDate DATETIME2 '$.AuthenticationDate',
|
||||
OrganizationId UNIQUEIDENTIFIER '$.OrganizationId'
|
||||
) ARI ON AR.Id = ARI.Id;
|
||||
END
|
Loading…
Reference in New Issue
Block a user