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:
parent
be41865b59
commit
5fabad35c7
@ -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)
|
||||
{
|
||||
|
@ -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; }
|
||||
|
@ -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; }
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
@ -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)
|
||||
{
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user