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

[PM-12758] Add managed status to OrganizationUserDetailsResponseModel and OrganizationUserUserDetailsResponse (#4918)

* Refactor OrganizationUsersController.Get to include organization management status of organization users in details endpoint

* Refactor OrganizationUsersController.Get to include organization management status of an individual user in details endpoint

* Remove redundant .ToDictionary()

* Simpify the property xmldoc

* Name tuple variables in OrganizationUsersController.Get

* Name returned tuple objects in GetDetailsByIdWithCollectionsAsync method in OrganizationUserRepository

* Refactor MembersController.Get to destructure tuple returned by GetDetailsByIdWithCollectionsAsync

* Add test for OrganizationUsersController.Get to assert ManagedByOrganization is set accordingly
This commit is contained in:
Rui Tomé 2024-10-24 15:39:35 +01:00 committed by GitHub
parent d38c489443
commit a128cf1506
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 91 additions and 24 deletions

View File

@ -53,6 +53,8 @@ public class OrganizationUsersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IFeatureService _featureService;
public OrganizationUsersController( public OrganizationUsersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -73,7 +75,9 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand) IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IFeatureService featureService)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -94,22 +98,28 @@ public class OrganizationUsersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_featureService = featureService;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<OrganizationUserDetailsResponseModel> Get(string id, bool includeGroups = false) public async Task<OrganizationUserDetailsResponseModel> Get(Guid id, bool includeGroups = false)
{ {
var organizationUser = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(new Guid(id)); var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.Item1.OrganizationId)) if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.OrganizationId))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var response = new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2); var managedByOrganization = await GetManagedByOrganizationStatusAsync(
organizationUser.OrganizationId,
[organizationUser.Id]);
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections);
if (includeGroups) if (includeGroups)
{ {
response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Item1.Id); response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id);
} }
return response; return response;
@ -150,11 +160,13 @@ public class OrganizationUsersController : Controller
} }
); );
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
var responses = organizationUsers var responses = organizationUsers
.Select(o => .Select(o =>
{ {
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled); var managedByOrganization = organizationUsersManagementStatus[o.Id];
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization);
return orgUser; return orgUser;
}); });
@ -682,4 +694,15 @@ public class OrganizationUsersController : Controller
return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r => return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
} }
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
return userIds.ToDictionary(kvp => kvp, kvp => false);
}
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds);
return usersOrganizationManagementStatus;
}
} }

View File

@ -64,20 +64,27 @@ public class OrganizationUserResponseModel : ResponseModel
public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserDetailsResponseModel(OrganizationUser organizationUser, public OrganizationUserDetailsResponseModel(
OrganizationUser organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public bool ManagedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
@ -110,7 +117,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, string obj = "organizationUserUserDetails") bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj) : base(organizationUser, obj)
{ {
if (organizationUser == null) if (organizationUser == null)
@ -127,6 +134,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
Groups = organizationUser.Groups; Groups = organizationUser.Groups;
// Prevent reset password when using key connector. // Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
ManagedByOrganization = managedByOrganization;
} }
public string Name { get; set; } public string Name { get; set; }
@ -134,6 +142,11 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
public string AvatarColor { get; set; } public string AvatarColor { get; set; }
public bool TwoFactorEnabled { get; set; } public bool TwoFactorEnabled { get; set; }
public bool SsoBound { get; set; } public bool SsoBound { get; set; }
/// <summary>
/// Indicates if the organization manages the user. If a user is "managed" by an organization,
/// the organization has greater control over their account, and some user actions are restricted.
/// </summary>
public bool ManagedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
public IEnumerable<Guid> Groups { get; set; } public IEnumerable<Guid> Groups { get; set; }
} }

View File

@ -71,14 +71,13 @@ public class MembersController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)] [ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
var userDetails = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id); var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
var orgUser = userDetails?.Item1;
if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId) if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser), var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser),
userDetails.Item2); collections);
return new JsonResult(response); return new JsonResult(response);
} }

View File

@ -22,8 +22,7 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId); Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);
Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id); Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);
Task<OrganizationUserUserDetails?> GetDetailsByIdAsync(Guid id); Task<OrganizationUserUserDetails?> GetDetailsByIdAsync(Guid id);
Task<Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>> Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id);
GetDetailsByIdWithCollectionsAsync(Guid id);
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false); Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId, Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
OrganizationUserStatusType? status = null); OrganizationUserStatusType? status = null);

View File

@ -196,8 +196,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
return results.SingleOrDefault(); return results.SingleOrDefault();
} }
} }
public async Task<Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>> public async Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id)
GetDetailsByIdWithCollectionsAsync(Guid id)
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))
{ {
@ -206,9 +205,9 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
new { Id = id }, new { Id = id },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
var user = (await results.ReadAsync<OrganizationUserUserDetails>()).SingleOrDefault(); var organizationUserUserDetails = (await results.ReadAsync<OrganizationUserUserDetails>()).SingleOrDefault();
var collections = (await results.ReadAsync<CollectionAccessSelection>()).ToList(); var collections = (await results.ReadAsync<CollectionAccessSelection>()).ToList();
return new Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>(user, collections); return (organizationUserUserDetails, collections);
} }
} }

View File

@ -248,7 +248,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
} }
} }
public async Task<Tuple<OrganizationUserUserDetails, ICollection<CollectionAccessSelection>>> GetDetailsByIdWithCollectionsAsync(Guid id) public async Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id)
{ {
var organizationUserUserDetails = await GetDetailsByIdAsync(id); var organizationUserUserDetails = await GetDetailsByIdAsync(id);
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())
@ -265,7 +265,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
HidePasswords = cu.HidePasswords, HidePasswords = cu.HidePasswords,
Manage = cu.Manage Manage = cu.Manage
}).ToListAsync(); }).ToListAsync();
return new Tuple<OrganizationUserUserDetails, ICollection<CollectionAccessSelection>>(organizationUserUserDetails, collections); return (organizationUserUserDetails, collections);
} }
} }

View File

@ -3,6 +3,7 @@ using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@ -15,6 +16,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
@ -185,14 +187,46 @@ public class OrganizationUsersControllerTests
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.Invite(organizationAbility.Id, model)); await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.Invite(organizationAbility.Id, model));
} }
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task Get_ReturnsUser(
bool accountDeprovisioningEnabled,
OrganizationUserUserDetails organizationUser, ICollection<CollectionAccessSelection> collections,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.Permissions = null;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(accountDeprovisioningEnabled);
sutProvider.GetDependency<ICurrentContext>()
.ManageUsers(organizationUser.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetDetailsByIdWithCollectionsAsync(organizationUser.Id)
.Returns((organizationUser, collections));
sutProvider.GetDependency<IGetOrganizationUsersManagementStatusQuery>()
.GetUsersOrganizationManagementStatusAsync(organizationUser.OrganizationId, Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(organizationUser.Id)))
.Returns(new Dictionary<Guid, bool> { { organizationUser.Id, true } });
var response = await sutProvider.Sut.Get(organizationUser.Id, false);
Assert.Equal(organizationUser.Id, response.Id);
Assert.Equal(accountDeprovisioningEnabled, response.ManagedByOrganization);
}
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task Get_ReturnsUsers( public async Task GetMany_ReturnsUsers(
ICollection<OrganizationUserUserDetails> organizationUsers, OrganizationAbility organizationAbility, ICollection<OrganizationUserUserDetails> organizationUsers, OrganizationAbility organizationAbility,
SutProvider<OrganizationUsersController> sutProvider) SutProvider<OrganizationUsersController> sutProvider)
{ {
Get_Setup(organizationAbility, organizationUsers, sutProvider); GetMany_Setup(organizationAbility, organizationUsers, sutProvider);
var response = await sutProvider.Sut.Get(organizationAbility.Id); var response = await sutProvider.Sut.Get(organizationAbility.Id, false, false);
Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id)));
} }
@ -368,7 +402,7 @@ public class OrganizationUsersControllerTests
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.BulkDeleteAccount(orgId, model)); await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.BulkDeleteAccount(orgId, model));
} }
private void Get_Setup(OrganizationAbility organizationAbility, private void GetMany_Setup(OrganizationAbility organizationAbility,
ICollection<OrganizationUserUserDetails> organizationUsers, ICollection<OrganizationUserUserDetails> organizationUsers,
SutProvider<OrganizationUsersController> sutProvider) SutProvider<OrganizationUsersController> sutProvider)
{ {