1
0
mirror of https://github.com/bitwarden/server synced 2026-01-07 19:13:50 +00:00

[PM-22241] Add DefaultUserCollectionName support to bulk organization user confirmation (#6153)

* Implement GetByOrganizationAsync method in PolicyRequirementQuery and add corresponding unit tests

* Refactor ConfirmOrganizationUserCommand for clarity and add bulk support

* Update ConfirmOrganizationUserCommandTests to use GetByOrganizationAsync for policy requirement queries

* Add DefaultUserCollectionName property to OrganizationUserBulkConfirmRequestModel with encryption attributes

* Update ConfirmUsersAsync method to include DefaultUserCollectionName parameter in OrganizationUsersController

* Add EnableOrganizationDataOwnershipPolicyAsync method to OrganizationTestHelpers

* Add integration tests for confirming organization users in OrganizationUserControllerTests

- Implemented Confirm_WithValidUser test to verify successful confirmation of a single user.
- Added BulkConfirm_WithValidUsers test to ensure multiple users can be confirmed successfully.

* Refactor organization user confirmation integration tests to also test when the organization data ownership policy is disabled

* Refactor ConfirmOrganizationUserCommand to consolidate confirmation side effects handling

- Replaced single and bulk confirmation side effect methods with a unified HandleConfirmationSideEffectsAsync method.
- Updated related logic to handle confirmed organization users more efficiently.
- Adjusted unit tests to reflect changes in the collection creation process for confirmed users.

* Refactor OrganizationUserControllerTests to simplify feature flag handling and consolidate test logic

- Removed redundant feature flag checks in Confirm and BulkConfirm tests.
- Updated tests to directly enable the Organization Data Ownership policy without conditional checks.
- Ensured verification of DefaultUserCollection for confirmed users remains intact.

* Refactor OrganizationUserControllerTests to enhance clarity and reduce redundancy

- Simplified user creation and confirmation logic in tests by introducing helper methods.
- Consolidated verification of confirmed users and their associated collections.
- Removed unnecessary comments and streamlined test flow for better readability.
This commit is contained in:
Rui Tomé
2025-08-05 15:34:13 +01:00
committed by GitHub
parent 11cc50af6e
commit 7454430aa1
10 changed files with 294 additions and 48 deletions

View File

@@ -340,7 +340,7 @@ public class OrganizationUsersController : Controller
[FromBody] OrganizationUserBulkConfirmRequestModel model)
{
var userId = _userService.GetProperUserId(User);
var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgId, model.ToDictionary(), userId.Value);
var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgId, model.ToDictionary(), userId.Value, model.DefaultUserCollectionName);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));

View File

@@ -82,6 +82,10 @@ public class OrganizationUserBulkConfirmRequestModel
[Required]
public IEnumerable<OrganizationUserBulkConfirmRequestModelEntry> Keys { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string DefaultUserCollectionName { get; set; }
public Dictionary<Guid, string> ToDictionary()
{
return Keys.ToDictionary(e => e.Id, e => e.Key);

View File

@@ -11,7 +11,6 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -67,7 +66,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId, string defaultUserCollectionName = null)
{
var result = await ConfirmUsersAsync(
var result = await SaveChangesToDatabaseAsync(
organizationId,
new Dictionary<Guid, string>() { { organizationUserId, key } },
confirmingUserId);
@@ -83,12 +82,30 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
throw new BadRequestException(error);
}
await HandleConfirmationSideEffectsAsync(organizationId, orgUser, defaultUserCollectionName);
await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers: [orgUser], defaultUserCollectionName);
return orgUser;
}
public async Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId, string defaultUserCollectionName = null)
{
var result = await SaveChangesToDatabaseAsync(organizationId, keys, confirmingUserId);
var confirmedOrganizationUsers = result
.Where(r => string.IsNullOrEmpty(r.Item2))
.Select(r => r.Item1)
.ToList();
if (confirmedOrganizationUsers.Count > 0)
{
await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName);
}
return result;
}
private async Task<List<Tuple<OrganizationUser, string>>> SaveChangesToDatabaseAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId)
{
var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys);
@@ -227,17 +244,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
.Select(d => d.Id.ToString());
}
private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, OrganizationUser organizationUser, string defaultUserCollectionName)
{
// Create DefaultUserCollection type collection for the user if the OrganizationDataOwnership policy is enabled for the organization
var requiresDefaultCollection = await OrganizationRequiresDefaultCollectionAsync(organizationId, organizationUser.UserId.Value, defaultUserCollectionName);
if (requiresDefaultCollection)
{
await CreateDefaultCollectionAsync(organizationId, organizationUser.Id, defaultUserCollectionName);
}
}
private async Task<bool> OrganizationRequiresDefaultCollectionAsync(Guid organizationId, Guid userId, string defaultUserCollectionName)
private async Task<bool> OrganizationRequiresDefaultCollectionAsync(Guid organizationId, string defaultUserCollectionName)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
{
@@ -250,30 +257,29 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return false;
}
var organizationDataOwnershipRequirement = await _policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(userId);
return organizationDataOwnershipRequirement.RequiresDefaultCollection(organizationId);
var organizationPolicyRequirement = await _policyRequirementQuery.GetByOrganizationAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId);
// Check if the organization requires default collections
return organizationPolicyRequirement.RequiresDefaultCollection(organizationId);
}
private async Task CreateDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName)
/// <summary>
/// Handles the side effects of confirming an organization user.
/// Creates a default collection for the user if the organization
/// has the OrganizationDataOwnership policy enabled.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="confirmedOrganizationUsers">The confirmed organization users.</param>
/// <param name="defaultUserCollectionName">The encrypted default user collection name.</param>
private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, IEnumerable<OrganizationUser> confirmedOrganizationUsers, string defaultUserCollectionName)
{
var collection = new Collection
var requiresDefaultCollections = await OrganizationRequiresDefaultCollectionAsync(organizationId, defaultUserCollectionName);
if (!requiresDefaultCollections)
{
OrganizationId = organizationId,
Name = defaultCollectionName,
Type = CollectionType.DefaultUserCollection
};
return;
}
var userAccess = new List<CollectionAccessSelection>
{
new CollectionAccessSelection
{
Id = organizationUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true
}
};
await _collectionRepository.CreateAsync(collection, groups: null, users: userAccess);
var organizationUserIds = confirmedOrganizationUsers.Select(u => u.Id).ToList();
await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultUserCollectionName);
}
}

View File

@@ -29,7 +29,8 @@ public interface IConfirmOrganizationUserCommand
/// <param name="organizationId">The ID of the organization.</param>
/// <param name="keys">A dictionary mapping organization user IDs to their encrypted organization keys.</param>
/// <param name="confirmingUserId">The ID of the user performing the confirmation.</param>
/// <param name="defaultUserCollectionName">Optional encrypted collection name for creating default collections.</param>
/// <returns>A list of tuples containing the organization user and an error message (if any).</returns>
Task<List<Tuple<OrganizationUser, string>>> ConfirmUsersAsync(Guid organizationId, Dictionary<Guid, string> keys,
Guid confirmingUserId);
Guid confirmingUserId, string defaultUserCollectionName = null);
}

View File

@@ -15,4 +15,14 @@ public interface IPolicyRequirementQuery
/// <param name="userId">The user that you need to enforce the policy against.</param>
/// <typeparam name="T">The IPolicyRequirement that corresponds to the policy you want to enforce.</typeparam>
Task<T> GetAsync<T>(Guid userId) where T : IPolicyRequirement;
/// <summary>
/// Get a policy requirement for a specific organization.
/// This returns the policy requirement that represents the policy state for the entire organization.
/// It will always return a value even if there are no policies that should be enforced.
/// This should be used for organization-level policy checks.
/// </summary>
/// <param name="organizationId">The organization to check policies for.</param>
/// <typeparam name="T">The IPolicyRequirement that corresponds to the policy you want to enforce.</typeparam>
Task<T> GetByOrganizationAsync<T>(Guid organizationId) where T : IPolicyRequirement;
}

View File

@@ -1,5 +1,6 @@
#nullable enable
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
@@ -27,6 +28,27 @@ public class PolicyRequirementQuery(
return requirement;
}
public async Task<T> GetByOrganizationAsync<T>(Guid organizationId) where T : IPolicyRequirement
{
var factory = factories.OfType<IPolicyRequirementFactory<T>>().SingleOrDefault();
if (factory is null)
{
throw new NotImplementedException("No Requirement Factory found for " + typeof(T));
}
var organizationPolicyDetails = await GetOrganizationPolicyDetails(organizationId, factory.PolicyType);
var filteredPolicies = organizationPolicyDetails
.Cast<PolicyDetails>()
.Where(policyDetails => policyDetails.PolicyType == factory.PolicyType)
.Where(factory.Enforce)
.ToList();
var requirement = factory.Create(filteredPolicies);
return requirement;
}
private Task<IEnumerable<PolicyDetails>> GetPolicyDetails(Guid userId)
=> policyRepository.GetPolicyDetailsByUserId(userId);
private async Task<IEnumerable<OrganizationPolicyDetails>> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType)
=> await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType);
}