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

[AC-1373] Flexible Collections (#3245)

* [AC-1117] Add manage permission (#3126)

* Update sql files to add Manage permission

* Add migration script

* Rename collection manage migration file to remove duplicate migration date

* Migrations

* Add manage to models

* Add manage to repository

* Add constraint to Manage columns

* Migration lint fixes

* Add manage to OrganizationUserUserDetails_ReadWithCollectionsById

* Add missing manage fields

* Add 'Manage' to UserCollectionDetails

* Use CREATE OR ALTER where possible

* [AC-1374] Limit collection creation/deletion to Owner/Admin (#3145)

* feat: update org table with new column, write migration, refs AC-1374

* feat: update views with new column, refs AC-1374

* feat: Alter sprocs (org create/update) to include new column, refs AC-1374

* feat: update entity/data/request/response models to handle new column, refs AC-1374

* feat: update necessary Provider related views during migration, refs AC-1374

* fix: update org create to default new column to false, refs AC-1374

* feat: added new API/request model for collection management and removed property from update request model, refs AC-1374

* fix: renamed migration script to be after secrets manage beta column changes, refs AC-1374

* fix: dotnet format, refs AC-1374

* feat: add ef migrations to reflect mssql changes, refs AC-1374

* fix: dotnet format, refs AC-1374

* feat: update API signature to accept Guid and explain Cd verbiage, refs AC-1374

* fix: merge conflict resolution

* [AC-1174] CollectionUser and CollectionGroup authorization handlers (#3194)

* [AC-1174] Introduce BulkAuthorizationHandler.cs

* [AC-1174] Introduce CollectionUserAuthorizationHandler

* [AC-1174] Add CreateForNewCollection CollectionUser requirement

* [AC-1174] Add some more details to CollectionCustomization

* [AC-1174] Formatting

* [AC-1174] Add CollectionGroupOperation.cs

* [AC-1174] Introduce CollectionGroupAuthorizationHandler.cs

* [AC-1174] Cleanup CollectionFixture customization

Implement and use re-usable extension method to support seeded Guids

* [AC-1174] Introduce WithValueFromList AutoFixtureExtensions

Modify CollectionCustomization to use multiple organization Ids for auto generated test data

* [AC-1174] Simplify CollectionUserAuthorizationHandler.cs

Modify the authorization handler to only perform authorization logic. Validation logic will need to be handled by any calling commands/controllers instead.

* [AC-1174] Introduce shared CollectionAccessAuthorizationHandlerBase

A shared base authorization handler was created for both CollectionUser and CollectionGroup resources, as they share the same underlying management authorization logic.

* [AC-1174] Update CollectionUserAuthorizationHandler and CollectionGroupAuthorizationHandler to use the new CollectionAccessAuthorizationHandlerBase class

* [AC-1174] Formatting

* [AC-1174] Cleanup typo and redundant ToList() call

* [AC-1174] Add check for provider users

* [AC-1174] Reduce nested loops

* [AC-1174] Introduce ICollectionAccess.cs

* [AC-1174] Remove individual CollectionGroup and CollectionUser auth handlers and use base class instead

* [AC-1174] Tweak unit test to fail minimally

* [AC-1174] Reorganize authorization handlers in Core project

* [AC-1174] Introduce new AddCoreAuthorizationHandlers() extension method

* [AC-1174] Move CollectionAccessAuthorizationHandler into Api project

* [AC-1174] Move CollectionFixture to Vault folder

* [AC-1174] Rename operation to CreateUpdateDelete

* [AC-1174] Require single organization for collection access authorization handler

- Add requirement that all target collections must belong to the same organization
- Simplify logic related to multiple organizations
- Update tests and helpers
- Use ToHashSet to improve lookup time

* [AC-1174] Fix null reference exception

* [AC-1174] Throw bad request exception when collections belong to different organizations

* [AC-1174] Switch to CollectionAuthorizationHandler instead of CollectionAccessAuthorizationHandler to reduce complexity

* Fix improper merge conflict resolution

* fix: add permission check for collection management api, refs AC-1647 (#3252)

* [AC-1125] Enforce org setting for creating/deleting collections (#3241)

* [AC-1117] Add manage permission (#3126)

* Update sql files to add Manage permission

* Add migration script

* Rename collection manage migration file to remove duplicate migration date

* Migrations

* Add manage to models

* Add manage to repository

* Add constraint to Manage columns

* Migration lint fixes

* Add manage to OrganizationUserUserDetails_ReadWithCollectionsById

* Add missing manage fields

* Add 'Manage' to UserCollectionDetails

* Use CREATE OR ALTER where possible

* [AC-1374] Limit collection creation/deletion to Owner/Admin (#3145)

* feat: update org table with new column, write migration, refs AC-1374

* feat: update views with new column, refs AC-1374

* feat: Alter sprocs (org create/update) to include new column, refs AC-1374

* feat: update entity/data/request/response models to handle new column, refs AC-1374

* feat: update necessary Provider related views during migration, refs AC-1374

* fix: update org create to default new column to false, refs AC-1374

* feat: added new API/request model for collection management and removed property from update request model, refs AC-1374

* fix: renamed migration script to be after secrets manage beta column changes, refs AC-1374

* fix: dotnet format, refs AC-1374

* feat: add ef migrations to reflect mssql changes, refs AC-1374

* fix: dotnet format, refs AC-1374

* feat: update API signature to accept Guid and explain Cd verbiage, refs AC-1374

* feat: created collection auth handler/operations, added LimitCollectionCdOwnerAdmin to CurrentContentOrganization, refs AC-1125

* feat: create vault service collection extensions and register with base services, refs AC-1125

* feat: deprecated CurrentContext.CreateNewCollections, refs AC-1125

* feat: deprecate DeleteAnyCollection for single resource usages, refs AC-1125

* feat: move service registration to api, update references, refs AC-1125

* feat: add bulk delete authorization handler, refs AC-1125

* feat: always assign user and give manage access on create, refs AC-1125

* fix: updated CurrentContextOrganization type, refs AC-1125

* feat: combined existing collection authorization handlers/operations, refs AC-1125

* fix: OrganizationServiceTests -> CurrentContentOrganization typo, refs AC-1125

* fix: format, refs AC-1125

* fix: update collection controller tests, refs AC-1125

* fix: dotnet format, refs AC-1125

* feat: removed extra BulkAuthorizationHandler, refs AC-1125

* fix: dotnet format, refs AC-1125

* fix: change string to guid for org id, update bulk delete request model, refs AC-1125

* fix: remove delete many collection check, refs AC-1125

* fix: clean up collection auth handler, refs AC-1125

* fix: format fix for CollectionOperations, refs AC-1125

* fix: removed unnecessary owner check, add org null check to custom permission validation, refs AC-1125

* fix: remove unused methods in CurrentContext, refs AC-1125

* fix: removed obsolete test, fixed failling delete many test, refs AC-1125

* fix: CollectionAuthorizationHandlerTests fixes, refs AC-1125

* fix: OrganizationServiceTests fix broken test by mocking GetOrganization, refs AC-1125

* fix: CollectionAuthorizationHandler - remove unused repository, refs AC-1125

* feat: moved UserId null check to common method, refs AC-1125

* fix: updated auth handler tests to remove dependency on requirement for common code checks, refs AC-1125

* feat: updated conditionals/comments for create/delete methods within colleciton auth handler, refs AC-1125

* feat: added create/delete collection auth handler success methods, refs AC-1125

* fix: new up permissions to prevent excessive null checks, refs AC-1125

* fix: remove old reference to CreateNewCollections, refs AC-1125

* fix: typo within ViewAssignedCollections method, refs AC-1125

---------

Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com>

* refactor: remove organizationId from CollectionBulkDeleteRequestModel, refs AC-1649 (#3282)

* [AC-1174] Bulk Collection Management (#3229)

* [AC-1174] Update SelectionReadOnlyRequestModel to use Guid for Id property

* [AC-1174] Introduce initial bulk-access collection endpoint

* [AC-1174] Introduce BulkAddCollectionAccessCommand and validation logic/tests

* [AC-1174] Add CreateOrUpdateAccessMany method to CollectionRepository

* [AC-1174] Add event logs for bulk add collection access command

* [AC-1174] Add User_BumpAccountRevisionDateByCollectionIds and database migration script

* [AC-1174] Implement EF repository method

* [AC-1174] Improve null checks

* [AC-1174] Remove unnecessary BulkCollectionAccessRequestModel helpers

* [AC-1174] Add unit tests for new controller endpoint

* [AC-1174] Fix formatting

* [AC-1174] Remove comment

* [AC-1174] Remove redundant organizationId parameter

* [AC-1174] Ensure user and group Ids are distinct

* [AC-1174] Cleanup tests based on PR feedback

* [AC-1174] Formatting

* [AC-1174] Update CollectionGroup alias in the sproc

* [AC-1174] Add some additional comments to SQL sproc

* [AC-1174] Add comment explaining additional SaveChangesAsync call

---------

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>

* [AC-1646] Rename LimitCollectionCdOwnerAdmin column (#3300)

* Rename LimitCollectionCdOwnerAdmin -> LimitCollectionCreationDeletion

* Rename and bump migration script

* [AC-1666] Removed EditAnyCollection from Create/Delete permission checks (#3301)

* fix: remove EditAnyCollection from Create/Delete permission check, refs AC-1666

* fix: updated comment, refs AC-1666

* [AC-1669] Bug - Remove obsolete assignUserId from CollectionService.SaveAsync(...) (#3312)

* fix: remove AssignUserId from CollectionService.SaveAsync, refs AC-1669

* fix: add manage access conditional before creating collection, refs AC-1669

* fix: move access logic for create/update, fix all tests, refs AC-1669

* fix: add CollectionAccessSelection fixture, update tests, update bad reqeuest message, refs AC-1669

* fix: format, refs AC-1669

* fix: update null params with specific arg.is null checks, refs Ac-1669

* fix: update attribute class name, refs AC-1669

* [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>

* Add joint codeownership for auth handlers (#3346)

* [AC-1717] Update default values for LimitCollectionCreationDeletion (#3365)

* Change default value in organization create sproc to 1

* Drop old column name still present in some QA instances

* Set LimitCollectionCreationDeletion value in code based on feature flag

* Fix: add missing namespace after merging in master

* Fix: add missing namespace after merging in master

* [AC-1683] Fix DB migrations for new Manage permission (#3307)

* [AC-1683] Update migration script and introduce V2 procedures and types

* [AC-1683] Update repository calls to use new V2 procedures / types

* [AC-1684] Update bulk add collection migration script to use new V2 type

* [AC-1683] Undo Manage changes to more original procedures

* [AC-1683] Restore whitespace changes

* [AC-1683] Clarify comments regarding explicit column lists

* [AC-1683] Update migration script dates

* [AC-1683] Split the migration script for readability

* [AC-1683] Re-name SelectReadOnlyArray_V2 to CollectionAccessSelectionType

* [AC-1648] [Flexible Collections] Bump migration scripts before feature branch merge (#3371)

* Bump dates on sql migration scripts

* Bump date on ef migrations

---------

Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com>
Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com>
Co-authored-by: Vincent Salucci <vincesalucci21@gmail.com>
Co-authored-by: Shane Melton <smelton@bitwarden.com>
Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com>
This commit is contained in:
Thomas Rittson 2023-11-01 19:30:52 +10:00 committed by GitHub
parent 419760623a
commit da4a86c643
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 18289 additions and 256 deletions

5
.github/CODEOWNERS vendored
View File

@ -21,13 +21,16 @@ src/Identity @bitwarden/team-auth-dev
**/SecretsManager @bitwarden/team-secrets-manager-dev
**/Tools @bitwarden/team-tools-dev
## Vault Team files
**/Vault @bitwarden/team-vault-dev
**/Vault/AuthorizationHandlers @bitwarden/team-vault-dev @bitwarden/team-admin-console-dev # joint ownership over authorization handlers that affect organization users
# Admin-Console Team
**/AdminConsole @bitwarden/team-admin-console-dev
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
**/AdminConsole @bitwarden/team-admin-console-dev
# Billing Team
**/*billing* @bitwarden/team-billing-dev

View File

@ -767,4 +767,23 @@ public class OrganizationsController : Controller
return new OrganizationSsoResponseModel(organization, _globalSettings, ssoConfig);
}
[HttpPut("{id}/collection-management")]
[RequireFeature(FeatureFlagKeys.FlexibleCollections)]
public async Task<OrganizationResponseModel> PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await _currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
await _organizationService.UpdateAsync(model.ToOrganization(organization));
return new OrganizationResponseModel(organization);
}
}

View File

@ -54,6 +54,7 @@ public class OrganizationResponseModel : ResponseModel
SmServiceAccounts = organization.SmServiceAccounts;
MaxAutoscaleSmSeats = organization.MaxAutoscaleSmSeats;
MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts;
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
}
public Guid Id { get; set; }
@ -93,6 +94,7 @@ public class OrganizationResponseModel : ResponseModel
public int? SmServiceAccounts { get; set; }
public int? MaxAutoscaleSmSeats { get; set; }
public int? MaxAutoscaleSmServiceAccounts { get; set; }
public bool LimitCollectionCreationDeletion { get; set; }
}
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel

View File

@ -60,6 +60,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil;
AccessSecretsManager = organization.AccessSecretsManager;
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
if (organization.SsoConfig != null)
{
@ -113,4 +114,5 @@ public class ProfileOrganizationResponseModel : ResponseModel
public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; }
public bool AccessSecretsManager { get; set; }
public bool LimitCollectionCreationDeletion { get; set; }
}

View File

@ -1,11 +1,14 @@
using Bit.Api.Models.Request;
using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -19,22 +22,33 @@ public class CollectionsController : Controller
private readonly ICollectionService _collectionService;
private readonly IDeleteCollectionCommand _deleteCollectionCommand;
private readonly IUserService _userService;
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentContext _currentContext;
private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand;
private readonly IFeatureService _featureService;
public CollectionsController(
ICollectionRepository collectionRepository,
ICollectionService collectionService,
IDeleteCollectionCommand deleteCollectionCommand,
IUserService userService,
ICurrentContext currentContext)
IAuthorizationService authorizationService,
ICurrentContext currentContext,
IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand,
IFeatureService featureService)
{
_collectionRepository = collectionRepository;
_collectionService = collectionService;
_deleteCollectionCommand = deleteCollectionCommand;
_userService = userService;
_authorizationService = authorizationService;
_currentContext = currentContext;
_bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand;
_featureService = featureService;
}
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
[HttpGet("{id}")]
public async Task<CollectionResponseModel> Get(Guid orgId, Guid id)
{
@ -62,6 +76,7 @@ public class CollectionsController : Controller
{
throw new NotFoundException();
}
return new CollectionAccessDetailsResponseModel(collection, access.Groups, access.Users);
}
else
@ -72,6 +87,7 @@ public class CollectionsController : Controller
{
throw new NotFoundException();
}
return new CollectionAccessDetailsResponseModel(collection, access.Groups, access.Users);
}
}
@ -79,13 +95,15 @@ public class CollectionsController : Controller
[HttpGet("details")]
public async Task<ListResponseModel<CollectionAccessDetailsResponseModel>> GetManyWithDetails(Guid orgId)
{
if (!await ViewAtLeastOneCollectionAsync(orgId) && !await _currentContext.ManageUsers(orgId) && !await _currentContext.ManageGroups(orgId))
if (!await ViewAtLeastOneCollectionAsync(orgId) && !await _currentContext.ManageUsers(orgId) &&
!await _currentContext.ManageGroups(orgId))
{
throw new NotFoundException();
}
// We always need to know which collections the current user is assigned to
var assignedOrgCollections = await _collectionRepository.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId);
var assignedOrgCollections =
await _collectionRepository.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId);
if (await _currentContext.ViewAllCollections(orgId) || await _currentContext.ManageUsers(orgId))
{
@ -141,8 +159,10 @@ public class CollectionsController : Controller
{
var collection = model.ToCollection(orgId);
if (!await CanCreateCollection(orgId, collection.Id) &&
!await CanEditCollectionAsync(orgId, collection.Id))
var authorized = FlexibleCollectionsIsEnabled
? (await _authorizationService.AuthorizeAsync(User, collection, CollectionOperations.Create)).Succeeded
: await CanCreateCollection(orgId, collection.Id) || await CanEditCollectionAsync(orgId, collection.Id);
if (!authorized)
{
throw new NotFoundException();
}
@ -150,10 +170,7 @@ public class CollectionsController : Controller
var groups = model.Groups?.Select(g => g.ToSelectionReadOnly());
var users = model.Users?.Select(g => g.ToSelectionReadOnly());
var assignUserToCollection = !(await _currentContext.EditAnyCollection(orgId)) &&
await _currentContext.EditAssignedCollections(orgId);
await _collectionService.SaveAsync(collection, groups, users, assignUserToCollection ? _currentContext.UserId : null);
await _collectionService.SaveAsync(collection, groups, users);
return new CollectionResponseModel(collection);
}
@ -185,32 +202,77 @@ public class CollectionsController : Controller
await _collectionRepository.UpdateUsersAsync(collection.Id, model?.Select(g => g.ToSelectionReadOnly()));
}
[HttpDelete("{id}")]
[HttpPost("{id}/delete")]
public async Task Delete(Guid orgId, Guid id)
[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)
{
if (!await CanDeleteCollectionAsync(orgId, id))
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds);
if (collections.Count != model.CollectionIds.Count())
{
throw new NotFoundException("One or more collections not found.");
}
var result = await _authorizationService.AuthorizeAsync(User, collections, CollectionOperations.ModifyAccess);
if (!result.Succeeded)
{
throw new NotFoundException();
}
await _bulkAddCollectionAccessCommand.AddAccessAsync(
collections,
model.Users?.Select(u => u.ToSelectionReadOnly()).ToList(),
model.Groups?.Select(g => g.ToSelectionReadOnly()).ToList());
}
[HttpDelete("{id}")]
[HttpPost("{id}/delete")]
public async Task Delete(Guid orgId, Guid id)
{
var collection = await GetCollectionAsync(id, orgId);
var authorized = FlexibleCollectionsIsEnabled
? (await _authorizationService.AuthorizeAsync(User, collection, CollectionOperations.Delete)).Succeeded
: await CanDeleteCollectionAsync(orgId, id);
if (!authorized)
{
throw new NotFoundException();
}
await _deleteCollectionCommand.DeleteAsync(collection);
}
[HttpDelete("")]
[HttpPost("delete")]
public async Task DeleteMany([FromBody] CollectionBulkDeleteRequestModel model)
public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model)
{
var orgId = new Guid(model.OrganizationId);
var collectionIds = model.Ids.Select(i => new Guid(i));
if (!await _currentContext.DeleteAssignedCollections(orgId) && !await _currentContext.DeleteAnyCollection(orgId))
if (FlexibleCollectionsIsEnabled)
{
// New flexible collections logic
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids);
var result = await _authorizationService.AuthorizeAsync(User, collections, CollectionOperations.Delete);
if (!result.Succeeded)
{
throw new NotFoundException();
}
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 => collectionIds.Contains(c.Id) && c.OrganizationId == orgId);
var filteredCollections = userCollections
.Where(c => model.Ids.Contains(c.Id) && c.OrganizationId == orgId);
if (!filteredCollections.Any())
{
@ -248,15 +310,26 @@ public class CollectionsController : Controller
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.CreateNewCollections(orgId);
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)
@ -273,34 +346,49 @@ public class CollectionsController : Controller
if (await _currentContext.EditAssignedCollections(orgId))
{
var collectionDetails = await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value);
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> CanDeleteCollectionAsync(Guid orgId, Guid collectionId)
{
DeprecatedPermissionsGuard();
if (collectionId == default)
{
return false;
}
if (await _currentContext.DeleteAnyCollection(orgId))
if (await DeleteAnyCollection(orgId))
{
return true;
}
if (await _currentContext.DeleteAssignedCollections(orgId))
{
var collectionDetails = await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value);
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)
{
if (collectionId == default)
@ -315,7 +403,8 @@ public class CollectionsController : Controller
if (await _currentContext.ViewAssignedCollections(orgId))
{
var collectionDetails = await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value);
var collectionDetails =
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value);
return collectionDetails != null;
}

View File

@ -0,0 +1,8 @@
namespace Bit.Api.Models.Request;
public class BulkCollectionAccessRequestModel
{
public IEnumerable<Guid> CollectionIds { get; set; }
public IEnumerable<SelectionReadOnlyRequestModel> Groups { get; set; }
public IEnumerable<SelectionReadOnlyRequestModel> Users { get; set; }
}

View File

@ -34,8 +34,7 @@ public class CollectionRequestModel
public class CollectionBulkDeleteRequestModel
{
[Required]
public IEnumerable<string> Ids { get; set; }
public string OrganizationId { get; set; }
public IEnumerable<Guid> Ids { get; set; }
}
public class CollectionWithIdRequestModel : CollectionRequestModel

View File

@ -0,0 +1,14 @@
using Bit.Core.Entities;
namespace Bit.Api.Models.Request.Organizations;
public class OrganizationCollectionManagementUpdateRequestModel
{
public bool LimitCreateDeleteOwnerAdmin { get; set; }
public virtual Organization ToOrganization(Organization existingOrganization)
{
existingOrganization.LimitCollectionCreationDeletion = LimitCreateDeleteOwnerAdmin;
return existingOrganization;
}
}

View File

@ -6,17 +6,19 @@ namespace Bit.Api.Models.Request;
public class SelectionReadOnlyRequestModel
{
[Required]
public string Id { get; set; }
public Guid Id { get; set; }
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
public CollectionAccessSelection ToSelectionReadOnly()
{
return new CollectionAccessSelection
{
Id = new Guid(Id),
Id = Id,
ReadOnly = ReadOnly,
HidePasswords = HidePasswords,
Manage = Manage,
};
}
}

View File

@ -33,10 +33,12 @@ public class CollectionDetailsResponseModel : CollectionResponseModel
{
ReadOnly = collectionDetails.ReadOnly;
HidePasswords = collectionDetails.HidePasswords;
Manage = collectionDetails.Manage;
}
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
}
public class CollectionAccessDetailsResponseModel : CollectionResponseModel

View File

@ -14,9 +14,11 @@ public class SelectionReadOnlyResponseModel
Id = selection.Id;
ReadOnly = selection.ReadOnly;
HidePasswords = selection.HidePasswords;
Manage = selection.Manage;
}
public Guid Id { get; set; }
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
}

View File

@ -137,6 +137,9 @@ public class Startup
services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices();
// Authorization Handlers
services.AddAuthorizationHandlers();
//health check
if (!globalSettings.SelfHosted)
{

View File

@ -1,7 +1,9 @@
using Bit.Core.IdentityServer;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core.IdentityServer;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Health;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
namespace Bit.Api.Utilities;
@ -115,4 +117,9 @@ public static class ServiceCollectionExtensions
}
});
}
public static void AddAuthorizationHandlers(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler, CollectionAuthorizationHandler>();
}
}

View File

@ -0,0 +1,177 @@
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
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>
{
private readonly ICurrentContext _currentContext;
private readonly ICollectionRepository _collectionRepository;
private readonly IFeatureService _featureService;
public CollectionAuthorizationHandler(ICurrentContext currentContext, ICollectionRepository collectionRepository,
IFeatureService featureService)
{
_currentContext = currentContext;
_collectionRepository = collectionRepository;
_featureService = featureService;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
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
if (resources == null || !resources.Any())
{
context.Fail();
return;
}
if (!_currentContext.UserId.HasValue)
{
context.Fail();
return;
}
var targetOrganizationId = resources.First().OrganizationId;
// Ensure all target collections belong to the same organization
if (resources.Any(tc => tc.OrganizationId != targetOrganizationId))
{
throw new BadRequestException("Requested collections must belong to the same organization.");
}
// Acting user is not a member of the target organization, fail
var org = _currentContext.GetOrganization(targetOrganizationId);
if (org == null)
{
context.Fail();
return;
}
switch (requirement)
{
case not null when requirement == CollectionOperations.Create:
await CanCreateAsync(context, requirement, org);
break;
case not null when requirement == CollectionOperations.Delete:
await CanDeleteAsync(context, requirement, resources, org);
break;
case not null when requirement == CollectionOperations.ModifyAccess:
await CanManageCollectionAccessAsync(context, requirement, resources, org);
break;
}
}
private async Task CanCreateAsync(AuthorizationHandlerContext context, CollectionOperationRequirement requirement,
CurrentContextOrganization org)
{
// If false, all organization members are allowed to create collections
if (!org.LimitCollectionCreationDeletion)
{
context.Succeed(requirement);
return;
}
// Owners, Admins, Providers, and users with CreateNewCollections permission can always create collections
if (
org.Type is OrganizationUserType.Owner or OrganizationUserType.Admin ||
org.Permissions is { CreateNewCollections: true } ||
await _currentContext.ProviderUserForOrgAsync(org.Id))
{
context.Succeed(requirement);
return;
}
context.Fail();
}
private async Task CanDeleteAsync(AuthorizationHandlerContext context, CollectionOperationRequirement requirement,
ICollection<Collection> resources, CurrentContextOrganization org)
{
// Owners, Admins, Providers, and users with DeleteAnyCollection permission can always delete collections
if (
org.Type is OrganizationUserType.Owner or OrganizationUserType.Admin ||
org.Permissions is { DeleteAnyCollection: true } ||
await _currentContext.ProviderUserForOrgAsync(org.Id))
{
context.Succeed(requirement);
return;
}
// The limit collection management setting is enabled and we are not an Admin (above condition), fail
if (org.LimitCollectionCreationDeletion)
{
context.Fail();
return;
}
// Other members types should have the Manage capability for all collections being deleted
var manageableCollectionIds =
(await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value))
.Where(c => c.Manage && c.OrganizationId == org.Id)
.Select(c => c.Id)
.ToHashSet();
// The acting user does not have permission to manage all target collections, fail
if (resources.Any(c => !manageableCollectionIds.Contains(c.Id)))
{
context.Fail();
return;
}
context.Succeed(requirement);
}
/// <summary>
/// Ensures the acting user is allowed to manage access permissions for the target collections.
/// </summary>
private async Task CanManageCollectionAccessAsync(AuthorizationHandlerContext context,
IAuthorizationRequirement requirement, ICollection<Collection> targetCollections, CurrentContextOrganization org)
{
// Owners, Admins, Providers, and users with EditAnyCollection permission can always manage collection access
if (
org.Permissions is { EditAnyCollection: true } ||
org.Type is OrganizationUserType.Owner or OrganizationUserType.Admin ||
await _currentContext.ProviderUserForOrgAsync(org.Id))
{
context.Succeed(requirement);
return;
}
// List of collection Ids the acting user is allowed to manage
var manageableCollectionIds =
(await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId!.Value))
.Where(c => c.Manage && c.OrganizationId == org.Id)
.Select(c => c.Id)
.ToHashSet();
// The acting user does not have permission to manage all target collections, fail
if (targetCollections.Any(tc => !manageableCollectionIds.Contains(tc.Id)))
{
context.Fail();
return;
}
context.Succeed(requirement);
}
}

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Api.Vault.AuthorizationHandlers.Collections;
public class CollectionOperationRequirement : OperationAuthorizationRequirement { }
public static class CollectionOperations
{
public static readonly CollectionOperationRequirement Create = new() { Name = nameof(Create) };
public static readonly CollectionOperationRequirement Delete = new() { Name = nameof(Delete) };
/// <summary>
/// The operation that represents creating, updating, or removing collection access.
/// Combined together to allow for a single requirement to be used for each operation
/// as they all currently share the same underlying authorization logic.
/// </summary>
public static readonly CollectionOperationRequirement ModifyAccess = new() { Name = nameof(ModifyAccess) };
}

View File

@ -45,6 +45,8 @@ public static class FeatureFlagKeys
public const string Fido2VaultCredentials = "fido2-vault-credentials";
public const string AutofillV2 = "autofill-v2";
public const string BrowserFilelessImport = "browser-fileless-import";
public const string FlexibleCollections = "flexible-collections";
public const string BulkCollectionAccess = "bulk-collection-access";
public const string AutofillOverlay = "autofill-overlay";
public const string ItemShare = "item-share";

View File

@ -324,27 +324,16 @@ public class CurrentContext : ICurrentContext
&& (o.Permissions?.AccessReports ?? false)) ?? false);
}
public async Task<bool> CreateNewCollections(Guid orgId)
{
return await OrganizationManager(orgId) || (Organizations?.Any(o => o.Id == orgId
&& (o.Permissions?.CreateNewCollections ?? false)) ?? false);
}
public async Task<bool> EditAnyCollection(Guid orgId)
{
return await OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId
&& (o.Permissions?.EditAnyCollection ?? false)) ?? false);
}
public async Task<bool> DeleteAnyCollection(Guid orgId)
{
return await OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId
&& (o.Permissions?.DeleteAnyCollection ?? false)) ?? false);
}
public async Task<bool> ViewAllCollections(Guid orgId)
{
return await EditAnyCollection(orgId) || await DeleteAnyCollection(orgId);
var org = GetOrganization(orgId);
return await EditAnyCollection(orgId) || (org != null && org.Permissions.DeleteAnyCollection);
}
public async Task<bool> EditAssignedCollections(Guid orgId)
@ -361,9 +350,20 @@ public class CurrentContext : ICurrentContext
public async Task<bool> ViewAssignedCollections(Guid orgId)
{
return await CreateNewCollections(orgId) // Required to display the existing collections under which the new collection can be nested
|| await EditAssignedCollections(orgId)
|| await DeleteAssignedCollections(orgId);
/*
* Required to display the existing collections under which the new collection can be nested.
* Owner, Admin, Manager, and Provider checks are handled via the EditAssigned/DeleteAssigned context calls.
* This entire method will be moved to the CollectionAuthorizationHandler in the future
*/
var canCreateNewCollections = false;
var org = GetOrganization(orgId);
if (org != null)
{
canCreateNewCollections = !org.LimitCollectionCreationDeletion || org.Permissions.CreateNewCollections;
}
return await EditAssignedCollections(orgId)
|| await DeleteAssignedCollections(orgId)
|| canCreateNewCollections;
}
public async Task<bool> ManageGroups(Guid orgId)
@ -512,6 +512,11 @@ public class CurrentContext : ICurrentContext
return Providers;
}
public CurrentContextOrganization GetOrganization(Guid orgId)
{
return Organizations?.Find(o => o.Id == orgId);
}
private string GetClaimValue(Dictionary<string, IEnumerable<Claim>> claims, string type)
{
if (!claims.ContainsKey(type))

View File

@ -15,10 +15,12 @@ public class CurrentContextOrganization
Type = orgUser.Type;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(orgUser.Permissions);
AccessSecretsManager = orgUser.AccessSecretsManager && orgUser.UseSecretsManager && orgUser.Enabled;
LimitCollectionCreationDeletion = orgUser.LimitCollectionCreationDeletion;
}
public Guid Id { get; set; }
public OrganizationUserType Type { get; set; }
public Permissions Permissions { get; set; }
public Permissions Permissions { get; set; } = new();
public bool AccessSecretsManager { get; set; }
public bool LimitCollectionCreationDeletion { get; set; }
}

View File

@ -1,4 +1,6 @@
using System.Security.Claims;
#nullable enable
using System.Security.Claims;
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
@ -41,9 +43,7 @@ public interface ICurrentContext
Task<bool> AccessEventLogs(Guid orgId);
Task<bool> AccessImportExport(Guid orgId);
Task<bool> AccessReports(Guid orgId);
Task<bool> CreateNewCollections(Guid orgId);
Task<bool> EditAnyCollection(Guid orgId);
Task<bool> DeleteAnyCollection(Guid orgId);
Task<bool> ViewAllCollections(Guid orgId);
Task<bool> EditAssignedCollections(Guid orgId);
Task<bool> DeleteAssignedCollections(Guid orgId);
@ -74,4 +74,5 @@ public interface ICurrentContext
Task<Guid?> ProviderIdForOrg(Guid orgId);
bool AccessSecretsManager(Guid organizationId);
CurrentContextOrganization? GetOrganization(Guid orgId);
}

View File

@ -6,4 +6,5 @@ public class CollectionGroup
public Guid GroupId { get; set; }
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
}

View File

@ -6,4 +6,5 @@ public class CollectionUser
public Guid OrganizationUserId { get; set; }
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
}

View File

@ -78,6 +78,10 @@ public class Organization : ITableObject<Guid>, ISubscriber, IStorable, IStorabl
public int? MaxAutoscaleSmSeats { get; set; }
public int? MaxAutoscaleSmServiceAccounts { get; set; }
public bool SecretsManagerBeta { get; set; }
/// <summary>
/// Refers to the ability for an organization to limit collection creation and deletion to owners and admins only
/// </summary>
public bool LimitCollectionCreationDeletion { get; set; }
public void SetNewId()
{

View File

@ -5,4 +5,5 @@ public class CollectionAccessSelection
public Guid Id { get; set; }
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
}

View File

@ -6,4 +6,5 @@ public class CollectionDetails : Collection
{
public bool ReadOnly { get; set; }
public bool HidePasswords { get; set; }
public bool Manage { get; set; }
}

View File

@ -48,4 +48,5 @@ public class OrganizationUserOrganizationDetails
public bool UsePasswordManager { get; set; }
public int? SmSeats { get; set; }
public int? SmServiceAccounts { get; set; }
public bool LimitCollectionCreationDeletion { get; set; }
}

View File

@ -142,6 +142,7 @@ public class SelfHostedOrganizationDetails : Organization
RevisionDate = RevisionDate,
MaxAutoscaleSeats = MaxAutoscaleSeats,
OwnersNotifiedOfAutoscaling = OwnersNotifiedOfAutoscaling,
LimitCollectionCreationDeletion = LimitCollectionCreationDeletion,
};
}
}

View File

@ -0,0 +1,96 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.OrganizationFeatures.OrganizationCollections;
public class BulkAddCollectionAccessCommand : IBulkAddCollectionAccessCommand
{
private readonly ICollectionRepository _collectionRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IGroupRepository _groupRepository;
private readonly IEventService _eventService;
public BulkAddCollectionAccessCommand(
ICollectionRepository collectionRepository,
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
IEventService eventService)
{
_collectionRepository = collectionRepository;
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
_eventService = eventService;
}
public async Task AddAccessAsync(ICollection<Collection> collections,
ICollection<CollectionAccessSelection> users,
ICollection<CollectionAccessSelection> groups)
{
await ValidateRequestAsync(collections, users, groups);
await _collectionRepository.CreateOrUpdateAccessForManyAsync(
collections.First().OrganizationId,
collections.Select(c => c.Id),
users,
groups
);
await _eventService.LogCollectionEventsAsync(collections.Select(c =>
(c, EventType.Collection_Updated, (DateTime?)DateTime.UtcNow)));
}
private async Task ValidateRequestAsync(ICollection<Collection> collections, ICollection<CollectionAccessSelection> usersAccess, ICollection<CollectionAccessSelection> groupsAccess)
{
if (collections == null || collections.Count == 0)
{
throw new BadRequestException("No collections were provided.");
}
var orgId = collections.First().OrganizationId;
if (collections.Any(c => c.OrganizationId != orgId))
{
throw new BadRequestException("All collections must belong to the same organization.");
}
var collectionUserIds = usersAccess?.Select(u => u.Id).Distinct().ToList();
if (collectionUserIds is { Count: > 0 })
{
var users = await _organizationUserRepository.GetManyAsync(collectionUserIds);
if (users.Count != collectionUserIds.Count)
{
throw new BadRequestException("One or more users do not exist.");
}
if (users.Any(u => u.OrganizationId != orgId))
{
throw new BadRequestException("One or more users do not belong to the same organization as the collection being assigned.");
}
}
var collectionGroupIds = groupsAccess?.Select(g => g.Id).Distinct().ToList();
if (collectionGroupIds is { Count: > 0 })
{
var groups = await _groupRepository.GetManyByManyIds(collectionGroupIds);
if (groups.Count != collectionGroupIds.Count)
{
throw new BadRequestException("One or more groups do not exist.");
}
if (groups.Any(g => g.OrganizationId != orgId))
{
throw new BadRequestException("One or more groups do not belong to the same organization as the collection being assigned.");
}
}
}
}

View File

@ -0,0 +1,10 @@
using Bit.Core.Entities;
using Bit.Core.Models.Data;
namespace Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
public interface IBulkAddCollectionAccessCommand
{
Task AddAccessAsync(ICollection<Collection> collections,
ICollection<CollectionAccessSelection> users, ICollection<CollectionAccessSelection> groups);
}

View File

@ -98,6 +98,7 @@ public static class OrganizationServiceCollectionExtensions
public static void AddOrganizationCollectionCommands(this IServiceCollection services)
{
services.AddScoped<IDeleteCollectionCommand, DeleteCollectionCommand>();
services.AddScoped<IBulkAddCollectionAccessCommand, BulkAddCollectionAccessCommand>();
}
private static void AddOrganizationGroupCommands(this IServiceCollection services)

View File

@ -20,4 +20,6 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
Task UpdateUsersAsync(Guid id, IEnumerable<CollectionAccessSelection> users);
Task<ICollection<CollectionAccessSelection>> GetManyUsersByIdAsync(Guid id);
Task DeleteManyAsync(IEnumerable<Guid> collectionIds);
Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups);
}

View File

@ -5,7 +5,7 @@ namespace Bit.Core.Services;
public interface ICollectionService
{
Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null, IEnumerable<CollectionAccessSelection> users = null, Guid? assignUserId = null);
Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null, IEnumerable<CollectionAccessSelection> users = null);
Task DeleteUserAsync(Collection collection, Guid organizationUserId);
Task<IEnumerable<Collection>> GetOrganizationCollectionsAsync(Guid organizationId);
}

View File

@ -19,6 +19,7 @@ public class CollectionService : ICollectionService
private readonly IMailService _mailService;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public CollectionService(
IEventService eventService,
@ -28,7 +29,8 @@ public class CollectionService : ICollectionService
IUserRepository userRepository,
IMailService mailService,
IReferenceEventService referenceEventService,
ICurrentContext currentContext)
ICurrentContext currentContext,
IFeatureService featureService)
{
_eventService = eventService;
_organizationRepository = organizationRepository;
@ -38,10 +40,11 @@ public class CollectionService : ICollectionService
_mailService = mailService;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_featureService = featureService;
}
public async Task SaveAsync(Collection collection, IEnumerable<CollectionAccessSelection> groups = null,
IEnumerable<CollectionAccessSelection> users = null, Guid? assignUserId = null)
IEnumerable<CollectionAccessSelection> users = null)
{
var org = await _organizationRepository.GetByIdAsync(collection.OrganizationId);
if (org == null)
@ -49,6 +52,21 @@ public class CollectionService : ICollectionService
throw new BadRequestException("Organization not found");
}
var groupsList = groups?.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 userHasManageAccess = usersList?.Any(u => u.Manage) ?? false;
if (!groupHasManageAccess && !userHasManageAccess)
{
throw new BadRequestException(
"At least one member or group must have can manage permission.");
}
}
if (collection.Id == default(Guid))
{
if (org.MaxCollections.HasValue)
@ -61,26 +79,13 @@ public class CollectionService : ICollectionService
}
}
await _collectionRepository.CreateAsync(collection, org.UseGroups ? groups : null, users);
// Assign a user to the newly created collection.
if (assignUserId.HasValue)
{
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, assignUserId.Value);
if (orgUser != null && orgUser.Status == Enums.OrganizationUserStatusType.Confirmed)
{
await _collectionRepository.UpdateUsersAsync(collection.Id,
new List<CollectionAccessSelection> {
new CollectionAccessSelection { Id = orgUser.Id, ReadOnly = false } });
}
}
await _collectionRepository.CreateAsync(collection, org.UseGroups ? groupsList : null, usersList);
await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Created);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.CollectionCreated, org, _currentContext));
}
else
{
await _collectionRepository.ReplaceAsync(collection, org.UseGroups ? groups : null, users);
await _collectionRepository.ReplaceAsync(collection, org.UseGroups ? groupsList : null, usersList);
await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Updated);
}
}

View File

@ -57,6 +57,7 @@ public class OrganizationService : IOrganizationService
private readonly IProviderUserRepository _providerUserRepository;
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IFeatureService _featureService;
public OrganizationService(
IOrganizationRepository organizationRepository,
@ -85,7 +86,8 @@ public class OrganizationService : IOrganizationService
IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand)
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IFeatureService featureService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -114,6 +116,7 @@ public class OrganizationService : IOrganizationService
_providerUserRepository = providerUserRepository;
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_featureService = featureService;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -425,6 +428,9 @@ public class OrganizationService : IOrganizationService
await ValidateSignUpPoliciesAsync(signup.Owner.Id);
}
var flexibleCollectionsIsEnabled =
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
var organization = new Organization
{
// Pre-generate the org id so that we can save it with the Stripe subscription..
@ -462,6 +468,7 @@ public class OrganizationService : IOrganizationService
Status = OrganizationStatusType.Created,
UsePasswordManager = true,
UseSecretsManager = signup.UseSecretsManager,
LimitCollectionCreationDeletion = !flexibleCollectionsIsEnabled
};
if (signup.UseSecretsManager)
@ -2095,7 +2102,7 @@ public class OrganizationService : IOrganizationService
private async Task<bool> ValidateCustomPermissionsGrant(Guid organizationId, Permissions permissions)
{
if (permissions == null || await _currentContext.OrganizationOwner(organizationId) || await _currentContext.OrganizationAdmin(organizationId))
if (permissions == null || await _currentContext.OrganizationAdmin(organizationId))
{
return true;
}
@ -2140,16 +2147,6 @@ public class OrganizationService : IOrganizationService
return false;
}
if (permissions.CreateNewCollections && !await _currentContext.CreateNewCollections(organizationId))
{
return false;
}
if (permissions.DeleteAnyCollection && !await _currentContext.DeleteAnyCollection(organizationId))
{
return false;
}
if (permissions.DeleteAssignedCollections && !await _currentContext.DeleteAssignedCollections(organizationId))
{
return false;
@ -2170,6 +2167,22 @@ public class OrganizationService : IOrganizationService
return false;
}
var org = _currentContext.GetOrganization(organizationId);
if (org == null)
{
return false;
}
if (permissions.CreateNewCollections && !org.Permissions.CreateNewCollections)
{
return false;
}
if (permissions.DeleteAnyCollection && !org.Permissions.DeleteAnyCollection)
{
return false;
}
return true;
}

View File

@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.Utilities;
/// <summary>
/// Allows a single authorization handler implementation to handle requirements for
/// both singular or bulk operations on single or multiple resources.
/// </summary>
/// <typeparam name="TRequirement">The type of the requirement to evaluate.</typeparam>
/// <typeparam name="TResource">The type of the resource(s) that will be evaluated.</typeparam>
public abstract class BulkAuthorizationHandler<TRequirement, TResource> : AuthorizationHandler<TRequirement>
where TRequirement : IAuthorizationRequirement
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, TRequirement requirement)
{
// Attempt to get the resource(s) from the context
var bulkResources = GetBulkResourceFromContext(context);
// No resources of the expected type were found in the context, nothing to evaluate
if (bulkResources == null)
{
return;
}
await HandleRequirementAsync(context, requirement, bulkResources);
}
private static ICollection<TResource> GetBulkResourceFromContext(AuthorizationHandlerContext context)
{
return context.Resource switch
{
TResource resource => new List<TResource> { resource },
IEnumerable<TResource> resources => resources.ToList(),
_ => null
};
}
protected abstract Task HandleRequirementAsync(AuthorizationHandlerContext context, TRequirement requirement,
ICollection<TResource> resources);
}

View File

@ -73,7 +73,8 @@ public class GroupRepository : Repository<Group, Guid>, IGroupRepository
{
Id = c.CollectionId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly
ReadOnly = c.ReadOnly,
Manage = c.Manage
}
).ToList() ?? new List<CollectionAccessSelection>())
).ToList();
@ -141,7 +142,7 @@ public class GroupRepository : Repository<Group, Guid>, IGroupRepository
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[Group_CreateWithCollections]",
$"[{Schema}].[Group_CreateWithCollections_V2]",
objWithCollections,
commandType: CommandType.StoredProcedure);
}
@ -155,7 +156,7 @@ public class GroupRepository : Repository<Group, Guid>, IGroupRepository
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[Group_UpdateWithCollections]",
$"[{Schema}].[Group_UpdateWithCollections_V2]",
objWithCollections,
commandType: CommandType.StoredProcedure);
}

View File

@ -32,7 +32,7 @@ public static class DapperHelpers
public static DataTable ToArrayTVP(this IEnumerable<CollectionAccessSelection> values)
{
var table = new DataTable();
table.SetTypeName("[dbo].[SelectionReadOnlyArray]");
table.SetTypeName("[dbo].[CollectionAccessSelectionType]");
var idColumn = new DataColumn("Id", typeof(Guid));
table.Columns.Add(idColumn);
@ -40,6 +40,8 @@ public static class DapperHelpers
table.Columns.Add(readOnlyColumn);
var hidePasswordsColumn = new DataColumn("HidePasswords", typeof(bool));
table.Columns.Add(hidePasswordsColumn);
var manageColumn = new DataColumn("Manage", typeof(bool));
table.Columns.Add(manageColumn);
if (values != null)
{
@ -49,6 +51,7 @@ public static class DapperHelpers
row[idColumn] = value.Id;
row[readOnlyColumn] = value.ReadOnly;
row[hidePasswordsColumn] = value.HidePasswords;
row[manageColumn] = value.Manage;
table.Rows.Add(row);
}
}

View File

@ -121,7 +121,8 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
{
Id = g.GroupId,
HidePasswords = g.HidePasswords,
ReadOnly = g.ReadOnly
ReadOnly = g.ReadOnly,
Manage = g.Manage
}).ToList() ?? new List<CollectionAccessSelection>(),
Users = users
.FirstOrDefault(u => u.Key == collection.Id)?
@ -129,7 +130,8 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
{
Id = c.OrganizationUserId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly
ReadOnly = c.ReadOnly,
Manage = c.Manage
}).ToList() ?? new List<CollectionAccessSelection>()
}
)
@ -163,7 +165,8 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
{
Id = g.GroupId,
HidePasswords = g.HidePasswords,
ReadOnly = g.ReadOnly
ReadOnly = g.ReadOnly,
Manage = g.Manage
}).ToList() ?? new List<CollectionAccessSelection>(),
Users = users
.FirstOrDefault(u => u.Key == collection.Id)?
@ -171,7 +174,8 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
{
Id = c.OrganizationUserId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly
ReadOnly = c.ReadOnly,
Manage = c.Manage
}).ToList() ?? new List<CollectionAccessSelection>()
}
)
@ -217,7 +221,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[Collection_CreateWithGroupsAndUsers]",
$"[{Schema}].[Collection_CreateWithGroupsAndUsers_V2]",
objWithGroupsAndUsers,
commandType: CommandType.StoredProcedure);
}
@ -233,7 +237,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[Collection_UpdateWithGroupsAndUsers]",
$"[{Schema}].[Collection_UpdateWithGroupsAndUsers_V2]",
objWithGroupsAndUsers,
commandType: CommandType.StoredProcedure);
}
@ -248,6 +252,21 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
}
}
public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups)
{
using (var connection = new SqlConnection(ConnectionString))
{
var usersArray = users != null ? users.ToArrayTVP() : Enumerable.Empty<CollectionAccessSelection>().ToArrayTVP();
var groupsArray = groups != null ? groups.ToArrayTVP() : Enumerable.Empty<CollectionAccessSelection>().ToArrayTVP();
var results = await connection.ExecuteAsync(
$"[{Schema}].[Collection_CreateOrUpdateAccessForMany]",
new { OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP(), Users = usersArray, Groups = groupsArray },
commandType: CommandType.StoredProcedure);
}
}
public async Task CreateUserAsync(Guid collectionId, Guid organizationUserId)
{
using (var connection = new SqlConnection(ConnectionString))
@ -275,7 +294,7 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[CollectionUser_UpdateUsers]",
$"[{Schema}].[CollectionUser_UpdateUsers_V2]",
new { CollectionId = id, Users = users.ToArrayTVP() },
commandType: CommandType.StoredProcedure);
}

View File

@ -267,7 +267,8 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
{
Id = uc.CollectionId,
ReadOnly = uc.ReadOnly,
HidePasswords = uc.HidePasswords
HidePasswords = uc.HidePasswords,
Manage = uc.Manage
}).ToList() ?? new List<CollectionAccessSelection>();
}
}
@ -325,7 +326,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[OrganizationUser_CreateWithCollections]",
$"[{Schema}].[OrganizationUser_CreateWithCollections_V2]",
objWithCollections,
commandType: CommandType.StoredProcedure);
}
@ -342,7 +343,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.ExecuteAsync(
$"[{Schema}].[OrganizationUser_UpdateWithCollections]",
$"[{Schema}].[OrganizationUser_UpdateWithCollections_V2]",
objWithCollections,
commandType: CommandType.StoredProcedure);
}

View File

@ -32,6 +32,7 @@ public class GroupRepository : Repository<AdminConsoleEntities.Group, Group, Gui
GroupId = grp.Id,
ReadOnly = y.ReadOnly,
HidePasswords = y.HidePasswords,
Manage = y.Manage,
});
await dbContext.CollectionGroups.AddRangeAsync(collectionGroups);
await dbContext.SaveChangesAsync();
@ -68,6 +69,7 @@ public class GroupRepository : Repository<AdminConsoleEntities.Group, Group, Gui
Id = c.CollectionId,
ReadOnly = c.ReadOnly,
HidePasswords = c.HidePasswords,
Manage = c.Manage,
}).ToList();
return new Tuple<AdminConsoleEntities.Group, ICollection<CollectionAccessSelection>>(
grp, collections);
@ -110,7 +112,8 @@ public class GroupRepository : Repository<AdminConsoleEntities.Group, Group, Gui
{
Id = c.CollectionId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly
ReadOnly = c.ReadOnly,
Manage = c.Manage
}
).ToList() ?? new List<CollectionAccessSelection>())
).ToList();
@ -204,12 +207,14 @@ public class GroupRepository : Repository<AdminConsoleEntities.Group, Group, Gui
GroupId = group.Id,
ReadOnly = requestedCollection.ReadOnly,
HidePasswords = requestedCollection.HidePasswords,
Manage = requestedCollection.Manage
});
continue;
}
existingCollectionGroup.ReadOnly = requestedCollection.ReadOnly;
existingCollectionGroup.HidePasswords = requestedCollection.HidePasswords;
existingCollectionGroup.Manage = requestedCollection.Manage;
}
var requestedCollectionIds = requestedCollections.Select(c => c.Id);

View File

@ -68,6 +68,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
GroupId = g.Id,
ReadOnly = g.ReadOnly,
HidePasswords = g.HidePasswords,
Manage = g.Manage
});
await dbContext.AddRangeAsync(collectionGroups);
}
@ -85,6 +86,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
OrganizationUserId = u.Id,
ReadOnly = u.ReadOnly,
HidePasswords = u.HidePasswords,
Manage = u.Manage
});
await dbContext.AddRangeAsync(collectionUsers);
}
@ -130,6 +132,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
Id = cg.GroupId,
ReadOnly = cg.ReadOnly,
HidePasswords = cg.HidePasswords,
Manage = cg.Manage
};
var groups = await groupQuery.ToArrayAsync();
@ -140,6 +143,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
Id = cg.OrganizationUserId,
ReadOnly = cg.ReadOnly,
HidePasswords = cg.HidePasswords,
Manage = cg.Manage
};
var users = await userQuery.ToArrayAsync();
var access = new CollectionAccessDetails { Users = users, Groups = groups };
@ -161,6 +165,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
Id = cg.GroupId,
ReadOnly = cg.ReadOnly,
HidePasswords = cg.HidePasswords,
Manage = cg.Manage
};
var groups = await groupQuery.ToArrayAsync();
@ -171,6 +176,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
Id = cg.OrganizationUserId,
ReadOnly = cg.ReadOnly,
HidePasswords = cg.HidePasswords,
Manage = cg.Manage,
};
var users = await userQuery.ToArrayAsync();
var access = new CollectionAccessDetails { Users = users, Groups = groups };
@ -207,7 +213,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
{
Id = g.GroupId,
HidePasswords = g.HidePasswords,
ReadOnly = g.ReadOnly
ReadOnly = g.ReadOnly,
Manage = g.Manage
}).ToList() ?? new List<CollectionAccessSelection>(),
Users = users
.FirstOrDefault(u => u.Key == collection.Id)?
@ -215,7 +222,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
{
Id = c.OrganizationUserId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly
ReadOnly = c.ReadOnly,
Manage = c.Manage
}).ToList() ?? new List<CollectionAccessSelection>()
}
)
@ -251,7 +259,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
{
Id = g.GroupId,
HidePasswords = g.HidePasswords,
ReadOnly = g.ReadOnly
ReadOnly = g.ReadOnly,
Manage = g.Manage
}).ToList() ?? new List<CollectionAccessSelection>(),
Users = users
.FirstOrDefault(u => u.Key == collection.Id)?
@ -259,7 +268,8 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
{
Id = c.OrganizationUserId,
HidePasswords = c.HidePasswords,
ReadOnly = c.ReadOnly
ReadOnly = c.ReadOnly,
Manage = c.Manage
}).ToList() ?? new List<CollectionAccessSelection>()
}
)
@ -329,6 +339,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))),
})
.ToList();
}
@ -353,6 +364,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
ExternalId = collectionGroup.Key.ExternalId,
ReadOnly = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.ReadOnly))),
HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))),
Manage = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.Manage))),
}).ToListAsync();
}
}
@ -371,6 +383,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
Id = cu.OrganizationUserId,
ReadOnly = cu.ReadOnly,
HidePasswords = cu.HidePasswords,
Manage = cu.Manage
}).ToArray();
}
}
@ -415,6 +428,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
OrganizationUserId = requestedUser.Id,
HidePasswords = requestedUser.HidePasswords,
ReadOnly = requestedUser.ReadOnly,
Manage = requestedUser.Manage
});
continue;
}
@ -422,6 +436,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
// It already exists, update it
existingCollectionUser.HidePasswords = requestedUser.HidePasswords;
existingCollectionUser.ReadOnly = requestedUser.ReadOnly;
existingCollectionUser.Manage = requestedUser.Manage;
dbContext.CollectionUsers.Update(existingCollectionUser);
}
@ -458,6 +473,97 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
}
}
public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable<Guid> collectionIds,
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var collectionIdsList = collectionIds.ToList();
if (users != null)
{
var existingCollectionUsers = await dbContext.CollectionUsers
.Where(cu => collectionIdsList.Contains(cu.CollectionId))
.ToDictionaryAsync(x => (x.CollectionId, x.OrganizationUserId));
var requestedUsers = users.ToList();
foreach (var collectionId in collectionIdsList)
{
foreach (var requestedUser in requestedUsers)
{
if (!existingCollectionUsers.TryGetValue(
(collectionId, requestedUser.Id),
out var existingCollectionUser)
)
{
// This is a brand new entry
dbContext.CollectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = requestedUser.Id,
HidePasswords = requestedUser.HidePasswords,
ReadOnly = requestedUser.ReadOnly,
Manage = requestedUser.Manage
});
continue;
}
// It already exists, update it
existingCollectionUser.HidePasswords = requestedUser.HidePasswords;
existingCollectionUser.ReadOnly = requestedUser.ReadOnly;
existingCollectionUser.Manage = requestedUser.Manage;
dbContext.CollectionUsers.Update(existingCollectionUser);
}
}
}
if (groups != null)
{
var existingCollectionGroups = await dbContext.CollectionGroups
.Where(cu => collectionIdsList.Contains(cu.CollectionId))
.ToDictionaryAsync(x => (x.CollectionId, x.GroupId));
var requestedGroups = groups.ToList();
foreach (var collectionId in collectionIdsList)
{
foreach (var requestedGroup in requestedGroups)
{
if (!existingCollectionGroups.TryGetValue(
(collectionId, requestedGroup.Id),
out var existingCollectionGroup)
)
{
// This is a brand new entry
dbContext.CollectionGroups.Add(new CollectionGroup()
{
CollectionId = collectionId,
GroupId = requestedGroup.Id,
HidePasswords = requestedGroup.HidePasswords,
ReadOnly = requestedGroup.ReadOnly,
Manage = requestedGroup.Manage
});
continue;
}
// It already exists, update it
existingCollectionGroup.HidePasswords = requestedGroup.HidePasswords;
existingCollectionGroup.ReadOnly = requestedGroup.ReadOnly;
existingCollectionGroup.Manage = requestedGroup.Manage;
dbContext.CollectionGroups.Update(existingCollectionGroup);
}
}
}
// Need to save the new collection users/groups before running the bump revision code
await dbContext.SaveChangesAsync();
await dbContext.UserBumpAccountRevisionDateByCollectionIdsAsync(collectionIdsList, organizationId);
await dbContext.SaveChangesAsync();
}
}
private async Task ReplaceCollectionGroupsAsync(DatabaseContext dbContext, Core.Entities.Collection collection, IEnumerable<CollectionAccessSelection> groups)
{
var groupsInOrg = dbContext.Groups.Where(g => g.OrganizationId == collection.OrganizationId);
@ -487,13 +593,15 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
GroupId = x.g.Id,
ReadOnly = groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly,
HidePasswords = groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords,
Manage = groups.FirstOrDefault(g => g.Id == x.g.Id).Manage
}).ToList();
var update = union
.Where(
x => x.g != null &&
x.cg != null &&
(x.cg.ReadOnly != groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly ||
x.cg.HidePasswords != groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords)
x.cg.HidePasswords != groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords ||
x.cg.Manage != groups.FirstOrDefault(g => g.Id == x.g.Id).Manage)
)
.Select(x => new CollectionGroup
{
@ -501,6 +609,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
GroupId = x.g.Id,
ReadOnly = groups.FirstOrDefault(g => g.Id == x.g.Id).ReadOnly,
HidePasswords = groups.FirstOrDefault(g => g.Id == x.g.Id).HidePasswords,
Manage = groups.FirstOrDefault(g => g.Id == x.g.Id).Manage,
});
var delete = union
.Where(
@ -549,13 +658,15 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
OrganizationUserId = x.u.Id,
ReadOnly = users.FirstOrDefault(u => u.Id == x.u.Id).ReadOnly,
HidePasswords = users.FirstOrDefault(u => u.Id == x.u.Id).HidePasswords,
Manage = users.FirstOrDefault(u => u.Id == x.u.Id).Manage,
}).ToList();
var update = union
.Where(
x => x.u != null &&
x.cu != null &&
(x.cu.ReadOnly != users.FirstOrDefault(u => u.Id == x.u.Id).ReadOnly ||
x.cu.HidePasswords != users.FirstOrDefault(u => u.Id == x.u.Id).HidePasswords)
x.cu.HidePasswords != users.FirstOrDefault(u => u.Id == x.u.Id).HidePasswords ||
x.cu.Manage != users.FirstOrDefault(u => u.Id == x.u.Id).Manage)
)
.Select(x => new CollectionUser
{
@ -563,6 +674,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
OrganizationUserId = x.u.Id,
ReadOnly = users.FirstOrDefault(u => u.Id == x.u.Id).ReadOnly,
HidePasswords = users.FirstOrDefault(u => u.Id == x.u.Id).HidePasswords,
Manage = users.FirstOrDefault(u => u.Id == x.u.Id).Manage,
});
var delete = union
.Where(

View File

@ -110,6 +110,9 @@ public class DatabaseContext : DbContext
eGroup.Property(c => c.Id).ValueGeneratedNever();
eInstallation.Property(c => c.Id).ValueGeneratedNever();
eOrganization.Property(c => c.Id).ValueGeneratedNever();
eOrganization.Property(c => c.LimitCollectionCreationDeletion)
.ValueGeneratedNever()
.HasDefaultValue(true);
eOrganizationSponsorship.Property(c => c.Id).ValueGeneratedNever();
eOrganizationUser.Property(c => c.Id).ValueGeneratedNever();
ePolicy.Property(c => c.Id).ValueGeneratedNever();

View File

@ -74,6 +74,39 @@ public static class DatabaseContextExtensions
UpdateUserRevisionDate(users);
}
public static async Task UserBumpAccountRevisionDateByCollectionIdsAsync(this DatabaseContext context, IEnumerable<Guid> collectionIds, Guid organizationId)
{
var query = from u in context.Users
from c in context.Collections
join ou in context.OrganizationUsers
on u.Id equals ou.UserId
join cu in context.CollectionUsers
on new { ou.AccessAll, OrganizationUserId = ou.Id, CollectionId = c.Id } equals
new { AccessAll = false, cu.OrganizationUserId, cu.CollectionId } into cu_g
from cu in cu_g.DefaultIfEmpty()
join gu in context.GroupUsers
on new { CollectionId = (Guid?)cu.CollectionId, ou.AccessAll, OrganizationUserId = ou.Id } equals
new { CollectionId = (Guid?)null, AccessAll = false, gu.OrganizationUserId } into gu_g
from gu in gu_g.DefaultIfEmpty()
join g in context.Groups
on gu.GroupId equals g.Id into g_g
from g in g_g.DefaultIfEmpty()
join cg in context.CollectionGroups
on new { g.AccessAll, gu.GroupId, CollectionId = c.Id } equals
new { AccessAll = false, cg.GroupId, cg.CollectionId } into cg_g
from cg in cg_g.DefaultIfEmpty()
where ou.OrganizationId == organizationId && collectionIds.Contains(c.Id) &&
ou.Status == OrganizationUserStatusType.Confirmed &&
(cu.CollectionId != null ||
cg.CollectionId != null ||
ou.AccessAll == true ||
g.AccessAll == true)
select u;
var users = await query.ToListAsync();
UpdateUserRevisionDate(users);
}
public static async Task UserBumpAccountRevisionDateByOrganizationUserIdAsync(this DatabaseContext context, Guid organizationUserId)
{
var query = from u in context.Users

View File

@ -33,6 +33,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
OrganizationUserId = organizationUser.Id,
ReadOnly = y.ReadOnly,
HidePasswords = y.HidePasswords,
Manage = y.Manage
});
await dbContext.CollectionUsers.AddRangeAsync(collectionUsers);
await dbContext.SaveChangesAsync();
@ -146,6 +147,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
Id = cu.CollectionId,
ReadOnly = cu.ReadOnly,
HidePasswords = cu.HidePasswords,
Manage = cu.Manage,
});
return new Tuple<Core.Entities.OrganizationUser, ICollection<CollectionAccessSelection>>(
organizationUser, collections.ToList());
@ -240,6 +242,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
Id = cu.CollectionId,
ReadOnly = cu.ReadOnly,
HidePasswords = cu.HidePasswords,
Manage = cu.Manage
}).ToListAsync();
return new Tuple<OrganizationUserUserDetails, ICollection<CollectionAccessSelection>>(organizationUserUserDetails, collections);
}
@ -365,7 +368,8 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
{
Id = cu.CollectionId,
ReadOnly = cu.ReadOnly,
HidePasswords = cu.HidePasswords
HidePasswords = cu.HidePasswords,
Manage = cu.Manage,
}).ToList() ?? new List<CollectionAccessSelection>();
}
}
@ -445,6 +449,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
OrganizationUserId = obj.Id,
HidePasswords = requestedCollection.HidePasswords,
ReadOnly = requestedCollection.ReadOnly,
Manage = requestedCollection.Manage
});
continue;
}
@ -452,6 +457,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
// It already exists, update it
existingCollectionUser.HidePasswords = requestedCollection.HidePasswords;
existingCollectionUser.ReadOnly = requestedCollection.ReadOnly;
existingCollectionUser.Manage = requestedCollection.Manage;
dbContext.CollectionUsers.Update(existingCollectionUser);
}

View File

@ -58,6 +58,8 @@ public class UserCollectionDetailsQuery : IQuery<CollectionDetails>
!((bool?)x.cu.ReadOnly ?? (bool?)x.cg.ReadOnly ?? false) ? false : true,
HidePasswords = x.ou.AccessAll || x.g.AccessAll ||
!((bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false) ? false : true,
Manage = x.ou.AccessAll || x.g.AccessAll ||
!((bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false) ? false : true,
});
}
}

View File

@ -18,7 +18,15 @@ SELECT
OR COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 0
ELSE 1
END [HidePasswords]
END [HidePasswords],
CASE
WHEN
OU.[AccessAll] = 1
OR G.[AccessAll] = 1
OR COALESCE(CU.[Manage], CG.[Manage], 0) = 0
THEN 0
ELSE 1
END [Manage]
FROM
[dbo].[CollectionView] C
INNER JOIN

View File

@ -7,9 +7,10 @@ BEGIN
SELECT
[GroupId] [Id],
[ReadOnly],
[HidePasswords]
[HidePasswords],
[Manage]
FROM
[dbo].[CollectionGroup]
WHERE
[CollectionId] = @CollectionId
END
END

View File

@ -7,9 +7,10 @@ BEGIN
SELECT
[OrganizationUserId] [Id],
[ReadOnly],
[HidePasswords]
[HidePasswords],
[Manage]
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = @CollectionId
END
END

View File

@ -31,9 +31,14 @@ BEGIN
OR [Target].[HidePasswords] != [Source].[HidePasswords]
)
-- Insert
INSERT INTO
[dbo].[CollectionUser]
-- Insert (with column list because a value for Manage is not being provided)
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords]
)
SELECT
@CollectionId,
[Source].[Id],
@ -53,7 +58,7 @@ BEGIN
[CollectionId] = @CollectionId
AND [OrganizationUserId] = [Source].[Id]
)
-- Delete
DELETE
CU
@ -71,4 +76,4 @@ BEGIN
)
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @CollectionId, @OrgId
END
END

View File

@ -0,0 +1,83 @@
CREATE PROCEDURE [dbo].[CollectionUser_UpdateUsers_V2]
@CollectionId UNIQUEIDENTIFIER,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @OrgId UNIQUEIDENTIFIER = (
SELECT TOP 1
[OrganizationId]
FROM
[dbo].[Collection]
WHERE
[Id] = @CollectionId
)
-- Update
UPDATE
[Target]
SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
FROM
[dbo].[CollectionUser] [Target]
INNER JOIN
@Users [Source] ON [Source].[Id] = [Target].[OrganizationUserId]
WHERE
[Target].[CollectionId] = @CollectionId
AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
)
-- Insert
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@CollectionId,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
FROM
@Users [Source]
INNER JOIN
[dbo].[OrganizationUser] OU ON [Source].[Id] = OU.[Id] AND OU.[OrganizationId] = @OrgId
WHERE
NOT EXISTS (
SELECT
1
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = @CollectionId
AND [OrganizationUserId] = [Source].[Id]
)
-- Delete
DELETE
CU
FROM
[dbo].[CollectionUser] CU
WHERE
CU.[CollectionId] = @CollectionId
AND NOT EXISTS (
SELECT
1
FROM
@Users
WHERE
[Id] = CU.[OrganizationUserId]
)
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @CollectionId, @OrgId
END

View File

@ -0,0 +1,113 @@
CREATE PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany]
@OrganizationId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
-- Groups
;WITH [NewCollectionGroups] AS (
SELECT
cId.[Id] AS [CollectionId],
cg.[Id] AS [GroupId],
cg.[ReadOnly],
cg.[HidePasswords],
cg.[Manage]
FROM
@Groups AS cg
CROSS JOIN -- Create a CollectionGroup record for every CollectionId
@CollectionIds cId
INNER JOIN
[dbo].[Group] g ON cg.[Id] = g.[Id]
WHERE
g.[OrganizationId] = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] as [Target]
USING
[NewCollectionGroups] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[GroupId] = [Source].[GroupId]
-- Update the target if any values are different from the source
WHEN MATCHED AND EXISTS(
SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage]
EXCEPT
SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage]
) THEN UPDATE SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY TARGET
THEN INSERT
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
[Source].[CollectionId],
[Source].[GroupId],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
);
-- Users
;WITH [NewCollectionUsers] AS (
SELECT
cId.[Id] AS [CollectionId],
cu.[Id] AS [OrganizationUserId],
cu.[ReadOnly],
cu.[HidePasswords],
cu.[Manage]
FROM
@Users AS cu
CROSS JOIN -- Create a CollectionUser record for every CollectionId
@CollectionIds cId
INNER JOIN
[dbo].[OrganizationUser] u ON cu.[Id] = u.[Id]
WHERE
u.[OrganizationId] = @OrganizationId
)
MERGE
[dbo].[CollectionUser] as [Target]
USING
[NewCollectionUsers] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[OrganizationUserId] = [Source].[OrganizationUserId]
-- Update the target if any values are different from the source
WHEN MATCHED AND EXISTS(
SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage]
EXCEPT
SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage]
) THEN UPDATE SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY TARGET
THEN INSERT
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
[Source].[CollectionId],
[Source].[OrganizationUserId],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
);
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId
END

View File

@ -0,0 +1,73 @@
CREATE PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
;WITH [AvailableGroupsCTE] AS(
SELECT
[Id]
FROM
[dbo].[Group]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionGroup]
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@Id,
[Id],
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Groups
WHERE
[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE])
-- Users
;WITH [AvailableUsersCTE] AS(
SELECT
[Id]
FROM
[dbo].[OrganizationUser]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@Id,
[Id],
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Users
WHERE
[Id] IN (SELECT [Id] FROM [AvailableUsersCTE])
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END

View File

@ -12,7 +12,8 @@ BEGIN
RevisionDate,
ExternalId,
MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords]
MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage]
FROM
[dbo].[UserCollectionDetails](@UserId)
WHERE

View File

@ -12,7 +12,8 @@ BEGIN
RevisionDate,
ExternalId,
MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords]
MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage]
FROM
[dbo].[UserCollectionDetails](@UserId)
GROUP BY

View File

@ -12,7 +12,8 @@ BEGIN
RevisionDate DATETIME2(7),
ExternalId NVARCHAR(300),
ReadOnly BIT,
HidePasswords BIT)
HidePasswords BIT,
Manage BIT)
INSERT INTO @TempUserCollections EXEC [dbo].[Collection_ReadByUserId] @UserId
@ -35,4 +36,4 @@ BEGIN
INNER JOIN
@TempUserCollections C ON C.[Id] = CU.[CollectionId]
END
END

View File

@ -24,14 +24,21 @@ BEGIN
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
USING
@Groups AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[GroupId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT VALUES
INSERT -- With column list because a value for Manage is not being provided
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords]
)
VALUES
(
@Id,
[Source].[Id],
@ -60,14 +67,21 @@ BEGIN
)
MERGE
[dbo].[CollectionUser] AS [Target]
USING
USING
@Users AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[OrganizationUserId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT VALUES
INSERT -- With column list because a value for Manage is not being provided
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords]
)
VALUES
(
@Id,
[Source].[Id],
@ -86,4 +100,4 @@ BEGIN
;
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
END
END

View File

@ -0,0 +1,111 @@
CREATE PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[Group]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
@Groups AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[GroupId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT -- Add explicit column list
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
-- Users
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[OrganizationUser]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionUser] AS [Target]
USING
@Users AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[OrganizationUserId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
END

View File

@ -0,0 +1,44 @@
CREATE PROCEDURE [dbo].[Group_CreateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name NVARCHAR(100),
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Group_Create] @Id, @OrganizationId, @Name, @AccessAll, @ExternalId, @CreationDate, @RevisionDate
;WITH [AvailableCollectionsCTE] AS(
SELECT
[Id]
FROM
[dbo].[Collection]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionGroup]
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
[Id],
@Id,
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Collections
WHERE
[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE])
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END

View File

@ -9,9 +9,10 @@ BEGIN
SELECT
[CollectionId] [Id],
[ReadOnly],
[HidePasswords]
[HidePasswords],
[Manage]
FROM
[dbo].[CollectionGroup]
WHERE
[GroupId] = @Id
END
END

View File

@ -23,14 +23,21 @@ BEGIN
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
USING
@Collections AS [Source]
ON
[Target].[CollectionId] = [Source].[Id]
AND [Target].[GroupId] = @Id
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN
INSERT VALUES
INSERT -- With column list because a value for Manage is not being provided
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords]
)
VALUES
(
[Source].[Id],
@Id,
@ -49,4 +56,4 @@ BEGIN
;
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
END

View File

@ -0,0 +1,63 @@
CREATE PROCEDURE [dbo].[Group_UpdateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name NVARCHAR(100),
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @AccessAll, @ExternalId, @CreationDate, @RevisionDate
;WITH [AvailableCollectionsCTE] AS(
SELECT
Id
FROM
[dbo].[Collection]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
@Collections AS [Source]
ON
[Target].[CollectionId] = [Source].[Id]
AND [Target].[GroupId] = @Id
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN
INSERT
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
[Source].[Id],
@Id,
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[GroupId] = @Id THEN
DELETE
;
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END

View File

@ -9,11 +9,12 @@ BEGIN
SELECT
CU.[CollectionId] Id,
CU.[ReadOnly],
CU.[HidePasswords]
CU.[HidePasswords],
CU.[Manage]
FROM
[dbo].[OrganizationUser] OU
INNER JOIN
[dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[OrganizationUserId] = [OU].[Id]
WHERE
[OrganizationUserId] = @Id
END
END

View File

@ -0,0 +1,49 @@
CREATE PROCEDURE [dbo].[OrganizationUser_CreateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@Email NVARCHAR(256),
@Key VARCHAR(MAX),
@Status SMALLINT,
@Type TINYINT,
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Permissions NVARCHAR(MAX),
@ResetPasswordKey VARCHAR(MAX),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY,
@AccessSecretsManager BIT = 0
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[OrganizationUser_Create] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @AccessAll, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager
;WITH [AvailableCollectionsCTE] AS(
SELECT
[Id]
FROM
[dbo].[Collection]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
[Id],
@Id,
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Collections
WHERE
[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE])
END

View File

@ -36,9 +36,14 @@ BEGIN
OR [Target].[HidePasswords] != [Source].[HidePasswords]
)
-- Insert
INSERT INTO
[dbo].[CollectionUser]
-- Insert (with column list because a value for Manage is not being provided)
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords]
)
SELECT
[Source].[Id],
@Id,
@ -58,7 +63,7 @@ BEGIN
[CollectionId] = [Source].[Id]
AND [OrganizationUserId] = @Id
)
-- Delete
DELETE
CU

View File

@ -0,0 +1,86 @@
CREATE PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@Email NVARCHAR(256),
@Key VARCHAR(MAX),
@Status SMALLINT,
@Type TINYINT,
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Permissions NVARCHAR(MAX),
@ResetPasswordKey VARCHAR(MAX),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY,
@AccessSecretsManager BIT = 0
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @AccessAll, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager
-- Update
UPDATE
[Target]
SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
FROM
[dbo].[CollectionUser] AS [Target]
INNER JOIN
@Collections AS [Source] ON [Source].[Id] = [Target].[CollectionId]
WHERE
[Target].[OrganizationUserId] = @Id
AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
)
-- Insert
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
[Source].[Id],
@Id,
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
FROM
@Collections AS [Source]
INNER JOIN
[dbo].[Collection] C ON C.[Id] = [Source].[Id] AND C.[OrganizationId] = @OrganizationId
WHERE
NOT EXISTS (
SELECT
1
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = [Source].[Id]
AND [OrganizationUserId] = @Id
)
-- Delete
DELETE
CU
FROM
[dbo].[CollectionUser] CU
WHERE
CU.[OrganizationUserId] = @Id
AND NOT EXISTS (
SELECT
1
FROM
@Collections
WHERE
[Id] = CU.[CollectionId]
)
END

View File

@ -50,7 +50,8 @@
@SmServiceAccounts INT = null,
@MaxAutoscaleSmSeats INT= null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0
@SecretsManagerBeta BIT = 0,
@LimitCollectionCreationDeletion BIT = 1
AS
BEGIN
SET NOCOUNT ON
@ -108,7 +109,8 @@ BEGIN
[SmServiceAccounts],
[MaxAutoscaleSmSeats],
[MaxAutoscaleSmServiceAccounts],
[SecretsManagerBeta]
[SecretsManagerBeta],
[LimitCollectionCreationDeletion]
)
VALUES
(
@ -163,6 +165,7 @@ BEGIN
@SmServiceAccounts,
@MaxAutoscaleSmSeats,
@MaxAutoscaleSmServiceAccounts,
@SecretsManagerBeta
@SecretsManagerBeta,
@LimitCollectionCreationDeletion
)
END
END

View File

@ -50,7 +50,8 @@
@SmServiceAccounts INT = null,
@MaxAutoscaleSmSeats INT = null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0
@SecretsManagerBeta BIT = 0,
@LimitCollectionCreationDeletion BIT = 1
AS
BEGIN
SET NOCOUNT ON
@ -108,7 +109,8 @@ BEGIN
[SmServiceAccounts] = @SmServiceAccounts,
[MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats,
[MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts,
[SecretsManagerBeta] = @SecretsManagerBeta
[SecretsManagerBeta] = @SecretsManagerBeta,
[LimitCollectionCreationDeletion] = @LimitCollectionCreationDeletion
WHERE
[Id] = @Id
END

View File

@ -0,0 +1,35 @@
CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByCollectionIds]
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
UPDATE
U
SET
U.[AccountRevisionDate] = GETUTCDATE()
FROM
[dbo].[User] U
INNER JOIN
[dbo].[Collection] C ON C.[Id] IN (SELECT [Id] FROM @CollectionIds)
INNER JOIN
[dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id]
LEFT JOIN
[dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] = C.[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND OU.[AccessAll] = 0 AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON G.[AccessAll] = 0 AND CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = C.[Id]
WHERE
OU.[OrganizationId] = @OrganizationId
AND OU.[Status] = 2 -- 2 = Confirmed
AND (
CU.[CollectionId] IS NOT NULL
OR CG.[CollectionId] IS NOT NULL
OR OU.[AccessAll] = 1
OR G.[AccessAll] = 1
)
END

View File

@ -3,6 +3,7 @@
[GroupId] UNIQUEIDENTIFIER NOT NULL,
[ReadOnly] BIT NOT NULL,
[HidePasswords] BIT NOT NULL,
[Manage] BIT NOT NULL CONSTRAINT D_CollectionGroup_Manage DEFAULT (0),
CONSTRAINT [PK_CollectionGroup] PRIMARY KEY CLUSTERED ([CollectionId] ASC, [GroupId] ASC),
CONSTRAINT [FK_CollectionGroup_Collection] FOREIGN KEY ([CollectionId]) REFERENCES [dbo].[Collection] ([Id]),
CONSTRAINT [FK_CollectionGroup_Group] FOREIGN KEY ([GroupId]) REFERENCES [dbo].[Group] ([Id]) ON DELETE CASCADE

View File

@ -3,6 +3,7 @@
[OrganizationUserId] UNIQUEIDENTIFIER NOT NULL,
[ReadOnly] BIT NOT NULL,
[HidePasswords] BIT NOT NULL,
[Manage] BIT NOT NULL CONSTRAINT D_CollectionUser_Manage DEFAULT (0),
CONSTRAINT [PK_CollectionUser] PRIMARY KEY CLUSTERED ([CollectionId] ASC, [OrganizationUserId] ASC),
CONSTRAINT [FK_CollectionUser_Collection] FOREIGN KEY ([CollectionId]) REFERENCES [dbo].[Collection] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_CollectionUser_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id])

View File

@ -51,6 +51,7 @@
[MaxAutoscaleSmSeats] INT NULL,
[MaxAutoscaleSmServiceAccounts] INT NULL,
[SecretsManagerBeta] BIT NOT NULL CONSTRAINT [DF_Organization_SecretsManagerBeta] DEFAULT (0),
[LimitCollectionCreationDeletion] BIT NOT NULL CONSTRAINT [DF_Organization_LimitCollectionCreationDeletion] DEFAULT (1),
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
);

View File

@ -0,0 +1,6 @@
CREATE TYPE [dbo].[CollectionAccessSelectionType] AS TABLE (
[Id] UNIQUEIDENTIFIER NOT NULL,
[ReadOnly] BIT NOT NULL,
[HidePasswords] BIT NOT NULL,
[Manage] BIT NOT NULL);

View File

@ -44,7 +44,8 @@ SELECT
OU.[AccessSecretsManager],
O.[UsePasswordManager],
O.[SmSeats],
O.[SmServiceAccounts]
O.[SmServiceAccounts],
O.[LimitCollectionCreationDeletion]
FROM
[dbo].[OrganizationUser] OU
LEFT JOIN

View File

@ -1,5 +1,8 @@
using Bit.Api.Controllers;
using System.Security.Claims;
using Bit.Api.Controllers;
using Bit.Api.Models.Request;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
@ -7,8 +10,10 @@ using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization;
using NSubstitute;
using Xunit;
@ -16,30 +21,29 @@ namespace Bit.Api.Test.Controllers;
[ControllerCustomize(typeof(CollectionsController))]
[SutProviderCustomize]
[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)]
public class CollectionsControllerTests
{
[Theory, BitAutoData]
public async Task Post_Success(Guid orgId, SutProvider<CollectionsController> sutProvider)
public async Task Post_Success(Guid orgId, CollectionRequestModel collectionRequest,
SutProvider<CollectionsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>()
.CreateNewCollections(orgId)
.Returns(true);
Collection ExpectedCollection() => Arg.Is<Collection>(c =>
c.Name == collectionRequest.Name && c.ExternalId == collectionRequest.ExternalId &&
c.OrganizationId == orgId);
sutProvider.GetDependency<ICurrentContext>()
.EditAnyCollection(orgId)
.Returns(false);
var collectionRequest = new CollectionRequestModel
{
Name = "encrypted_string",
ExternalId = "my_external_id"
};
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
ExpectedCollection(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(r => r.Contains(CollectionOperations.Create)))
.Returns(AuthorizationResult.Success());
_ = await sutProvider.Sut.Post(orgId, collectionRequest);
await sutProvider.GetDependency<ICollectionService>()
.Received(1)
.SaveAsync(Arg.Any<Collection>(), Arg.Any<IEnumerable<CollectionAccessSelection>>(), null);
.SaveAsync(Arg.Any<Collection>(), Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>());
}
[Theory, BitAutoData]
@ -139,13 +143,12 @@ public class CollectionsControllerTests
[Theory, BitAutoData]
public async Task DeleteMany_Success(Guid orgId, User user, Collection collection1, Collection collection2, SutProvider<CollectionsController> sutProvider)
public async Task DeleteMany_Success(Guid orgId, Collection collection1, Collection collection2, SutProvider<CollectionsController> sutProvider)
{
// Arrange
var model = new CollectionBulkDeleteRequestModel
{
Ids = new[] { collection1.Id.ToString(), collection2.Id.ToString() },
OrganizationId = orgId.ToString()
Ids = new[] { collection1.Id, collection2.Id }
};
var collections = new List<Collection>
@ -162,20 +165,17 @@ public class CollectionsControllerTests
},
};
sutProvider.GetDependency<ICurrentContext>()
.DeleteAssignedCollections(orgId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.UserId
.Returns(user.Id);
sutProvider.GetDependency<ICollectionService>()
.GetOrganizationCollectionsAsync(orgId)
sutProvider.GetDependency<ICollectionRepository>().GetManyByManyIdsAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(collections);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
collections,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(r => r.Contains(CollectionOperations.Delete)))
.Returns(AuthorizationResult.Success());
// Act
await sutProvider.Sut.DeleteMany(model);
await sutProvider.Sut.DeleteMany(orgId, model);
// Assert
await sutProvider.GetDependency<IDeleteCollectionCommand>()
@ -185,69 +185,159 @@ public class CollectionsControllerTests
}
[Theory, BitAutoData]
public async Task DeleteMany_CanNotDeleteAssignedCollection_ThrowsNotFound(Guid orgId, Collection collection1, Collection collection2, SutProvider<CollectionsController> sutProvider)
public async Task DeleteMany_PermissionDenied_ThrowsNotFound(Guid orgId, Collection collection1, Collection collection2, SutProvider<CollectionsController> sutProvider)
{
// Arrange
var model = new CollectionBulkDeleteRequestModel
{
Ids = new[] { collection1.Id.ToString(), collection2.Id.ToString() },
OrganizationId = orgId.ToString()
Ids = new[] { collection1.Id, collection2.Id }
};
sutProvider.GetDependency<ICurrentContext>()
.DeleteAssignedCollections(orgId)
.Returns(false);
var collections = new List<Collection>
{
new CollectionDetails
{
Id = collection1.Id,
OrganizationId = orgId,
},
new CollectionDetails
{
Id = collection2.Id,
OrganizationId = orgId,
},
};
sutProvider.GetDependency<ICollectionRepository>().GetManyByManyIdsAsync(Arg.Any<IEnumerable<Guid>>())
.Returns(collections);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
collections,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(r => r.Contains(CollectionOperations.Delete)))
.Returns(AuthorizationResult.Failed());
// Assert
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.DeleteMany(model));
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)
public async Task PostBulkCollectionAccess_Success(User actingUser, ICollection<Collection> collections, SutProvider<CollectionsController> sutProvider)
{
// Arrange
var model = new CollectionBulkDeleteRequestModel
var userId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var model = new BulkCollectionAccessRequestModel
{
Ids = new[] { collection1.Id.ToString(), collection2.Id.ToString() },
OrganizationId = orgId.ToString()
CollectionIds = collections.Select(c => c.Id),
Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } },
Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } },
};
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)
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByManyIdsAsync(model.CollectionIds)
.Returns(collections);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(actingUser.Id);
sutProvider.GetDependency<IAuthorizationService>().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(), ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess)
))
.Returns(AuthorizationResult.Success());
IEnumerable<Collection> ExpectedCollectionAccess() => Arg.Is<IEnumerable<Collection>>(cols => cols.SequenceEqual(collections));
// Act
await sutProvider.Sut.DeleteMany(model);
await sutProvider.Sut.PostBulkCollectionAccess(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))));
await sutProvider.GetDependency<IAuthorizationService>().Received().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess))
);
await sutProvider.GetDependency<IBulkAddCollectionAccessCommand>().Received()
.AddAccessAsync(
Arg.Is<ICollection<Collection>>(g => g.SequenceEqual(collections)),
Arg.Is<ICollection<CollectionAccessSelection>>(u => u.All(c => c.Id == userId && c.Manage)),
Arg.Is<ICollection<CollectionAccessSelection>>(g => g.All(c => c.Id == groupId && c.ReadOnly)));
}
[Theory, BitAutoData]
public async Task PostBulkCollectionAccess_CollectionsNotFound_Throws(User actingUser, ICollection<Collection> collections, SutProvider<CollectionsController> sutProvider)
{
var userId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var model = new BulkCollectionAccessRequestModel
{
CollectionIds = collections.Select(c => c.Id),
Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } },
Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } },
};
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(actingUser.Id);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByManyIdsAsync(model.CollectionIds)
.Returns(collections.Skip(1).ToList());
var exception = await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PostBulkCollectionAccess(model));
Assert.Equal("One or more collections not found.", exception.Message);
await sutProvider.GetDependency<IAuthorizationService>().DidNotReceiveWithAnyArgs().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
Arg.Any<IEnumerable<Collection>>(),
Arg.Any<IEnumerable<IAuthorizationRequirement>>()
);
await sutProvider.GetDependency<IBulkAddCollectionAccessCommand>().DidNotReceiveWithAnyArgs()
.AddAccessAsync(default, default, default);
}
[Theory, BitAutoData]
public async Task PostBulkCollectionAccess_AccessDenied_Throws(User actingUser, ICollection<Collection> collections, SutProvider<CollectionsController> sutProvider)
{
var userId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var model = new BulkCollectionAccessRequestModel
{
CollectionIds = collections.Select(c => c.Id),
Users = new[] { new SelectionReadOnlyRequestModel { Id = userId, Manage = true } },
Groups = new[] { new SelectionReadOnlyRequestModel { Id = groupId, ReadOnly = true } },
};
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(actingUser.Id);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByManyIdsAsync(model.CollectionIds)
.Returns(collections);
sutProvider.GetDependency<IAuthorizationService>().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(), ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess)
))
.Returns(AuthorizationResult.Failed());
IEnumerable<Collection> ExpectedCollectionAccess() => Arg.Is<IEnumerable<Collection>>(cols => cols.SequenceEqual(collections));
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.PostBulkCollectionAccess(model));
await sutProvider.GetDependency<IAuthorizationService>().Received().AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
ExpectedCollectionAccess(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(
r => r.Contains(CollectionOperations.ModifyAccess))
);
await sutProvider.GetDependency<IBulkAddCollectionAccessCommand>().DidNotReceiveWithAnyArgs()
.AddAccessAsync(default, default, default);
}
}

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

@ -0,0 +1,224 @@
using System.Security.Claims;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Test.AutoFixture;
using Bit.Core.Test.Vault.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Vault.AuthorizationHandlers;
[SutProviderCustomize]
[FeatureServiceCustomize(FeatureFlagKeys.FlexibleCollections)]
public class CollectionAuthorizationHandlerTests
{
[Theory, CollectionCustomization]
[BitAutoData(OrganizationUserType.User, false, true)]
[BitAutoData(OrganizationUserType.Admin, false, false)]
[BitAutoData(OrganizationUserType.Owner, false, false)]
[BitAutoData(OrganizationUserType.Custom, true, false)]
[BitAutoData(OrganizationUserType.Owner, true, true)]
public async Task CanManageCollectionAccessAsync_Success(
OrganizationUserType userType, bool editAnyCollection, bool manageCollections,
SutProvider<CollectionAuthorizationHandler> sutProvider,
ICollection<Collection> collections,
ICollection<CollectionDetails> collectionDetails,
CurrentContextOrganization organization)
{
var actingUserId = Guid.NewGuid();
foreach (var collectionDetail in collectionDetails)
{
collectionDetail.Manage = manageCollections;
}
organization.Type = userType;
organization.Permissions.EditAnyCollection = editAnyCollection;
var context = new AuthorizationHandlerContext(
new[] { CollectionOperations.ModifyAccess },
new ClaimsPrincipal(),
collections);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId).Returns(collectionDetails);
await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Theory, CollectionCustomization]
[BitAutoData(OrganizationUserType.User, false, false)]
[BitAutoData(OrganizationUserType.Admin, false, true)]
[BitAutoData(OrganizationUserType.Owner, false, true)]
[BitAutoData(OrganizationUserType.Custom, true, true)]
public async Task CanCreateAsync_Success(
OrganizationUserType userType, bool createNewCollection, bool limitCollectionCreateDelete,
SutProvider<CollectionAuthorizationHandler> sutProvider,
ICollection<Collection> collections,
CurrentContextOrganization organization)
{
var actingUserId = Guid.NewGuid();
organization.Type = userType;
organization.Permissions.CreateNewCollections = createNewCollection;
organization.LimitCollectionCreationDeletion = limitCollectionCreateDelete;
var context = new AuthorizationHandlerContext(
new[] { CollectionOperations.Create },
new ClaimsPrincipal(),
collections);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Theory, CollectionCustomization]
[BitAutoData(OrganizationUserType.User, false, false, true)]
[BitAutoData(OrganizationUserType.Admin, false, true, false)]
[BitAutoData(OrganizationUserType.Owner, false, true, false)]
[BitAutoData(OrganizationUserType.Custom, true, true, false)]
public async Task CanDeleteAsync_Success(
OrganizationUserType userType, bool deleteAnyCollection, bool limitCollectionCreateDelete, bool manageCollections,
SutProvider<CollectionAuthorizationHandler> sutProvider,
ICollection<Collection> collections,
ICollection<CollectionDetails> collectionDetails,
CurrentContextOrganization organization)
{
var actingUserId = Guid.NewGuid();
foreach (var collectionDetail in collectionDetails)
{
collectionDetail.Manage = manageCollections;
}
organization.Type = userType;
organization.Permissions.DeleteAnyCollection = deleteAnyCollection;
organization.LimitCollectionCreationDeletion = limitCollectionCreateDelete;
var context = new AuthorizationHandlerContext(
new[] { CollectionOperations.Delete },
new ClaimsPrincipal(),
collections);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(organization.Id).Returns(organization);
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId).Returns(collectionDetails);
await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task HandleRequirementAsync_MissingUserId_Failure(
SutProvider<CollectionAuthorizationHandler> sutProvider,
ICollection<Collection> collections)
{
var context = new AuthorizationHandlerContext(
new[] { CollectionOperations.Create },
new ClaimsPrincipal(),
collections
);
// Simulate missing user id
sutProvider.GetDependency<ICurrentContext>().UserId.Returns((Guid?)null);
await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasFailed);
sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs();
}
[Theory, BitAutoData, CollectionCustomization]
public async Task HandleRequirementAsync_TargetCollectionsMultipleOrgs_Failure(
SutProvider<CollectionAuthorizationHandler> sutProvider,
IList<Collection> collections)
{
var actingUserId = Guid.NewGuid();
// Simulate a collection in a different organization
collections.First().OrganizationId = Guid.NewGuid();
var context = new AuthorizationHandlerContext(
new[] { CollectionOperations.Create },
new ClaimsPrincipal(),
collections
);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.HandleAsync(context));
Assert.Equal("Requested collections must belong to the same organization.", exception.Message);
sutProvider.GetDependency<ICurrentContext>().DidNotReceiveWithAnyArgs().GetOrganization(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task HandleRequirementAsync_MissingOrg_Failure(
SutProvider<CollectionAuthorizationHandler> sutProvider,
ICollection<Collection> collections)
{
var actingUserId = Guid.NewGuid();
var context = new AuthorizationHandlerContext(
new[] { CollectionOperations.Create },
new ClaimsPrincipal(),
collections
);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(Arg.Any<Guid>()).Returns((CurrentContextOrganization)null);
await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasFailed);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task CanManageCollectionAccessAsync_MissingManageCollectionPermission_Failure(
SutProvider<CollectionAuthorizationHandler> sutProvider,
ICollection<Collection> collections,
ICollection<CollectionDetails> collectionDetails,
CurrentContextOrganization organization)
{
var actingUserId = Guid.NewGuid();
foreach (var collectionDetail in collectionDetails)
{
collectionDetail.Manage = true;
}
// Simulate one collection missing the manage permission
collectionDetails.First().Manage = false;
// Ensure the user is not an owner/admin and does not have edit any collection permission
organization.Type = OrganizationUserType.User;
organization.Permissions.EditAnyCollection = false;
var context = new AuthorizationHandlerContext(
new[] { CollectionOperations.ModifyAccess },
new ClaimsPrincipal(),
collections
);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(actingUserId);
sutProvider.GetDependency<ICurrentContext>().GetOrganization(Arg.Any<Guid>()).Returns(organization);
sutProvider.GetDependency<ICollectionRepository>().GetManyByUserIdAsync(actingUserId).Returns(collectionDetails);
await sutProvider.Sut.HandleAsync(context);
Assert.True(context.HasFailed);
sutProvider.GetDependency<ICurrentContext>().ReceivedWithAnyArgs().GetOrganization(default);
await sutProvider.GetDependency<ICollectionRepository>().ReceivedWithAnyArgs()
.GetManyByUserIdAsync(default);
}
}

View File

@ -0,0 +1,54 @@
using System.Linq.Expressions;
using AutoFixture.Dsl;
namespace Bit.Core.Test.AutoFixture;
public static class AutoFixtureExtensions
{
/// <summary>
/// Registers that a writable Guid property should be assigned a random value that is derived from the given seed.
/// </summary>
/// <remarks>
/// This can be used to generate random Guids that are deterministic based on the seed and thus can be re-used for
/// different entities that share the same identifiers. e.g. Collections, CollectionUsers, and CollectionGroups can
/// all have the same Guids generate for their "collection id" properties.
/// </remarks>
/// <param name="composer"></param>
/// <param name="propertyPicker">The Guid property to register</param>
/// <param name="seed">The random seed to use for random Guid generation</param>
public static IPostprocessComposer<T> WithGuidFromSeed<T>(this IPostprocessComposer<T> composer, Expression<Func<T, Guid>> propertyPicker, int seed)
{
var rnd = new Random(seed);
return composer.With(propertyPicker, () =>
{
// While not as random/unique as Guid.NewGuid(), this is works well enough for testing purposes.
var bytes = new byte[16];
rnd.NextBytes(bytes);
return new Guid(bytes);
});
}
/// <summary>
/// Registers that a writable property should be assigned a value from the given list.
/// </summary>
/// <remarks>
/// The value will be assigned in the order that the list is enumerated. Values will wrap around to the beginning
/// should the end of the list be reached.
/// </remarks>
/// <param name="composer"></param>
/// <param name="propertyPicker"></param>
/// <param name="values"></param>
public static IPostprocessComposer<T> WithValueFromList<T, TValue>(
this IPostprocessComposer<T> composer,
Expression<Func<T, TValue>> propertyPicker,
ICollection<TValue> values)
{
var index = 0;
return composer.With(propertyPicker, () =>
{
var value = values.ElementAt(index);
index = (index + 1) % values.Count;
return value;
});
}
}

View File

@ -0,0 +1,37 @@
using System.Reflection;
using AutoFixture;
using AutoFixture.Xunit2;
using Bit.Core.Models.Data;
namespace Bit.Core.Test.AutoFixture;
public class CollectionAccessSelectionCustomization : ICustomization
{
public bool Manage { get; set; }
public CollectionAccessSelectionCustomization(bool manage)
{
Manage = manage;
}
public void Customize(IFixture fixture)
{
fixture.Customize<CollectionAccessSelection>(composer => composer
.With(o => o.Manage, Manage));
}
}
public class CollectionAccessSelectionCustomizeAttribute : CustomizeAttribute
{
private readonly bool _manage;
public CollectionAccessSelectionCustomizeAttribute(bool manage = false)
{
_manage = manage;
}
public override ICustomization GetCustomization(ParameterInfo parameter)
{
return new CollectionAccessSelectionCustomization(_manage);
}
}

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

@ -0,0 +1,273 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationCollections;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.Vault.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.OrganizationFeatures.OrganizationCollections;
[SutProviderCustomize]
public class BulkAddCollectionAccessCommandTests
{
[Theory, BitAutoData, CollectionCustomization]
public async Task AddAccessAsync_Success(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
Organization org,
ICollection<Collection> collections,
ICollection<OrganizationUser> organizationUsers,
ICollection<Group> groups,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
sutProvider.GetDependency<IGroupRepository>()
.GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
)
.Returns(groups);
var userAccessSelections = ToAccessSelection(collectionUsers);
var groupAccessSelections = ToAccessSelection(collectionGroups);
await sutProvider.Sut.AddAccessAsync(collections,
userAccessSelections,
groupAccessSelections
);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(userAccessSelections.Select(u => u.Id)))
);
await sutProvider.GetDependency<IGroupRepository>().Received().GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(groupAccessSelections.Select(g => g.Id)))
);
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateOrUpdateAccessForManyAsync(
org.Id,
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collections.Select(c => c.Id))),
userAccessSelections,
groupAccessSelections);
await sutProvider.GetDependency<IEventService>().Received().LogCollectionEventsAsync(
Arg.Is<IEnumerable<(Collection, EventType, DateTime?)>>(
events => events.All(e =>
collections.Contains(e.Item1) &&
e.Item2 == EventType.Collection_Updated &&
e.Item3.HasValue
)
)
);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_NoCollectionsProvided_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider)
{
var exception =
await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AddAccessAsync(null, null, null));
Assert.Contains("No collections were provided.", exception.Message);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIdsAsync(default);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetManyAsync(default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_NoCollection_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(Enumerable.Empty<Collection>().ToList(),
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("No collections were provided.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetManyAsync(default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_DifferentOrgs_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
ICollection<Collection> collections,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
collections.First().OrganizationId = Guid.NewGuid();
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("All collections must belong to the same organization.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetManyAsync(default);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_MissingUser_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
organizationUsers.RemoveAt(0);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more users do not exist.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_UserWrongOrg_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
organizationUsers.First().OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more users do not belong to the same organization as the collection being assigned.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetManyByManyIds(default);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_MissingGroup_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IList<Group> groups,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
groups.RemoveAt(0);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
sutProvider.GetDependency<IGroupRepository>()
.GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
)
.Returns(groups);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more groups do not exist.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().Received().GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
);
}
[Theory, BitAutoData, CollectionCustomization]
public async Task ValidateRequestAsync_GroupWrongOrg_Failure(SutProvider<BulkAddCollectionAccessCommand> sutProvider,
IList<Collection> collections,
IList<OrganizationUser> organizationUsers,
IList<Group> groups,
IEnumerable<CollectionUser> collectionUsers,
IEnumerable<CollectionGroup> collectionGroups)
{
groups.First().OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
)
.Returns(organizationUsers);
sutProvider.GetDependency<IGroupRepository>()
.GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
)
.Returns(groups);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.AddAccessAsync(collections,
ToAccessSelection(collectionUsers),
ToAccessSelection(collectionGroups)
));
Assert.Contains("One or more groups do not belong to the same organization as the collection being assigned.", exception.Message);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received().GetManyAsync(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionUsers.Select(u => u.OrganizationUserId)))
);
await sutProvider.GetDependency<IGroupRepository>().Received().GetManyByManyIds(
Arg.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(collectionGroups.Select(u => u.GroupId)))
);
}
private static ICollection<CollectionAccessSelection> ToAccessSelection(IEnumerable<CollectionUser> collectionUsers)
{
return collectionUsers.Select(cu => new CollectionAccessSelection
{
Id = cu.OrganizationUserId,
Manage = cu.Manage,
HidePasswords = cu.HidePasswords,
ReadOnly = cu.ReadOnly
}).ToList();
}
private static ICollection<CollectionAccessSelection> ToAccessSelection(IEnumerable<CollectionGroup> collectionGroups)
{
return collectionGroups.Select(cg => new CollectionAccessSelection
{
Id = cg.GroupId,
Manage = cg.Manage,
HidePasswords = cg.HidePasswords,
ReadOnly = cg.ReadOnly
}).ToList();
}
}

View File

@ -5,6 +5,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -18,23 +19,7 @@ namespace Bit.Core.Test.Services;
public class CollectionServiceTest
{
[Theory, BitAutoData]
public async Task SaveAsync_DefaultId_CreatesCollectionInTheRepository(Collection collection, Organization organization, SutProvider<CollectionService> sutProvider)
{
collection.Id = default;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var utcNow = DateTime.UtcNow;
await sutProvider.Sut.SaveAsync(collection);
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateAsync(collection, null, null);
await sutProvider.GetDependency<IEventService>().Received()
.LogCollectionEventAsync(collection, EventType.Collection_Created);
Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1));
Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1));
}
[Theory, BitAutoData]
public async Task SaveAsync_DefaultIdWithUsers_CreatesCollectionInTheRepository(Collection collection, Organization organization, IEnumerable<CollectionAccessSelection> users, SutProvider<CollectionService> sutProvider)
public async Task SaveAsync_DefaultIdWithUsers_CreatesCollectionInTheRepository(Collection collection, Organization organization, [CollectionAccessSelectionCustomize(true)] IEnumerable<CollectionAccessSelection> users, SutProvider<CollectionService> sutProvider)
{
collection.Id = default;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
@ -42,7 +27,9 @@ public class CollectionServiceTest
await sutProvider.Sut.SaveAsync(collection, null, users);
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateAsync(collection, null, users);
await sutProvider.GetDependency<ICollectionRepository>().Received()
.CreateAsync(collection, Arg.Is<List<CollectionAccessSelection>>(l => l == null),
Arg.Is<List<CollectionAccessSelection>>(l => l.Any(i => i.Manage == true)));
await sutProvider.GetDependency<IEventService>().Received()
.LogCollectionEventAsync(collection, EventType.Collection_Created);
Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1));
@ -51,7 +38,7 @@ public class CollectionServiceTest
[Theory, BitAutoData]
public async Task SaveAsync_DefaultIdWithGroupsAndUsers_CreateCollectionWithGroupsAndUsersInRepository(Collection collection,
IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users, Organization organization, SutProvider<CollectionService> sutProvider)
[CollectionAccessSelectionCustomize(true)] IEnumerable<CollectionAccessSelection> groups, IEnumerable<CollectionAccessSelection> users, Organization organization, SutProvider<CollectionService> sutProvider)
{
collection.Id = default;
organization.UseGroups = true;
@ -60,7 +47,9 @@ public class CollectionServiceTest
await sutProvider.Sut.SaveAsync(collection, groups, users);
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateAsync(collection, groups, users);
await sutProvider.GetDependency<ICollectionRepository>().Received()
.CreateAsync(collection, Arg.Is<List<CollectionAccessSelection>>(l => l.Any(i => i.Manage == true)),
Arg.Any<List<CollectionAccessSelection>>());
await sutProvider.GetDependency<IEventService>().Received()
.LogCollectionEventAsync(collection, EventType.Collection_Created);
Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1));
@ -68,15 +57,17 @@ public class CollectionServiceTest
}
[Theory, BitAutoData]
public async Task SaveAsync_NonDefaultId_ReplacesCollectionInRepository(Collection collection, Organization organization, SutProvider<CollectionService> sutProvider)
public async Task SaveAsync_NonDefaultId_ReplacesCollectionInRepository(Collection collection, Organization organization, [CollectionAccessSelectionCustomize(true)] IEnumerable<CollectionAccessSelection> users, SutProvider<CollectionService> sutProvider)
{
var creationDate = collection.CreationDate;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var utcNow = DateTime.UtcNow;
await sutProvider.Sut.SaveAsync(collection);
await sutProvider.Sut.SaveAsync(collection, null, users);
await sutProvider.GetDependency<ICollectionRepository>().Received().ReplaceAsync(collection, null, null);
await sutProvider.GetDependency<ICollectionRepository>().Received().ReplaceAsync(collection,
Arg.Is<List<CollectionAccessSelection>>(l => l == null),
Arg.Is<List<CollectionAccessSelection>>(l => l.Any(i => i.Manage == true)));
await sutProvider.GetDependency<IEventService>().Received()
.LogCollectionEventAsync(collection, EventType.Collection_Updated);
Assert.Equal(collection.CreationDate, creationDate);
@ -84,39 +75,20 @@ public class CollectionServiceTest
}
[Theory, BitAutoData]
public async Task SaveAsync_OrganizationNotUseGroup_CreateCollectionWithoutGroupsInRepository(Collection collection, IEnumerable<CollectionAccessSelection> groups,
public async Task SaveAsync_OrganizationNotUseGroup_CreateCollectionWithoutGroupsInRepository(Collection collection,
IEnumerable<CollectionAccessSelection> groups, [CollectionAccessSelectionCustomize(true)] IEnumerable<CollectionAccessSelection> users,
Organization organization, SutProvider<CollectionService> sutProvider)
{
collection.Id = default;
organization.UseGroups = false;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var utcNow = DateTime.UtcNow;
await sutProvider.Sut.SaveAsync(collection, groups);
await sutProvider.Sut.SaveAsync(collection, groups, users);
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateAsync(collection, null, null);
await sutProvider.GetDependency<IEventService>().Received()
.LogCollectionEventAsync(collection, EventType.Collection_Created);
Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1));
Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1));
}
[Theory, BitAutoData]
public async Task SaveAsync_DefaultIdWithUserId_UpdateUserInCollectionRepository(Collection collection,
Organization organization, OrganizationUser organizationUser, SutProvider<CollectionService> sutProvider)
{
collection.Id = default;
organizationUser.Status = OrganizationUserStatusType.Confirmed;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(organization.Id, organizationUser.Id)
.Returns(organizationUser);
var utcNow = DateTime.UtcNow;
await sutProvider.Sut.SaveAsync(collection, null, null, organizationUser.Id);
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateAsync(collection, null, null);
await sutProvider.GetDependency<IOrganizationUserRepository>().Received()
.GetByOrganizationAsync(organization.Id, organizationUser.Id);
await sutProvider.GetDependency<ICollectionRepository>().Received().UpdateUsersAsync(collection.Id, Arg.Any<List<CollectionAccessSelection>>());
await sutProvider.GetDependency<ICollectionRepository>().Received().CreateAsync(collection,
Arg.Is<List<CollectionAccessSelection>>(l => l == null),
Arg.Is<List<CollectionAccessSelection>>(l => l.Any(i => i.Manage == true)));
await sutProvider.GetDependency<IEventService>().Received()
.LogCollectionEventAsync(collection, EventType.Collection_Created);
Assert.True(collection.CreationDate - utcNow < TimeSpan.FromSeconds(1));
@ -135,14 +107,34 @@ public class CollectionServiceTest
}
[Theory, BitAutoData]
public async Task SaveAsync_ExceedsOrganizationMaxCollections_ThrowsBadRequest(Collection collection, Organization organization, SutProvider<CollectionService> sutProvider)
public async Task SaveAsync_NoManageAccess_ThrowsBadRequest(Collection collection, Organization organization,
[CollectionAccessSelectionCustomize] IEnumerable<CollectionAccessSelection> users, SutProvider<CollectionService> sutProvider)
{
collection.Id = default;
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));
Assert.Contains("At least one member or group must have can manage permission.", ex.Message);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default, default, default);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().ReplaceAsync(default);
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogCollectionEventAsync(default, default);
}
[Theory, BitAutoData]
public async Task SaveAsync_ExceedsOrganizationMaxCollections_ThrowsBadRequest(Collection collection,
Organization organization, [CollectionAccessSelectionCustomize(true)] IEnumerable<CollectionAccessSelection> users,
SutProvider<CollectionService> sutProvider)
{
collection.Id = default;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<ICollectionRepository>().GetCountByOrganizationIdAsync(organization.Id)
.Returns(organization.MaxCollections.Value);
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveAsync(collection));
var ex = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SaveAsync(collection, null, users));
Assert.Equal($@"You have reached the maximum number of collections ({organization.MaxCollections.Value}) for this organization.", ex.Message);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<ICollectionRepository>().DidNotReceiveWithAnyArgs().CreateAsync(default, default, default);

View File

@ -607,12 +607,19 @@ public class OrganizationServiceTests
currentContext.ManageSso(organization.Id).Returns(true);
currentContext.AccessEventLogs(organization.Id).Returns(true);
currentContext.AccessImportExport(organization.Id).Returns(true);
currentContext.CreateNewCollections(organization.Id).Returns(true);
currentContext.DeleteAnyCollection(organization.Id).Returns(true);
currentContext.DeleteAssignedCollections(organization.Id).Returns(true);
currentContext.EditAnyCollection(organization.Id).Returns(true);
currentContext.EditAssignedCollections(organization.Id).Returns(true);
currentContext.ManageResetPassword(organization.Id).Returns(true);
currentContext.GetOrganization(organization.Id)
.Returns(new CurrentContextOrganization()
{
Permissions = new Permissions
{
CreateNewCollections = true,
DeleteAnyCollection = true
}
});
await sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, invites);
@ -942,6 +949,14 @@ public class OrganizationServiceTests
currentContext.OrganizationCustom(savingUser.OrganizationId).Returns(true);
currentContext.ManageUsers(savingUser.OrganizationId).Returns(true);
currentContext.AccessReports(savingUser.OrganizationId).Returns(true);
currentContext.GetOrganization(savingUser.OrganizationId).Returns(
new CurrentContextOrganization()
{
Permissions = new Permissions
{
AccessReports = true
}
});
await sutProvider.Sut.SaveUserAsync(newUserData, savingUser.UserId, collections, groups);
}

View File

@ -0,0 +1,72 @@
using System.Security.Claims;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Xunit;
namespace Bit.Core.Test.Utilities;
public class BulkAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirementAsync_SingleResource_Success()
{
var handler = new TestBulkAuthorizationHandler();
var context = new AuthorizationHandlerContext(
new[] { new TestOperationRequirement() },
new ClaimsPrincipal(),
new TestResource());
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirementAsync_BulkResource_Success()
{
var handler = new TestBulkAuthorizationHandler();
var context = new AuthorizationHandlerContext(
new[] { new TestOperationRequirement() },
new ClaimsPrincipal(),
new[] { new TestResource(), new TestResource() });
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirementAsync_NoResources_Failure()
{
var handler = new TestBulkAuthorizationHandler();
var context = new AuthorizationHandlerContext(
new[] { new TestOperationRequirement() },
new ClaimsPrincipal(),
null);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirementAsync_WrongResourceType_Failure()
{
var handler = new TestBulkAuthorizationHandler();
var context = new AuthorizationHandlerContext(
new[] { new TestOperationRequirement() },
new ClaimsPrincipal(),
new object());
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private class TestOperationRequirement : OperationAuthorizationRequirement { }
private class TestResource { }
private class TestBulkAuthorizationHandler : BulkAuthorizationHandler<TestOperationRequirement, TestResource>
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
TestOperationRequirement requirement,
ICollection<TestResource> resources)
{
context.Succeed(requirement);
}
}
}

View File

@ -0,0 +1,56 @@
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Test.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
namespace Bit.Core.Test.Vault.AutoFixture;
public class CollectionCustomization : ICustomization
{
private const int _collectionIdSeed = 1;
private const int _userIdSeed = 2;
private const int _groupIdSeed = 3;
public void Customize(IFixture fixture)
{
var orgId = Guid.NewGuid();
fixture.Customize<Organization>(composer => composer
.With(o => o.Id, orgId));
fixture.Customize<CurrentContextOrganization>(composer => composer
.With(o => o.Id, orgId));
fixture.Customize<OrganizationUser>(composer => composer
.With(o => o.OrganizationId, orgId)
.WithGuidFromSeed(o => o.Id, _userIdSeed));
fixture.Customize<Collection>(composer => composer
.With(o => o.OrganizationId, orgId)
.WithGuidFromSeed(c => c.Id, _collectionIdSeed));
fixture.Customize<CollectionDetails>(composer => composer
.With(o => o.OrganizationId, orgId)
.WithGuidFromSeed(cd => cd.Id, _collectionIdSeed));
fixture.Customize<CollectionUser>(c => c
.WithGuidFromSeed(cu => cu.OrganizationUserId, _userIdSeed)
.WithGuidFromSeed(cu => cu.CollectionId, _collectionIdSeed));
fixture.Customize<Group>(composer => composer
.With(o => o.OrganizationId, orgId)
.WithGuidFromSeed(o => o.Id, _groupIdSeed));
fixture.Customize<CollectionGroup>(c => c
.WithGuidFromSeed(cu => cu.GroupId, _groupIdSeed)
.WithGuidFromSeed(cu => cu.CollectionId, _collectionIdSeed));
}
}
public class CollectionCustomizationAttribute : BitCustomizeAttribute
{
public override ICustomization GetCustomization() => new CollectionCustomization();
}

View File

@ -91,6 +91,7 @@ public class CipherRepositoryTests
Id = orgUser.Id,
HidePasswords = true,
ReadOnly = true,
Manage = true
},
});

View File

@ -0,0 +1,412 @@
--Dev cleanup: drop previous column name (never used in production but may be present on some QA instances)
IF COL_LENGTH('[dbo].[Organization]', 'LimitCollectionCdOwnerAdmin') IS NOT NULL
BEGIN
ALTER TABLE
[dbo].[Organization]
DROP COLUMN
[LimitCollectionCdOwnerAdmin]
END
GO
--Add column 'LimitCollectionCreationDeletion' to 'Organization' table
IF COL_LENGTH('[dbo].[Organization]', 'LimitCollectionCreationDeletion') IS NULL
BEGIN
ALTER TABLE
[dbo].[Organization]
ADD
[LimitCollectionCreationDeletion] BIT NOT NULL CONSTRAINT [DF_Organization_LimitCollectionCreationDeletion] DEFAULT (1)
END
GO
/**
ORGANIZATION STORED PROCEDURES
*/
--Alter `Organization_Create` sproc to include `LimitCollectionCreationDeletion` column and default value
CREATE OR ALTER PROCEDURE [dbo].[Organization_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@BusinessName NVARCHAR(50),
@BusinessAddress1 NVARCHAR(50),
@BusinessAddress2 NVARCHAR(50),
@BusinessAddress3 NVARCHAR(50),
@BusinessCountry VARCHAR(2),
@BusinessTaxNumber NVARCHAR(30),
@BillingEmail NVARCHAR(256),
@Plan NVARCHAR(50),
@PlanType TINYINT,
@Seats INT,
@MaxCollections SMALLINT,
@UsePolicies BIT,
@UseSso BIT,
@UseGroups BIT,
@UseDirectory BIT,
@UseEvents BIT,
@UseTotp BIT,
@Use2fa BIT,
@UseApi BIT,
@UseResetPassword BIT,
@SelfHost BIT,
@UsersGetPremium BIT,
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@Enabled BIT,
@LicenseKey VARCHAR(100),
@PublicKey VARCHAR(MAX),
@PrivateKey VARCHAR(MAX),
@TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0,
@Status TINYINT = 0,
@UsePasswordManager BIT = 1,
@SmSeats INT = null,
@SmServiceAccounts INT = null,
@MaxAutoscaleSmSeats INT= null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0,
@LimitCollectionCreationDeletion BIT = 1
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Organization]
(
[Id],
[Identifier],
[Name],
[BusinessName],
[BusinessAddress1],
[BusinessAddress2],
[BusinessAddress3],
[BusinessCountry],
[BusinessTaxNumber],
[BillingEmail],
[Plan],
[PlanType],
[Seats],
[MaxCollections],
[UsePolicies],
[UseSso],
[UseGroups],
[UseDirectory],
[UseEvents],
[UseTotp],
[Use2fa],
[UseApi],
[UseResetPassword],
[SelfHost],
[UsersGetPremium],
[Storage],
[MaxStorageGb],
[Gateway],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ReferenceData],
[Enabled],
[LicenseKey],
[PublicKey],
[PrivateKey],
[TwoFactorProviders],
[ExpirationDate],
[CreationDate],
[RevisionDate],
[OwnersNotifiedOfAutoscaling],
[MaxAutoscaleSeats],
[UseKeyConnector],
[UseScim],
[UseCustomPermissions],
[UseSecretsManager],
[Status],
[UsePasswordManager],
[SmSeats],
[SmServiceAccounts],
[MaxAutoscaleSmSeats],
[MaxAutoscaleSmServiceAccounts],
[SecretsManagerBeta],
[LimitCollectionCreationDeletion]
)
VALUES
(
@Id,
@Identifier,
@Name,
@BusinessName,
@BusinessAddress1,
@BusinessAddress2,
@BusinessAddress3,
@BusinessCountry,
@BusinessTaxNumber,
@BillingEmail,
@Plan,
@PlanType,
@Seats,
@MaxCollections,
@UsePolicies,
@UseSso,
@UseGroups,
@UseDirectory,
@UseEvents,
@UseTotp,
@Use2fa,
@UseApi,
@UseResetPassword,
@SelfHost,
@UsersGetPremium,
@Storage,
@MaxStorageGb,
@Gateway,
@GatewayCustomerId,
@GatewaySubscriptionId,
@ReferenceData,
@Enabled,
@LicenseKey,
@PublicKey,
@PrivateKey,
@TwoFactorProviders,
@ExpirationDate,
@CreationDate,
@RevisionDate,
@OwnersNotifiedOfAutoscaling,
@MaxAutoscaleSeats,
@UseKeyConnector,
@UseScim,
@UseCustomPermissions,
@UseSecretsManager,
@Status,
@UsePasswordManager,
@SmSeats,
@SmServiceAccounts,
@MaxAutoscaleSmSeats,
@MaxAutoscaleSmServiceAccounts,
@SecretsManagerBeta,
@LimitCollectionCreationDeletion
)
END
GO
--Alter `Organization_Update` sproc to include `LimitCollectionCreationDeletion` column
CREATE OR ALTER PROCEDURE [dbo].[Organization_Update]
@Id UNIQUEIDENTIFIER,
@Identifier NVARCHAR(50),
@Name NVARCHAR(50),
@BusinessName NVARCHAR(50),
@BusinessAddress1 NVARCHAR(50),
@BusinessAddress2 NVARCHAR(50),
@BusinessAddress3 NVARCHAR(50),
@BusinessCountry VARCHAR(2),
@BusinessTaxNumber NVARCHAR(30),
@BillingEmail NVARCHAR(256),
@Plan NVARCHAR(50),
@PlanType TINYINT,
@Seats INT,
@MaxCollections SMALLINT,
@UsePolicies BIT,
@UseSso BIT,
@UseGroups BIT,
@UseDirectory BIT,
@UseEvents BIT,
@UseTotp BIT,
@Use2fa BIT,
@UseApi BIT,
@UseResetPassword BIT,
@SelfHost BIT,
@UsersGetPremium BIT,
@Storage BIGINT,
@MaxStorageGb SMALLINT,
@Gateway TINYINT,
@GatewayCustomerId VARCHAR(50),
@GatewaySubscriptionId VARCHAR(50),
@ReferenceData VARCHAR(MAX),
@Enabled BIT,
@LicenseKey VARCHAR(100),
@PublicKey VARCHAR(MAX),
@PrivateKey VARCHAR(MAX),
@TwoFactorProviders NVARCHAR(MAX),
@ExpirationDate DATETIME2(7),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0,
@UseScim BIT = 0,
@UseCustomPermissions BIT = 0,
@UseSecretsManager BIT = 0,
@Status TINYINT = 0,
@UsePasswordManager BIT = 1,
@SmSeats INT = null,
@SmServiceAccounts INT = null,
@MaxAutoscaleSmSeats INT = null,
@MaxAutoscaleSmServiceAccounts INT = null,
@SecretsManagerBeta BIT = 0,
@LimitCollectionCreationDeletion BIT = 1
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Organization]
SET
[Identifier] = @Identifier,
[Name] = @Name,
[BusinessName] = @BusinessName,
[BusinessAddress1] = @BusinessAddress1,
[BusinessAddress2] = @BusinessAddress2,
[BusinessAddress3] = @BusinessAddress3,
[BusinessCountry] = @BusinessCountry,
[BusinessTaxNumber] = @BusinessTaxNumber,
[BillingEmail] = @BillingEmail,
[Plan] = @Plan,
[PlanType] = @PlanType,
[Seats] = @Seats,
[MaxCollections] = @MaxCollections,
[UsePolicies] = @UsePolicies,
[UseSso] = @UseSso,
[UseGroups] = @UseGroups,
[UseDirectory] = @UseDirectory,
[UseEvents] = @UseEvents,
[UseTotp] = @UseTotp,
[Use2fa] = @Use2fa,
[UseApi] = @UseApi,
[UseResetPassword] = @UseResetPassword,
[SelfHost] = @SelfHost,
[UsersGetPremium] = @UsersGetPremium,
[Storage] = @Storage,
[MaxStorageGb] = @MaxStorageGb,
[Gateway] = @Gateway,
[GatewayCustomerId] = @GatewayCustomerId,
[GatewaySubscriptionId] = @GatewaySubscriptionId,
[ReferenceData] = @ReferenceData,
[Enabled] = @Enabled,
[LicenseKey] = @LicenseKey,
[PublicKey] = @PublicKey,
[PrivateKey] = @PrivateKey,
[TwoFactorProviders] = @TwoFactorProviders,
[ExpirationDate] = @ExpirationDate,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
[MaxAutoscaleSeats] = @MaxAutoscaleSeats,
[UseKeyConnector] = @UseKeyConnector,
[UseScim] = @UseScim,
[UseCustomPermissions] = @UseCustomPermissions,
[UseSecretsManager] = @UseSecretsManager,
[Status] = @Status,
[UsePasswordManager] = @UsePasswordManager,
[SmSeats] = @SmSeats,
[SmServiceAccounts] = @SmServiceAccounts,
[MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats,
[MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts,
[SecretsManagerBeta] = @SecretsManagerBeta,
[LimitCollectionCreationDeletion] = @LimitCollectionCreationDeletion
WHERE
[Id] = @Id
END
GO
/**
ORGANIZATION VIEWS
*/
--Add 'LimitCollectionCreationDeletion` to OrganizationUserOrganizationDetailsView
CREATE OR ALTER VIEW [dbo].[OrganizationUserOrganizationDetailsView]
AS
SELECT
OU.[UserId],
OU.[OrganizationId],
O.[Name],
O.[Enabled],
O.[PlanType],
O.[UsePolicies],
O.[UseSso],
O.[UseKeyConnector],
O.[UseScim],
O.[UseGroups],
O.[UseDirectory],
O.[UseEvents],
O.[UseTotp],
O.[Use2fa],
O.[UseApi],
O.[UseResetPassword],
O.[SelfHost],
O.[UsersGetPremium],
O.[UseCustomPermissions],
O.[UseSecretsManager],
O.[Seats],
O.[MaxCollections],
O.[MaxStorageGb],
O.[Identifier],
OU.[Key],
OU.[ResetPasswordKey],
O.[PublicKey],
O.[PrivateKey],
OU.[Status],
OU.[Type],
SU.[ExternalId] SsoExternalId,
OU.[Permissions],
PO.[ProviderId],
P.[Name] ProviderName,
P.[Type] ProviderType,
SS.[Data] SsoConfig,
OS.[FriendlyName] FamilySponsorshipFriendlyName,
OS.[LastSyncDate] FamilySponsorshipLastSyncDate,
OS.[ToDelete] FamilySponsorshipToDelete,
OS.[ValidUntil] FamilySponsorshipValidUntil,
OU.[AccessSecretsManager],
O.[UsePasswordManager],
O.[SmSeats],
O.[SmServiceAccounts],
O.[LimitCollectionCreationDeletion]
FROM
[dbo].[OrganizationUser] OU
LEFT JOIN
[dbo].[Organization] O ON O.[Id] = OU.[OrganizationId]
LEFT JOIN
[dbo].[SsoUser] SU ON SU.[UserId] = OU.[UserId] AND SU.[OrganizationId] = OU.[OrganizationId]
LEFT JOIN
[dbo].[ProviderOrganization] PO ON PO.[OrganizationId] = O.[Id]
LEFT JOIN
[dbo].[Provider] P ON P.[Id] = PO.[ProviderId]
LEFT JOIN
[dbo].[SsoConfig] SS ON SS.[OrganizationId] = OU.[OrganizationId]
LEFT JOIN
[dbo].[OrganizationSponsorship] OS ON OS.[SponsoringOrganizationUserID] = OU.[Id]
GO
--Manually refresh OrganizationView
IF OBJECT_ID('[dbo].[OrganizationView]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[OrganizationView]';
END
GO
/**
PROVIDER VIEWS - not directly modified, but access Organization table
*/
--Manually refresh ProviderOrganizationOrganizationDetailsView
IF OBJECT_ID('[dbo].[ProviderOrganizationOrganizationDetailsView]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[ProviderOrganizationOrganizationDetailsView]';
END
GO
--Manually refresh ProviderUserProviderOrganizationDetailsView
IF OBJECT_ID('[dbo].[ProviderUserProviderOrganizationDetailsView]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[ProviderUserProviderOrganizationDetailsView]';
END
GO

View File

@ -0,0 +1,340 @@
/*
* Update existing write procedures to safely ignore any newly added columns to the CollectionUser and
* CollectionGroup tables (e.g. preparation for [Manage] in the next migration script). This is accomplished by
* explicitly listing the columns in the INSERT and UPDATE statements.
*/
-- Update INSERT statement to include explicit column list
CREATE OR ALTER PROCEDURE [dbo].[CollectionUser_UpdateUsers]
@CollectionId UNIQUEIDENTIFIER,
@Users AS [dbo].[SelectionReadOnlyArray] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @OrgId UNIQUEIDENTIFIER = (
SELECT TOP 1
[OrganizationId]
FROM
[dbo].[Collection]
WHERE
[Id] = @CollectionId
)
-- Update
UPDATE
[Target]
SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords]
FROM
[dbo].[CollectionUser] [Target]
INNER JOIN
@Users [Source] ON [Source].[Id] = [Target].[OrganizationUserId]
WHERE
[Target].[CollectionId] = @CollectionId
AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
)
-- Insert
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords]
)
SELECT
@CollectionId,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords]
FROM
@Users [Source]
INNER JOIN
[dbo].[OrganizationUser] OU ON [Source].[Id] = OU.[Id] AND OU.[OrganizationId] = @OrgId
WHERE
NOT EXISTS (
SELECT
1
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = @CollectionId
AND [OrganizationUserId] = [Source].[Id]
)
-- Delete
DELETE
CU
FROM
[dbo].[CollectionUser] CU
WHERE
CU.[CollectionId] = @CollectionId
AND NOT EXISTS (
SELECT
1
FROM
@Users
WHERE
[Id] = CU.[OrganizationUserId]
)
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @CollectionId, @OrgId
END
GO
-- Update INSERT statement to include explicit column list
CREATE OR ALTER PROCEDURE [dbo].[Group_UpdateWithCollections]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name NVARCHAR(100),
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Collections AS [dbo].[SelectionReadOnlyArray] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @AccessAll, @ExternalId, @CreationDate, @RevisionDate
;WITH [AvailableCollectionsCTE] AS(
SELECT
Id
FROM
[dbo].[Collection]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
@Collections AS [Source]
ON
[Target].[CollectionId] = [Source].[Id]
AND [Target].[GroupId] = @Id
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN
INSERT
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords]
)
VALUES
(
[Source].[Id],
@Id,
[Source].[ReadOnly],
[Source].[HidePasswords]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords]
WHEN NOT MATCHED BY SOURCE
AND [Target].[GroupId] = @Id THEN
DELETE
;
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
GO
-- Update INSERT statements to include explicit column list
CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Groups AS [dbo].[SelectionReadOnlyArray] READONLY,
@Users AS [dbo].[SelectionReadOnlyArray] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[Group]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
@Groups AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[GroupId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT -- With column list because a value for Manage is not being provided
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords]
)
VALUES
(
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
-- Users
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[OrganizationUser]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionUser] AS [Target]
USING
@Users AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[OrganizationUserId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords]
)
VALUES
(
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
END
GO
-- Update INSERT statement to include explicit column list
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@Email NVARCHAR(256),
@Key VARCHAR(MAX),
@Status SMALLINT,
@Type TINYINT,
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Permissions NVARCHAR(MAX),
@ResetPasswordKey VARCHAR(MAX),
@Collections AS [dbo].[SelectionReadOnlyArray] READONLY,
@AccessSecretsManager BIT = 0
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @AccessAll, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager
-- Update
UPDATE
[Target]
SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords]
FROM
[dbo].[CollectionUser] AS [Target]
INNER JOIN
@Collections AS [Source] ON [Source].[Id] = [Target].[CollectionId]
WHERE
[Target].[OrganizationUserId] = @Id
AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
)
-- Insert
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords]
)
SELECT
[Source].[Id],
@Id,
[Source].[ReadOnly],
[Source].[HidePasswords]
FROM
@Collections AS [Source]
INNER JOIN
[dbo].[Collection] C ON C.[Id] = [Source].[Id] AND C.[OrganizationId] = @OrganizationId
WHERE
NOT EXISTS (
SELECT
1
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = [Source].[Id]
AND [OrganizationUserId] = @Id
)
-- Delete
DELETE
CU
FROM
[dbo].[CollectionUser] CU
WHERE
CU.[OrganizationUserId] = @Id
AND NOT EXISTS (
SELECT
1
FROM
@Collections
WHERE
[Id] = CU.[CollectionId]
)
END
GO

View File

@ -0,0 +1,845 @@
/*
* Add Manage permission to collections and update associated stored procedures
*/
-- To allow the migration to be re-run, drop any of the V2 procedures as they depend on a new type
IF OBJECT_ID('[dbo].[CollectionUser_UpdateUsers_V2]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[CollectionUser_UpdateUsers_V2]
END
GO
IF OBJECT_ID('[dbo].[Group_UpdateWithCollections_V2]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Group_UpdateWithCollections_V2]
END
GO
IF OBJECT_ID('[dbo].[Collection_UpdateWithGroupsAndUsers_V2]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers_V2]
END
GO
IF OBJECT_ID('[dbo].[OrganizationUser_UpdateWithCollections_V2]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections_V2]
END
GO
IF OBJECT_ID('[dbo].[Group_CreateWithCollections_V2]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Group_CreateWithCollections_V2]
END
GO
IF OBJECT_ID('[dbo].[OrganizationUser_CreateWithCollections_V2]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_CreateWithCollections_V2]
END
GO
IF OBJECT_ID('[dbo].[Collection_CreateWithGroupsAndUsers_V2]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers_V2]
END
GO
-- Create a new CollectionAccessSelectionType with a new [Manage] column
IF TYPE_ID('[dbo].[CollectionAccessSelectionType]') IS NOT NULL
BEGIN
DROP TYPE [dbo].[CollectionAccessSelectionType]
END
GO
CREATE TYPE [dbo].[CollectionAccessSelectionType] AS TABLE (
[Id] UNIQUEIDENTIFIER NOT NULL,
[ReadOnly] BIT NOT NULL,
[HidePasswords] BIT NOT NULL,
[Manage] BIT NOT NULL);
GO
-- Add Manage Column
IF COL_LENGTH('[dbo].[CollectionUser]', 'Manage') IS NULL
BEGIN
ALTER TABLE [dbo].[CollectionUser] ADD [Manage] BIT NOT NULL CONSTRAINT D_CollectionUser_Manage DEFAULT (0);
END
GO
-- Add Manage Column
IF COL_LENGTH('[dbo].[CollectionGroup]', 'Manage') IS NULL
BEGIN
ALTER TABLE [dbo].[CollectionGroup] ADD [Manage] BIT NOT NULL CONSTRAINT D_CollectionGroup_Manage DEFAULT (0);
END
GO
-- BEGIN Update procedures that support backwards compatability in place
-- These procedures can be safely used by server in case of rollback and do not require V2 versions
-- Readonly query that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER PROCEDURE [dbo].[CollectionUser_ReadByCollectionId]
@CollectionId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
[OrganizationUserId] [Id],
[ReadOnly],
[HidePasswords],
[Manage]
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = @CollectionId
END
GO
-- Readonly query that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER PROCEDURE [dbo].[CollectionGroup_ReadByCollectionId]
@CollectionId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
[GroupId] [Id],
[ReadOnly],
[HidePasswords],
[Manage]
FROM
[dbo].[CollectionGroup]
WHERE
[CollectionId] = @CollectionId
END
GO
-- Readonly query that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUserUserDetails_ReadWithCollectionsById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
EXEC [OrganizationUserUserDetails_ReadById] @Id
SELECT
CU.[CollectionId] Id,
CU.[ReadOnly],
CU.[HidePasswords],
CU.[Manage]
FROM
[dbo].[OrganizationUser] OU
INNER JOIN
[dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[OrganizationUserId] = [OU].[Id]
WHERE
[OrganizationUserId] = @Id
END
GO
-- Readonly function that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER FUNCTION [dbo].[UserCollectionDetails](@UserId UNIQUEIDENTIFIER)
RETURNS TABLE
AS RETURN
SELECT
C.*,
CASE
WHEN
OU.[AccessAll] = 1
OR G.[AccessAll] = 1
OR COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0
THEN 0
ELSE 1
END [ReadOnly],
CASE
WHEN
OU.[AccessAll] = 1
OR G.[AccessAll] = 1
OR COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0
THEN 0
ELSE 1
END [HidePasswords],
CASE
WHEN
OU.[AccessAll] = 1
OR G.[AccessAll] = 1
OR COALESCE(CU.[Manage], CG.[Manage], 0) = 0
THEN 0
ELSE 1
END [Manage]
FROM
[dbo].[CollectionView] C
INNER JOIN
[dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId]
INNER JOIN
[dbo].[Organization] O ON O.[Id] = C.[OrganizationId]
LEFT JOIN
[dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND OU.[AccessAll] = 0 AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON G.[AccessAll] = 0 AND CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
WHERE
OU.[UserId] = @UserId
AND OU.[Status] = 2 -- 2 = Confirmed
AND O.[Enabled] = 1
AND (
OU.[AccessAll] = 1
OR CU.[CollectionId] IS NOT NULL
OR G.[AccessAll] = 1
OR CG.[CollectionId] IS NOT NULL
)
GO
-- Readonly query that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByIdUserId]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
Id,
OrganizationId,
[Name],
CreationDate,
RevisionDate,
ExternalId,
MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage]
FROM
[dbo].[UserCollectionDetails](@UserId)
WHERE
[Id] = @Id
GROUP BY
Id,
OrganizationId,
[Name],
CreationDate,
RevisionDate,
ExternalId
END
GO
-- Readonly query that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadByUserId]
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
Id,
OrganizationId,
[Name],
CreationDate,
RevisionDate,
ExternalId,
MIN([ReadOnly]) AS [ReadOnly],
MIN([HidePasswords]) AS [HidePasswords],
MIN([Manage]) AS [Manage]
FROM
[dbo].[UserCollectionDetails](@UserId)
GROUP BY
Id,
OrganizationId,
[Name],
CreationDate,
RevisionDate,
ExternalId
END
GO
-- Readonly query that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER PROCEDURE [dbo].[Collection_ReadWithGroupsAndUsersByUserId]
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
DECLARE @TempUserCollections TABLE(
Id UNIQUEIDENTIFIER,
OrganizationId UNIQUEIDENTIFIER,
Name VARCHAR(MAX),
CreationDate DATETIME2(7),
RevisionDate DATETIME2(7),
ExternalId NVARCHAR(300),
ReadOnly BIT,
HidePasswords BIT,
Manage BIT)
INSERT INTO @TempUserCollections EXEC [dbo].[Collection_ReadByUserId] @UserId
SELECT
*
FROM
@TempUserCollections C
SELECT
CG.*
FROM
[dbo].[CollectionGroup] CG
INNER JOIN
@TempUserCollections C ON C.[Id] = CG.[CollectionId]
SELECT
CU.*
FROM
[dbo].[CollectionUser] CU
INNER JOIN
@TempUserCollections C ON C.[Id] = CU.[CollectionId]
END
GO
-- Readonly query that adds [Manage] column to result, safely ignored by rolled back server
CREATE OR ALTER PROCEDURE [dbo].[Group_ReadWithCollectionsById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Group_ReadById] @Id
SELECT
[CollectionId] [Id],
[ReadOnly],
[HidePasswords],
[Manage]
FROM
[dbo].[CollectionGroup]
WHERE
[GroupId] = @Id
END
GO
-- END Update procedures that support backwards compatability in place
-- BEGIN Create V2 of existing procedures to support new [Manage] column and new CollectionAccessSelectionType
CREATE OR ALTER PROCEDURE [dbo].[CollectionUser_UpdateUsers_V2]
@CollectionId UNIQUEIDENTIFIER,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @OrgId UNIQUEIDENTIFIER = (
SELECT TOP 1
[OrganizationId]
FROM
[dbo].[Collection]
WHERE
[Id] = @CollectionId
)
-- Update
UPDATE
[Target]
SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
FROM
[dbo].[CollectionUser] [Target]
INNER JOIN
@Users [Source] ON [Source].[Id] = [Target].[OrganizationUserId]
WHERE
[Target].[CollectionId] = @CollectionId
AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
)
-- Insert
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@CollectionId,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
FROM
@Users [Source]
INNER JOIN
[dbo].[OrganizationUser] OU ON [Source].[Id] = OU.[Id] AND OU.[OrganizationId] = @OrgId
WHERE
NOT EXISTS (
SELECT
1
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = @CollectionId
AND [OrganizationUserId] = [Source].[Id]
)
-- Delete
DELETE
CU
FROM
[dbo].[CollectionUser] CU
WHERE
CU.[CollectionId] = @CollectionId
AND NOT EXISTS (
SELECT
1
FROM
@Users
WHERE
[Id] = CU.[OrganizationUserId]
)
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @CollectionId, @OrgId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Group_UpdateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name NVARCHAR(100),
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @AccessAll, @ExternalId, @CreationDate, @RevisionDate
;WITH [AvailableCollectionsCTE] AS(
SELECT
Id
FROM
[dbo].[Collection]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
@Collections AS [Source]
ON
[Target].[CollectionId] = [Source].[Id]
AND [Target].[GroupId] = @Id
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN
INSERT
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
[Source].[Id],
@Id,
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[GroupId] = @Id THEN
DELETE
;
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[Group]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
@Groups AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[GroupId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT -- Add explicit column list
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
-- Users
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[OrganizationUser]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionUser] AS [Target]
USING
@Users AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[OrganizationUserId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@Email NVARCHAR(256),
@Key VARCHAR(MAX),
@Status SMALLINT,
@Type TINYINT,
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Permissions NVARCHAR(MAX),
@ResetPasswordKey VARCHAR(MAX),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY,
@AccessSecretsManager BIT = 0
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @AccessAll, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager
-- Update
UPDATE
[Target]
SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
FROM
[dbo].[CollectionUser] AS [Target]
INNER JOIN
@Collections AS [Source] ON [Source].[Id] = [Target].[CollectionId]
WHERE
[Target].[OrganizationUserId] = @Id
AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
)
-- Insert
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
[Source].[Id],
@Id,
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
FROM
@Collections AS [Source]
INNER JOIN
[dbo].[Collection] C ON C.[Id] = [Source].[Id] AND C.[OrganizationId] = @OrganizationId
WHERE
NOT EXISTS (
SELECT
1
FROM
[dbo].[CollectionUser]
WHERE
[CollectionId] = [Source].[Id]
AND [OrganizationUserId] = @Id
)
-- Delete
DELETE
CU
FROM
[dbo].[CollectionUser] CU
WHERE
CU.[OrganizationUserId] = @Id
AND NOT EXISTS (
SELECT
1
FROM
@Collections
WHERE
[Id] = CU.[CollectionId]
)
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Group_CreateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name NVARCHAR(100),
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Group_Create] @Id, @OrganizationId, @Name, @AccessAll, @ExternalId, @CreationDate, @RevisionDate
;WITH [AvailableCollectionsCTE] AS(
SELECT
[Id]
FROM
[dbo].[Collection]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionGroup]
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
[Id],
@Id,
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Collections
WHERE
[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE])
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
GO
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateWithCollections_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@Email NVARCHAR(256),
@Key VARCHAR(MAX),
@Status SMALLINT,
@Type TINYINT,
@AccessAll BIT,
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Permissions NVARCHAR(MAX),
@ResetPasswordKey VARCHAR(MAX),
@Collections AS [dbo].[CollectionAccessSelectionType] READONLY,
@AccessSecretsManager BIT = 0
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[OrganizationUser_Create] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @AccessAll, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager
;WITH [AvailableCollectionsCTE] AS(
SELECT
[Id]
FROM
[dbo].[Collection]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
[Id],
@Id,
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Collections
WHERE
[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE])
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers_V2]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Collection_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
;WITH [AvailableGroupsCTE] AS(
SELECT
[Id]
FROM
[dbo].[Group]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionGroup]
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@Id,
[Id],
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Groups
WHERE
[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE])
-- Users
;WITH [AvailableUsersCTE] AS(
SELECT
[Id]
FROM
[dbo].[OrganizationUser]
WHERE
[OrganizationId] = @OrganizationId
)
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@Id,
[Id],
[ReadOnly],
[HidePasswords],
[Manage]
FROM
@Users
WHERE
[Id] IN (SELECT [Id] FROM [AvailableUsersCTE])
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
END
GO

View File

@ -0,0 +1,151 @@
CREATE OR ALTER PROCEDURE [dbo].[User_BumpAccountRevisionDateByCollectionIds]
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
UPDATE
U
SET
U.[AccountRevisionDate] = GETUTCDATE()
FROM
[dbo].[User] U
INNER JOIN
[dbo].[Collection] C ON C.[Id] IN (SELECT [Id] FROM @CollectionIds)
INNER JOIN
[dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id]
LEFT JOIN
[dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] = C.[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND OU.[AccessAll] = 0 AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON G.[AccessAll] = 0 AND CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = C.[Id]
WHERE
OU.[OrganizationId] = @OrganizationId
AND OU.[Status] = 2 -- 2 = Confirmed
AND (
CU.[CollectionId] IS NOT NULL
OR CG.[CollectionId] IS NOT NULL
OR OU.[AccessAll] = 1
OR G.[AccessAll] = 1
)
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany]
@OrganizationId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY,
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
-- Groups
;WITH [NewCollectionGroups] AS (
SELECT
cId.[Id] AS [CollectionId],
cg.[Id] AS [GroupId],
cg.[ReadOnly],
cg.[HidePasswords],
cg.[Manage]
FROM
@Groups AS cg
CROSS JOIN -- Create a CollectionGroup record for every CollectionId
@CollectionIds cId
INNER JOIN
[dbo].[Group] g ON cg.[Id] = g.[Id]
WHERE
g.[OrganizationId] = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] as [Target]
USING
[NewCollectionGroups] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[GroupId] = [Source].[GroupId]
-- Update the target if any values are different from the source
WHEN MATCHED AND EXISTS(
SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage]
EXCEPT
SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage]
) THEN UPDATE SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY TARGET
THEN INSERT
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
[Source].[CollectionId],
[Source].[GroupId],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
);
-- Users
;WITH [NewCollectionUsers] AS (
SELECT
cId.[Id] AS [CollectionId],
cu.[Id] AS [OrganizationUserId],
cu.[ReadOnly],
cu.[HidePasswords],
cu.[Manage]
FROM
@Users AS cu
CROSS JOIN -- Create a CollectionUser record for every CollectionId
@CollectionIds cId
INNER JOIN
[dbo].[OrganizationUser] u ON cu.[Id] = u.[Id]
WHERE
u.[OrganizationId] = @OrganizationId
)
MERGE
[dbo].[CollectionUser] as [Target]
USING
[NewCollectionUsers] AS [Source]
ON
[Target].[CollectionId] = [Source].[CollectionId]
AND [Target].[OrganizationUserId] = [Source].[OrganizationUserId]
-- Update the target if any values are different from the source
WHEN MATCHED AND EXISTS(
SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage]
EXCEPT
SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage]
) THEN UPDATE SET
[Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY TARGET
THEN INSERT
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
[Source].[CollectionId],
[Source].[OrganizationUserId],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
);
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId
END
GO

View File

@ -0,0 +1,50 @@
-- Remove old stored procedures and SelectionReadOnlyArray for Flexible Collections
-- They have been superseded via their respective _V2 variants and the CollectionAccessSelectionType
IF OBJECT_ID('[dbo].[CollectionUser_UpdateUsers]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[CollectionUser_UpdateUsers]
END
GO
IF OBJECT_ID('[dbo].[Group_UpdateWithCollections]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Group_UpdateWithCollections]
END
GO
IF OBJECT_ID('[dbo].[Collection_UpdateWithGroupsAndUsers]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers]
END
GO
IF OBJECT_ID('[dbo].[OrganizationUser_UpdateWithCollections]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections]
END
GO
IF OBJECT_ID('[dbo].[Group_CreateWithCollections]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Group_CreateWithCollections]
END
GO
IF OBJECT_ID('[dbo].[OrganizationUser_CreateWithCollections]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_CreateWithCollections]
END
GO
IF OBJECT_ID('[dbo].[Collection_CreateWithGroupsAndUsers]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Collection_CreateWithGroupsAndUsers]
END
GO
IF TYPE_ID('[dbo].[SelectionReadOnlyArray]') IS NOT NULL
BEGIN
DROP TYPE [dbo].[SelectionReadOnlyArray]
END
GO

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class LimitCollectionCreateDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "LimitCollectionCreationDeletion",
table: "Organization",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LimitCollectionCreationDeletion",
table: "Organization");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class _20230711_00_CollectionManagePermissionsql : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Manage",
table: "CollectionUsers",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Manage",
table: "CollectionGroups",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Manage",
table: "CollectionUsers");
migrationBuilder.DropColumn(
name: "Manage",
table: "CollectionGroups");
}
}

View File

@ -284,6 +284,9 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<bool>("HidePasswords")
.HasColumnType("tinyint(1)");
b.Property<bool>("Manage")
.HasColumnType("tinyint(1)");
b.Property<bool>("ReadOnly")
.HasColumnType("tinyint(1)");
@ -305,6 +308,9 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<bool>("HidePasswords")
.HasColumnType("tinyint(1)");
b.Property<bool>("Manage")
.HasColumnType("tinyint(1)");
b.Property<bool>("ReadOnly")
.HasColumnType("tinyint(1)");
@ -564,6 +570,10 @@ namespace Bit.MySqlMigrations.Migrations
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<bool>("LimitCollectionCreationDeletion")
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<int?>("MaxAutoscaleSeats")
.HasColumnType("int");

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class _20230711_00_CollectionManagePermissionsql : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Manage",
table: "CollectionUsers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Manage",
table: "CollectionGroups",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Manage",
table: "CollectionUsers");
migrationBuilder.DropColumn(
name: "Manage",
table: "CollectionGroups");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class LimitCollectionCreateDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "LimitCollectionCreationDeletion",
table: "Organization",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LimitCollectionCreationDeletion",
table: "Organization");
}
}

View File

@ -293,6 +293,9 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<bool>("HidePasswords")
.HasColumnType("boolean");
b.Property<bool>("Manage")
.HasColumnType("boolean");
b.Property<bool>("ReadOnly")
.HasColumnType("boolean");
@ -314,6 +317,9 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<bool>("HidePasswords")
.HasColumnType("boolean");
b.Property<bool>("Manage")
.HasColumnType("boolean");
b.Property<bool>("ReadOnly")
.HasColumnType("boolean");
@ -574,6 +580,10 @@ namespace Bit.PostgresMigrations.Migrations
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("LimitCollectionCreationDeletion")
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<int?>("MaxAutoscaleSeats")
.HasColumnType("integer");

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More