1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-03 17:12:49 -05:00

[AC-1713] [Flexible collections] Add feature flags to server (#3334)

* Add feature flags for FlexibleCollections and BulkCollectionAccess

* Flag new routes and behaviour

---------

Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com>
This commit is contained in:
Thomas Rittson
2023-10-18 07:16:01 +10:00
committed by GitHub
parent 6bc38ac3d1
commit 3b049a64b8
10 changed files with 479 additions and 20 deletions

View File

@ -1,12 +1,14 @@
using Bit.Api.Models.Request; using Bit.Api.Models.Request;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationCollections.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;
@ -23,6 +25,7 @@ public class CollectionsController : Controller
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand; private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand;
private readonly IFeatureService _featureService;
public CollectionsController( public CollectionsController(
ICollectionRepository collectionRepository, ICollectionRepository collectionRepository,
@ -31,7 +34,8 @@ public class CollectionsController : Controller
IUserService userService, IUserService userService,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
ICurrentContext currentContext, ICurrentContext currentContext,
IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand) IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand,
IFeatureService featureService)
{ {
_collectionRepository = collectionRepository; _collectionRepository = collectionRepository;
_collectionService = collectionService; _collectionService = collectionService;
@ -40,8 +44,11 @@ public class CollectionsController : Controller
_authorizationService = authorizationService; _authorizationService = authorizationService;
_currentContext = currentContext; _currentContext = currentContext;
_bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand; _bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand;
_featureService = featureService;
} }
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<CollectionResponseModel> Get(Guid orgId, Guid id) public async Task<CollectionResponseModel> Get(Guid orgId, Guid id)
{ {
@ -152,8 +159,10 @@ public class CollectionsController : Controller
{ {
var collection = model.ToCollection(orgId); var collection = model.ToCollection(orgId);
var result = await _authorizationService.AuthorizeAsync(User, collection, CollectionOperations.Create); var authorized = FlexibleCollectionsIsEnabled
if (!result.Succeeded) ? (await _authorizationService.AuthorizeAsync(User, collection, CollectionOperations.Create)).Succeeded
: await CanCreateCollection(orgId, collection.Id) || await CanEditCollectionAsync(orgId, collection.Id);
if (!authorized)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -194,6 +203,10 @@ public class CollectionsController : Controller
} }
[HttpPost("bulk-access")] [HttpPost("bulk-access")]
[RequireFeature(FeatureFlagKeys.BulkCollectionAccess)]
// Also gated behind Flexible Collections flag because it only has new authorization logic.
// Could be removed if legacy authorization logic were implemented for many collections.
[RequireFeature(FeatureFlagKeys.FlexibleCollections)]
public async Task PostBulkCollectionAccess([FromBody] BulkCollectionAccessRequestModel model) public async Task PostBulkCollectionAccess([FromBody] BulkCollectionAccessRequestModel model)
{ {
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds); var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds);
@ -221,8 +234,11 @@ public class CollectionsController : Controller
public async Task Delete(Guid orgId, Guid id) public async Task Delete(Guid orgId, Guid id)
{ {
var collection = await GetCollectionAsync(id, orgId); var collection = await GetCollectionAsync(id, orgId);
var result = await _authorizationService.AuthorizeAsync(User, collection, CollectionOperations.Delete);
if (!result.Succeeded) var authorized = FlexibleCollectionsIsEnabled
? (await _authorizationService.AuthorizeAsync(User, collection, CollectionOperations.Delete)).Succeeded
: await CanDeleteCollectionAsync(orgId, id);
if (!authorized)
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -232,8 +248,11 @@ public class CollectionsController : Controller
[HttpDelete("")] [HttpDelete("")]
[HttpPost("delete")] [HttpPost("delete")]
public async Task DeleteMany([FromBody] CollectionBulkDeleteRequestModel model) public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model)
{ {
if (FlexibleCollectionsIsEnabled)
{
// New flexible collections logic
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids); var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids);
var result = await _authorizationService.AuthorizeAsync(User, collections, CollectionOperations.Delete); var result = await _authorizationService.AuthorizeAsync(User, collections, CollectionOperations.Delete);
if (!result.Succeeded) if (!result.Succeeded)
@ -242,6 +261,25 @@ public class CollectionsController : Controller
} }
await _deleteCollectionCommand.DeleteManyAsync(collections); await _deleteCollectionCommand.DeleteManyAsync(collections);
return;
}
// Old pre-flexible collections logic follows
if (!await _currentContext.DeleteAssignedCollections(orgId) && !await DeleteAnyCollection(orgId))
{
throw new NotFoundException();
}
var userCollections = await _collectionService.GetOrganizationCollectionsAsync(orgId);
var filteredCollections = userCollections
.Where(c => model.Ids.Contains(c.Id) && c.OrganizationId == orgId);
if (!filteredCollections.Any())
{
throw new BadRequestException("No collections found.");
}
await _deleteCollectionCommand.DeleteManyAsync(filteredCollections);
} }
[HttpDelete("{id}/user/{orgUserId}")] [HttpDelete("{id}/user/{orgUserId}")]
@ -272,6 +310,28 @@ public class CollectionsController : Controller
return collection; return collection;
} }
private void DeprecatedPermissionsGuard()
{
if (FlexibleCollectionsIsEnabled)
{
throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF.");
}
}
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> CanCreateCollection(Guid orgId, Guid collectionId)
{
DeprecatedPermissionsGuard();
if (collectionId != default)
{
return false;
}
return await _currentContext.OrganizationManager(orgId) || (_currentContext.Organizations?.Any(o => o.Id == orgId &&
(o.Permissions?.CreateNewCollections ?? false)) ?? false);
}
private async Task<bool> CanEditCollectionAsync(Guid orgId, Guid collectionId) private async Task<bool> CanEditCollectionAsync(Guid orgId, Guid collectionId)
{ {
if (collectionId == default) if (collectionId == default)
@ -294,6 +354,41 @@ public class CollectionsController : Controller
return false; return false;
} }
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> CanDeleteCollectionAsync(Guid orgId, Guid collectionId)
{
DeprecatedPermissionsGuard();
if (collectionId == default)
{
return false;
}
if (await DeleteAnyCollection(orgId))
{
return true;
}
if (await _currentContext.DeleteAssignedCollections(orgId))
{
var collectionDetails =
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value);
return collectionDetails != null;
}
return false;
}
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> DeleteAnyCollection(Guid orgId)
{
DeprecatedPermissionsGuard();
return await _currentContext.OrganizationAdmin(orgId) ||
(_currentContext.Organizations?.Any(o => o.Id == orgId
&& (o.Permissions?.DeleteAnyCollection ?? false)) ?? false);
}
private async Task<bool> CanViewCollectionAsync(Guid orgId, Guid collectionId) private async Task<bool> CanViewCollectionAsync(Guid orgId, Guid collectionId)
{ {
if (collectionId == default) if (collectionId == default)

View File

@ -765,6 +765,7 @@ public class OrganizationsController : Controller
} }
[HttpPut("{id}/collection-management")] [HttpPut("{id}/collection-management")]
[RequireFeature(FeatureFlagKeys.FlexibleCollections)]
public async Task<OrganizationResponseModel> PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) public async Task<OrganizationResponseModel> PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model)
{ {
var organization = await _organizationRepository.GetByIdAsync(id); var organization = await _organizationRepository.GetByIdAsync(id);

View File

@ -1,27 +1,42 @@
using Bit.Core.Context; using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.Vault.AuthorizationHandlers.Collections; namespace Bit.Api.Vault.AuthorizationHandlers.Collections;
/// <summary>
/// Handles authorization logic for Collection objects, including access permissions for users and groups.
/// This uses new logic implemented in the Flexible Collections initiative.
/// </summary>
public class CollectionAuthorizationHandler : BulkAuthorizationHandler<CollectionOperationRequirement, Collection> public class CollectionAuthorizationHandler : BulkAuthorizationHandler<CollectionOperationRequirement, Collection>
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ICollectionRepository _collectionRepository; private readonly ICollectionRepository _collectionRepository;
private readonly IFeatureService _featureService;
public CollectionAuthorizationHandler(ICurrentContext currentContext, ICollectionRepository collectionRepository) public CollectionAuthorizationHandler(ICurrentContext currentContext, ICollectionRepository collectionRepository,
IFeatureService featureService)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_collectionRepository = collectionRepository; _collectionRepository = collectionRepository;
_featureService = featureService;
} }
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
CollectionOperationRequirement requirement, ICollection<Collection> resources) CollectionOperationRequirement requirement, ICollection<Collection> resources)
{ {
if (!_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext))
{
// Flexible collections is OFF, should not be using this handler
throw new FeatureUnavailableException("Flexible collections is OFF when it should be ON.");
}
// Establish pattern of authorization handler null checking passed resources // Establish pattern of authorization handler null checking passed resources
if (resources == null || !resources.Any()) if (resources == null || !resources.Any())
{ {

View File

@ -40,6 +40,8 @@ public static class FeatureFlagKeys
public const string TrustedDeviceEncryption = "trusted-device-encryption"; public const string TrustedDeviceEncryption = "trusted-device-encryption";
public const string AutofillV2 = "autofill-v2"; public const string AutofillV2 = "autofill-v2";
public const string BrowserFilelessImport = "browser-fileless-import"; public const string BrowserFilelessImport = "browser-fileless-import";
public const string FlexibleCollections = "flexible-collections";
public const string BulkCollectionAccess = "bulk-collection-access";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -19,6 +19,7 @@ public class CollectionService : ICollectionService
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly IReferenceEventService _referenceEventService; private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public CollectionService( public CollectionService(
IEventService eventService, IEventService eventService,
@ -28,7 +29,8 @@ public class CollectionService : ICollectionService
IUserRepository userRepository, IUserRepository userRepository,
IMailService mailService, IMailService mailService,
IReferenceEventService referenceEventService, IReferenceEventService referenceEventService,
ICurrentContext currentContext) ICurrentContext currentContext,
IFeatureService featureService)
{ {
_eventService = eventService; _eventService = eventService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
@ -38,6 +40,7 @@ public class CollectionService : ICollectionService
_mailService = mailService; _mailService = mailService;
_referenceEventService = referenceEventService; _referenceEventService = referenceEventService;
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService;
} }
public async Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null, public async Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null,
@ -51,6 +54,10 @@ public class CollectionService : ICollectionService
var groupsList = groups?.ToList(); var groupsList = groups?.ToList();
var usersList = users?.ToList(); var usersList = users?.ToList();
// If using Flexible Collections - a collection should always have someone with Can Manage permissions
if (_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext))
{
var groupHasManageAccess = groupsList?.Any(g => g.Manage) ?? false; var groupHasManageAccess = groupsList?.Any(g => g.Manage) ?? false;
var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false; var userHasManageAccess = usersList?.Any(u => u.Manage) ?? false;
if (!groupHasManageAccess && !userHasManageAccess) if (!groupHasManageAccess && !userHasManageAccess)
@ -58,6 +65,7 @@ public class CollectionService : ICollectionService
throw new BadRequestException( throw new BadRequestException(
"At least one member or group must have can manage permission."); "At least one member or group must have can manage permission.");
} }
}
if (collection.Id == default(Guid)) if (collection.Id == default(Guid))
{ {

View File

@ -2,6 +2,7 @@
using Bit.Api.Controllers; using Bit.Api.Controllers;
using Bit.Api.Models.Request; using Bit.Api.Models.Request;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -9,6 +10,7 @@ using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Test.AutoFixture;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -19,6 +21,7 @@ namespace Bit.Api.Test.Controllers;
[ControllerCustomize(typeof(CollectionsController))] [ControllerCustomize(typeof(CollectionsController))]
[SutProviderCustomize] [SutProviderCustomize]
[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)]
public class CollectionsControllerTests public class CollectionsControllerTests
{ {
[Theory, BitAutoData] [Theory, BitAutoData]
@ -172,7 +175,7 @@ public class CollectionsControllerTests
.Returns(AuthorizationResult.Success()); .Returns(AuthorizationResult.Success());
// Act // Act
await sutProvider.Sut.DeleteMany(model); await sutProvider.Sut.DeleteMany(orgId, model);
// Assert // Assert
await sutProvider.GetDependency<IDeleteCollectionCommand>() await sutProvider.GetDependency<IDeleteCollectionCommand>()
@ -215,7 +218,7 @@ public class CollectionsControllerTests
// Assert // Assert
await Assert.ThrowsAsync<NotFoundException>(() => await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.DeleteMany(model)); sutProvider.Sut.DeleteMany(orgId, model));
await sutProvider.GetDependency<IDeleteCollectionCommand>() await sutProvider.GetDependency<IDeleteCollectionCommand>()
.DidNotReceiveWithAnyArgs() .DidNotReceiveWithAnyArgs()

View File

@ -0,0 +1,254 @@
using Bit.Api.Controllers;
using Bit.Api.Models.Request;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Controllers;
/// <summary>
/// CollectionsController tests that use pre-Flexible Collections logic. To be removed when the feature flag is removed.
/// Note the feature flag defaults to OFF so it is not explicitly set in these tests.
/// </summary>
[ControllerCustomize(typeof(CollectionsController))]
[SutProviderCustomize]
public class LegacyCollectionsControllerTests
{
[Theory, BitAutoData]
public async Task Post_Success(Guid orgId, SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>()
.OrganizationManager(orgId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.EditAnyCollection(orgId)
.Returns(false);
var collectionRequest = new CollectionRequestModel
{
Name = "encrypted_string",
ExternalId = "my_external_id"
};
_ = await sutProvider.Sut.Post(orgId, collectionRequest);
await sutProvider.GetDependency<ICollectionService>()
.Received(1)
.SaveAsync(Arg.Any<Collection>(), Arg.Any<IEnumerable<CollectionAccessSelection>>(), null);
}
[Theory, BitAutoData]
public async Task Put_Success(Guid orgId, Guid collectionId, Guid userId, CollectionRequestModel collectionRequest,
SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>()
.ViewAssignedCollections(orgId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.EditAssignedCollections(orgId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(userId);
sutProvider.GetDependency<ICollectionRepository>()
.GetByIdAsync(collectionId, userId)
.Returns(new CollectionDetails
{
OrganizationId = orgId,
});
_ = await sutProvider.Sut.Put(orgId, collectionId, collectionRequest);
}
[Theory, BitAutoData]
public async Task Put_CanNotEditAssignedCollection_ThrowsNotFound(Guid orgId, Guid collectionId, Guid userId, CollectionRequestModel collectionRequest,
SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>()
.EditAssignedCollections(orgId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(userId);
sutProvider.GetDependency<ICollectionRepository>()
.GetByIdAsync(collectionId, userId)
.Returns(Task.FromResult<CollectionDetails>(null));
_ = await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.Put(orgId, collectionId, collectionRequest));
}
[Theory, BitAutoData]
public async Task GetOrganizationCollectionsWithGroups_NoManagerPermissions_ThrowsNotFound(Organization organization, SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ViewAssignedCollections(organization.Id).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetManyWithDetails(organization.Id));
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().GetManyByOrganizationIdWithAccessAsync(default);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().GetManyByUserIdWithAccessAsync(default, default);
}
[Theory, BitAutoData]
public async Task GetOrganizationCollectionsWithGroups_AdminPermissions_GetsAllCollections(Organization organization, User user, SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(user.Id);
sutProvider.GetDependency<ICurrentContext>().ViewAllCollections(organization.Id).Returns(true);
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(organization.Id).Returns(true);
await sutProvider.Sut.GetManyWithDetails(organization.Id);
await sutProvider.GetDependency<ICollectionRepository>().Received().GetManyByOrganizationIdWithAccessAsync(organization.Id);
await sutProvider.GetDependency<ICollectionRepository>().Received().GetManyByUserIdWithAccessAsync(user.Id, organization.Id);
}
[Theory, BitAutoData]
public async Task GetOrganizationCollectionsWithGroups_MissingViewAllPermissions_GetsAssignedCollections(Organization organization, User user, SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(user.Id);
sutProvider.GetDependency<ICurrentContext>().ViewAssignedCollections(organization.Id).Returns(true);
sutProvider.GetDependency<ICurrentContext>().OrganizationManager(organization.Id).Returns(true);
await sutProvider.Sut.GetManyWithDetails(organization.Id);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().GetManyByOrganizationIdWithAccessAsync(default);
await sutProvider.GetDependency<ICollectionRepository>().Received().GetManyByUserIdWithAccessAsync(user.Id, organization.Id);
}
[Theory, BitAutoData]
public async Task GetOrganizationCollectionsWithGroups_CustomUserWithManagerPermissions_GetsAssignedCollections(Organization organization, User user, SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(user.Id);
sutProvider.GetDependency<ICurrentContext>().ViewAssignedCollections(organization.Id).Returns(true);
sutProvider.GetDependency<ICurrentContext>().EditAssignedCollections(organization.Id).Returns(true);
await sutProvider.Sut.GetManyWithDetails(organization.Id);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().GetManyByOrganizationIdWithAccessAsync(default);
await sutProvider.GetDependency<ICollectionRepository>().Received().GetManyByUserIdWithAccessAsync(user.Id, organization.Id);
}
[Theory, BitAutoData]
public async Task DeleteMany_Success(Guid orgId, User user, Collection collection1, Collection collection2, SutProvider<CollectionsController> sutProvider)
{
// Arrange
var model = new CollectionBulkDeleteRequestModel
{
Ids = new[] { collection1.Id, collection2.Id },
};
var collections = new List<Collection>
{
new CollectionDetails
{
Id = collection1.Id,
OrganizationId = orgId,
},
new CollectionDetails
{
Id = collection2.Id,
OrganizationId = orgId,
},
};
sutProvider.GetDependency<ICurrentContext>()
.DeleteAssignedCollections(orgId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(user.Id);
sutProvider.GetDependency<ICollectionService>()
.GetOrganizationCollectionsAsync(orgId)
.Returns(collections);
// Act
await sutProvider.Sut.DeleteMany(orgId, model);
// Assert
await sutProvider.GetDependency<IDeleteCollectionCommand>()
.Received(1)
.DeleteManyAsync(Arg.Is<IEnumerable<Collection>>(coll => coll.Select(c => c.Id).SequenceEqual(collections.Select(c => c.Id))));
}
[Theory, BitAutoData]
public async Task DeleteMany_CanNotDeleteAssignedCollection_ThrowsNotFound(Guid orgId, Collection collection1, Collection collection2, SutProvider<CollectionsController> sutProvider)
{
// Arrange
var model = new CollectionBulkDeleteRequestModel
{
Ids = new[] { collection1.Id, collection2.Id },
};
sutProvider.GetDependency<ICurrentContext>()
.DeleteAssignedCollections(orgId)
.Returns(false);
// Assert
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.DeleteMany(orgId, model));
await sutProvider.GetDependency<IDeleteCollectionCommand>()
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync((IEnumerable<Collection>)default);
}
[Theory, BitAutoData]
public async Task DeleteMany_UserCanNotAccessCollections_FiltersOutInvalid(Guid orgId, User user, Collection collection1, Collection collection2, SutProvider<CollectionsController> sutProvider)
{
// Arrange
var model = new CollectionBulkDeleteRequestModel
{
Ids = new[] { collection1.Id, collection2.Id },
};
var collections = new List<Collection>
{
new CollectionDetails
{
Id = collection2.Id,
OrganizationId = orgId,
},
};
sutProvider.GetDependency<ICurrentContext>()
.DeleteAssignedCollections(orgId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(user.Id);
sutProvider.GetDependency<ICollectionService>()
.GetOrganizationCollectionsAsync(orgId)
.Returns(collections);
// Act
await sutProvider.Sut.DeleteMany(orgId, model);
// Assert
await sutProvider.GetDependency<IDeleteCollectionCommand>()
.Received(1)
.DeleteManyAsync(Arg.Is<IEnumerable<Collection>>(coll => coll.Select(c => c.Id).SequenceEqual(collections.Select(c => c.Id))));
}
}

View File

@ -1,11 +1,13 @@
using System.Security.Claims; using System.Security.Claims;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Test.AutoFixture;
using Bit.Core.Test.Vault.AutoFixture; using Bit.Core.Test.Vault.AutoFixture;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
@ -16,6 +18,7 @@ using Xunit;
namespace Bit.Api.Test.Vault.AuthorizationHandlers; namespace Bit.Api.Test.Vault.AuthorizationHandlers;
[SutProviderCustomize] [SutProviderCustomize]
[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)]
public class CollectionAuthorizationHandlerTests public class CollectionAuthorizationHandlerTests
{ {
[Theory, CollectionCustomization] [Theory, CollectionCustomization]

View File

@ -0,0 +1,75 @@
using System.Reflection;
using AutoFixture;
using AutoFixture.Kernel;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
namespace Bit.Core.Test.AutoFixture;
internal class FeatureServiceBuilder : ISpecimenBuilder
{
private readonly string _enabledFeatureFlag;
public FeatureServiceBuilder(string enabledFeatureFlag)
{
_enabledFeatureFlag = enabledFeatureFlag;
}
public object Create(object request, ISpecimenContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (request is not ParameterInfo pi)
{
return new NoSpecimen();
}
if (pi.ParameterType == typeof(IFeatureService))
{
var fixture = new Fixture();
var featureService = fixture.WithAutoNSubstitutions().Create<IFeatureService>();
featureService
.IsEnabled(_enabledFeatureFlag, Arg.Any<ICurrentContext>(), Arg.Any<bool>())
.Returns(true);
return featureService;
}
return new NoSpecimen();
}
}
internal class FeatureServiceCustomization : ICustomization
{
private readonly string _enabledFeatureFlag;
public FeatureServiceCustomization(string enabledFeatureFlag)
{
_enabledFeatureFlag = enabledFeatureFlag;
}
public void Customize(IFixture fixture)
{
fixture.Customizations.Add(new FeatureServiceBuilder(_enabledFeatureFlag));
}
}
/// <summary>
/// Arranges the IFeatureService mock to enable the specified feature flag
/// </summary>
public class FeatureServiceCustomizeAttribute : BitCustomizeAttribute
{
private readonly string _enabledFeatureFlag;
public FeatureServiceCustomizeAttribute(string enabledFeatureFlag)
{
_enabledFeatureFlag = enabledFeatureFlag;
}
public override ICustomization GetCustomization() => new FeatureServiceCustomization(_enabledFeatureFlag);
}

View File

@ -112,6 +112,9 @@ public class CollectionServiceTest
{ {
collection.Id = default; collection.Id = default;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.FlexibleCollections, Arg.Any<ICurrentContext>(), Arg.Any<bool>())
.Returns(true);
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveAsync(collection, null, users)); var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveAsync(collection, null, users));
Assert.Contains("At least one member or group must have can manage permission.", ex.Message); Assert.Contains("At least one member or group must have can manage permission.", ex.Message);