mirror of
https://github.com/bitwarden/server.git
synced 2025-05-28 23:04:50 -05: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.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -186,6 +187,20 @@ public class OrganizationUsersController : Controller
|
|||||||
return new OrganizationUserResetPasswordDetailsResponseModel(new OrganizationUserResetPasswordDetails(organizationUser, user, org));
|
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")]
|
[HttpPost("invite")]
|
||||||
public async Task Invite(Guid orgId, [FromBody] OrganizationUserInviteRequestModel model)
|
public async Task Invite(Guid orgId, [FromBody] OrganizationUserInviteRequestModel model)
|
||||||
{
|
{
|
||||||
|
@ -128,6 +128,7 @@ public class OrganizationUserResetPasswordDetailsResponseModel : ResponseModel
|
|||||||
throw new ArgumentNullException(nameof(orgUser));
|
throw new ArgumentNullException(nameof(orgUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OrganizationUserId = orgUser.OrganizationUserId;
|
||||||
Kdf = orgUser.Kdf;
|
Kdf = orgUser.Kdf;
|
||||||
KdfIterations = orgUser.KdfIterations;
|
KdfIterations = orgUser.KdfIterations;
|
||||||
KdfMemory = orgUser.KdfMemory;
|
KdfMemory = orgUser.KdfMemory;
|
||||||
@ -136,6 +137,7 @@ public class OrganizationUserResetPasswordDetailsResponseModel : ResponseModel
|
|||||||
EncryptedPrivateKey = orgUser.EncryptedPrivateKey;
|
EncryptedPrivateKey = orgUser.EncryptedPrivateKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Guid OrganizationUserId { get; set; }
|
||||||
public KdfType Kdf { get; set; }
|
public KdfType Kdf { get; set; }
|
||||||
public int KdfIterations { get; set; }
|
public int KdfIterations { get; set; }
|
||||||
public int? KdfMemory { get; set; }
|
public int? KdfMemory { get; set; }
|
||||||
|
@ -6,6 +6,8 @@ namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
|||||||
|
|
||||||
public class OrganizationUserResetPasswordDetails
|
public class OrganizationUserResetPasswordDetails
|
||||||
{
|
{
|
||||||
|
public OrganizationUserResetPasswordDetails() { }
|
||||||
|
|
||||||
public OrganizationUserResetPasswordDetails(OrganizationUser orgUser, User user, Organization org)
|
public OrganizationUserResetPasswordDetails(OrganizationUser orgUser, User user, Organization org)
|
||||||
{
|
{
|
||||||
if (orgUser == null)
|
if (orgUser == null)
|
||||||
@ -23,6 +25,7 @@ public class OrganizationUserResetPasswordDetails
|
|||||||
throw new ArgumentNullException(nameof(org));
|
throw new ArgumentNullException(nameof(org));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OrganizationUserId = orgUser.Id;
|
||||||
Kdf = user.Kdf;
|
Kdf = user.Kdf;
|
||||||
KdfIterations = user.KdfIterations;
|
KdfIterations = user.KdfIterations;
|
||||||
KdfMemory = user.KdfMemory;
|
KdfMemory = user.KdfMemory;
|
||||||
@ -30,6 +33,7 @@ public class OrganizationUserResetPasswordDetails
|
|||||||
ResetPasswordKey = orgUser.ResetPasswordKey;
|
ResetPasswordKey = orgUser.ResetPasswordKey;
|
||||||
EncryptedPrivateKey = org.PrivateKey;
|
EncryptedPrivateKey = org.PrivateKey;
|
||||||
}
|
}
|
||||||
|
public Guid OrganizationUserId { get; set; }
|
||||||
public KdfType Kdf { get; set; }
|
public KdfType Kdf { get; set; }
|
||||||
public int KdfIterations { get; set; }
|
public int KdfIterations { get; set; }
|
||||||
public int? KdfMemory { get; set; }
|
public int? KdfMemory { get; set; }
|
||||||
|
@ -43,6 +43,7 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
|||||||
Task RestoreAsync(Guid id, OrganizationUserStatusType status);
|
Task RestoreAsync(Guid id, OrganizationUserStatusType status);
|
||||||
Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType);
|
Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType);
|
||||||
Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId);
|
Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId);
|
||||||
|
Task<IEnumerable<OrganizationUserResetPasswordDetails>> GetManyAccountRecoveryDetailsByOrganizationUserAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates encrypted data for organization users during a key rotation
|
/// 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 ExtensionRefresh = "extension-refresh";
|
||||||
public const string RestrictProviderAccess = "restrict-provider-access";
|
public const string RestrictProviderAccess = "restrict-provider-access";
|
||||||
public const string VaultBulkManagementAction = "vault-bulk-management-action";
|
public const string VaultBulkManagementAction = "vault-bulk-management-action";
|
||||||
|
public const string BulkDeviceApproval = "bulk-device-approval";
|
||||||
|
|
||||||
public static List<string> GetAllKeys()
|
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 />
|
/// <inheritdoc />
|
||||||
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
|
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
|
||||||
Guid userId, IEnumerable<OrganizationUser> resetPasswordKeys)
|
Guid userId, IEnumerable<OrganizationUser> resetPasswordKeys)
|
||||||
|
@ -661,6 +661,26 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
|
|||||||
return await GetCountFromQuery(query);
|
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 />
|
/// <inheritdoc />
|
||||||
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
|
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
|
||||||
Guid userId, IEnumerable<Core.Entities.OrganizationUser> resetPasswordKeys)
|
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);
|
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,
|
private void Put_Setup(SutProvider<OrganizationUsersController> sutProvider, OrganizationAbility organizationAbility,
|
||||||
OrganizationUser organizationUser, Guid savingUserId, OrganizationUserUpdateRequestModel model, bool authorizeAll)
|
OrganizationUser organizationUser, Guid savingUserId, OrganizationUserUpdateRequestModel model, bool authorizeAll)
|
||||||
{
|
{
|
||||||
|
@ -95,4 +95,85 @@ public class OrganizationUserRepositoryTests
|
|||||||
Assert.NotEqual(updatedUser1.AccountRevisionDate, user1.AccountRevisionDate);
|
Assert.NotEqual(updatedUser1.AccountRevisionDate, user1.AccountRevisionDate);
|
||||||
Assert.NotEqual(updatedUser2.AccountRevisionDate, user2.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…
x
Reference in New Issue
Block a user