1
0
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:
Addison Beck 2024-05-22 11:55:31 -05:00 committed by GitHub
parent e3f3392ec6
commit 56c523f76f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 240 additions and 0 deletions

View File

@ -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);
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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