1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[AC-2328] Add a Bulk OrganizationUsersController.GetResetPasswordDetails endpoint (#4079)

* Add new stored procedure for reading reset password details for multiple organization user IDs

* Add method IOrganizationUserRepository.GetManyResetPasswordDetailsByOrganizationUserAsync

* Add new API endpoint for getting reset password details for multiple organization users

* Add unit tests for bulk OrganizationUsersController.GetResetPasswordDetails

* Add alias to sql query result column

* Add constructor for automatic mapping

* Fix http method type for endpoint

* dotnet format

* Simplify the constructor in the OrganizationUserResetPasswordDetails

* Refactor stored procedure and repository method names for retrieving account recovery details

* Add integration tests for GetManyAccountRecoveryDetailsByOrganizationUserAsync

* Lock endpoint behind BulkDeviceApproval feature flag

* Update feature flag key value
This commit is contained in:
Rui Tomé 2024-05-24 11:20:54 +01:00 committed by GitHub
parent be41865b59
commit 5fabad35c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 225 additions and 0 deletions

View File

@ -20,6 +20,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -186,6 +187,20 @@ public class OrganizationUsersController : Controller
return new OrganizationUserResetPasswordDetailsResponseModel(new OrganizationUserResetPasswordDetails(organizationUser, user, org));
}
[RequireFeature(FeatureFlagKeys.BulkDeviceApproval)]
[HttpPost("account-recovery-details")]
public async Task<ListResponseModel<OrganizationUserResetPasswordDetailsResponseModel>> GetAccountRecoveryDetails(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
// Make sure the calling user can reset passwords for this org
if (!await _currentContext.ManageResetPassword(orgId))
{
throw new NotFoundException();
}
var responses = await _organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync(orgId, model.Ids);
return new ListResponseModel<OrganizationUserResetPasswordDetailsResponseModel>(responses.Select(r => new OrganizationUserResetPasswordDetailsResponseModel(r)));
}
[HttpPost("invite")]
public async Task Invite(Guid orgId, [FromBody] OrganizationUserInviteRequestModel model)
{

View File

@ -128,6 +128,7 @@ public class OrganizationUserResetPasswordDetailsResponseModel : ResponseModel
throw new ArgumentNullException(nameof(orgUser));
}
OrganizationUserId = orgUser.OrganizationUserId;
Kdf = orgUser.Kdf;
KdfIterations = orgUser.KdfIterations;
KdfMemory = orgUser.KdfMemory;
@ -136,6 +137,7 @@ public class OrganizationUserResetPasswordDetailsResponseModel : ResponseModel
EncryptedPrivateKey = orgUser.EncryptedPrivateKey;
}
public Guid OrganizationUserId { get; set; }
public KdfType Kdf { get; set; }
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }

View File

@ -6,6 +6,8 @@ namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationUserResetPasswordDetails
{
public OrganizationUserResetPasswordDetails() { }
public OrganizationUserResetPasswordDetails(OrganizationUser orgUser, User user, Organization org)
{
if (orgUser == null)
@ -23,6 +25,7 @@ public class OrganizationUserResetPasswordDetails
throw new ArgumentNullException(nameof(org));
}
OrganizationUserId = orgUser.Id;
Kdf = user.Kdf;
KdfIterations = user.KdfIterations;
KdfMemory = user.KdfMemory;
@ -30,6 +33,7 @@ public class OrganizationUserResetPasswordDetails
ResetPasswordKey = orgUser.ResetPasswordKey;
EncryptedPrivateKey = org.PrivateKey;
}
public Guid OrganizationUserId { get; set; }
public KdfType Kdf { get; set; }
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }

View File

@ -43,6 +43,7 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task RestoreAsync(Guid id, OrganizationUserStatusType status);
Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType);
Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId);
Task<IEnumerable<OrganizationUserResetPasswordDetails>> GetManyAccountRecoveryDetailsByOrganizationUserAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds);
/// <summary>
/// Updates encrypted data for organization users during a key rotation

View File

@ -127,6 +127,7 @@ public static class FeatureFlagKeys
public const string ExtensionRefresh = "extension-refresh";
public const string RestrictProviderAccess = "restrict-provider-access";
public const string VaultBulkManagementAction = "vault-bulk-management-action";
public const string BulkDeviceApproval = "bulk-device-approval";
public static List<string> GetAllKeys()
{

View File

@ -523,6 +523,20 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
}
}
public async Task<IEnumerable<OrganizationUserResetPasswordDetails>> GetManyAccountRecoveryDetailsByOrganizationUserAsync(
Guid organizationId, IEnumerable<Guid> organizationUserIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<OrganizationUserResetPasswordDetails>(
"[dbo].[OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds]",
new { OrganizationId = organizationId, OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
/// <inheritdoc />
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
Guid userId, IEnumerable<OrganizationUser> resetPasswordKeys)

View File

@ -661,6 +661,26 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
return await GetCountFromQuery(query);
}
public async Task<IEnumerable<OrganizationUserResetPasswordDetails>>
GetManyAccountRecoveryDetailsByOrganizationUserAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = from ou in dbContext.OrganizationUsers
where organizationUserIds.Contains(ou.Id)
join u in dbContext.Users
on ou.UserId equals u.Id
join o in dbContext.Organizations
on ou.OrganizationId equals o.Id
where ou.OrganizationId == organizationId
select new { ou, u, o };
var data = await query
.Select(x => new OrganizationUserResetPasswordDetails(x.ou, x.u, x.o)).ToListAsync();
return data;
}
}
/// <inheritdoc />
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
Guid userId, IEnumerable<Core.Entities.OrganizationUser> resetPasswordKeys)

View File

@ -0,0 +1,24 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds]
@OrganizationId UNIQUEIDENTIFIER,
@OrganizationUserIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
SELECT
OU.[Id] AS OrganizationUserId,
U.[Kdf],
U.[KdfIterations],
U.[KdfMemory],
U.[KdfParallelism],
OU.[ResetPasswordKey],
O.[PrivateKey] AS EncryptedPrivateKey
FROM @OrganizationUserIds AS OUIDs
INNER JOIN [dbo].[OrganizationUser] AS OU
ON OUIDs.[Id] = OU.[Id]
INNER JOIN [dbo].[Organization] AS O
ON OU.[OrganizationId] = O.[Id]
INNER JOIN [dbo].[User] U
ON U.[Id] = OU.[UserId]
WHERE OU.[OrganizationId] = @OrganizationId
END

View File

@ -471,6 +471,45 @@ public class OrganizationUsersControllerTests
Assert.False(customUserResponse.Permissions.DeleteAssignedCollections);
}
[Theory]
[BitAutoData]
public async Task GetAccountRecoveryDetails_ReturnsDetails(
Guid organizationId,
OrganizationUserBulkRequestModel bulkRequestModel,
ICollection<OrganizationUserResetPasswordDetails> resetPasswordDetails,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ManageResetPassword(organizationId).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAccountRecoveryDetailsByOrganizationUserAsync(organizationId, bulkRequestModel.Ids)
.Returns(resetPasswordDetails);
var response = await sutProvider.Sut.GetAccountRecoveryDetails(organizationId, bulkRequestModel);
Assert.Equal(resetPasswordDetails.Count, response.Data.Count());
Assert.True(response.Data.All(r =>
resetPasswordDetails.Any(ou =>
ou.OrganizationUserId == r.OrganizationUserId &&
ou.Kdf == r.Kdf &&
ou.KdfIterations == r.KdfIterations &&
ou.KdfMemory == r.KdfMemory &&
ou.KdfParallelism == r.KdfParallelism &&
ou.ResetPasswordKey == r.ResetPasswordKey &&
ou.EncryptedPrivateKey == r.EncryptedPrivateKey)));
}
[Theory]
[BitAutoData]
public async Task GetAccountRecoveryDetails_WithoutManageResetPasswordPermission_Throws(
Guid organizationId,
OrganizationUserBulkRequestModel bulkRequestModel,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ManageResetPassword(organizationId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetAccountRecoveryDetails(organizationId, bulkRequestModel));
}
private void Put_Setup(SutProvider<OrganizationUsersController> sutProvider, OrganizationAbility organizationAbility,
OrganizationUser organizationUser, Guid savingUserId, OrganizationUserUpdateRequestModel model, bool authorizeAll)
{

View File

@ -95,4 +95,85 @@ public class OrganizationUserRepositoryTests
Assert.NotEqual(updatedUser1.AccountRevisionDate, user1.AccountRevisionDate);
Assert.NotEqual(updatedUser2.AccountRevisionDate, user2.AccountRevisionDate);
}
[DatabaseTheory, DatabaseData]
public async Task GetManyAccountRecoveryDetailsByOrganizationUserAsync_Works(IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository)
{
var user1 = await userRepository.CreateAsync(new User
{
Name = "Test User 1",
Email = $"test+{Guid.NewGuid()}@example.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});
var user2 = await userRepository.CreateAsync(new User
{
Name = "Test User 2",
Email = $"test+{Guid.NewGuid()}@example.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.Argon2id,
KdfIterations = 4,
KdfMemory = 5,
KdfParallelism = 6
});
var organization = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl
Plan = "Test", // TODO: EF does not enforce this being NOT NULl
PrivateKey = "privatekey",
});
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user1.Id,
Status = OrganizationUserStatusType.Confirmed,
ResetPasswordKey = "resetpasswordkey1",
});
var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user2.Id,
Status = OrganizationUserStatusType.Confirmed,
ResetPasswordKey = "resetpasswordkey2",
});
var recoveryDetails = await organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync(
organization.Id,
new[]
{
orgUser1.Id,
orgUser2.Id,
});
Assert.NotNull(recoveryDetails);
Assert.Equal(2, recoveryDetails.Count());
Assert.Contains(recoveryDetails, r =>
r.OrganizationUserId == orgUser1.Id &&
r.Kdf == KdfType.PBKDF2_SHA256 &&
r.KdfIterations == 1 &&
r.KdfMemory == 2 &&
r.KdfParallelism == 3 &&
r.ResetPasswordKey == "resetpasswordkey1" &&
r.EncryptedPrivateKey == "privatekey");
Assert.Contains(recoveryDetails, r =>
r.OrganizationUserId == orgUser2.Id &&
r.Kdf == KdfType.Argon2id &&
r.KdfIterations == 4 &&
r.KdfMemory == 5 &&
r.KdfParallelism == 6 &&
r.ResetPasswordKey == "resetpasswordkey2" &&
r.EncryptedPrivateKey == "privatekey");
}
}

View File

@ -0,0 +1,24 @@
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds]
@OrganizationId UNIQUEIDENTIFIER,
@OrganizationUserIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
SELECT
OU.[Id] AS OrganizationUserId,
U.[Kdf],
U.[KdfIterations],
U.[KdfMemory],
U.[KdfParallelism],
OU.[ResetPasswordKey],
O.[PrivateKey] AS EncryptedPrivateKey
FROM @OrganizationUserIds AS OUIDs
INNER JOIN [dbo].[OrganizationUser] AS OU
ON OUIDs.[Id] = OU.[Id]
INNER JOIN [dbo].[Organization] AS O
ON OU.[OrganizationId] = O.[Id]
INNER JOIN [dbo].[User] U
ON U.[Id] = OU.[UserId]
WHERE OU.[OrganizationId] = @OrganizationId
END