diff --git a/Directory.Build.props b/Directory.Build.props index 6b5f4f3d69..b130621fa8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2024.3.0 + 2024.3.1 Bit.$(MSBuildProjectName) enable diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index a478f6e78d..9cc62490e9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -94,8 +94,11 @@ public class OrganizationUsersController : Controller response.Type = GetFlexibleCollectionsUserType(response.Type, response.Permissions); // Set 'Edit/Delete Assigned Collections' custom permissions to false - response.Permissions.EditAssignedCollections = false; - response.Permissions.DeleteAssignedCollections = false; + if (response.Permissions is not null) + { + response.Permissions.EditAssignedCollections = false; + response.Permissions.DeleteAssignedCollections = false; + } } if (includeGroups) @@ -552,8 +555,11 @@ public class OrganizationUsersController : Controller orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions); // Set 'Edit/Delete Assigned Collections' custom permissions to false - orgUser.Permissions.EditAssignedCollections = false; - orgUser.Permissions.DeleteAssignedCollections = false; + if (orgUser.Permissions is not null) + { + orgUser.Permissions.EditAssignedCollections = false; + orgUser.Permissions.DeleteAssignedCollections = false; + } return orgUser; }); @@ -565,7 +571,7 @@ public class OrganizationUsersController : Controller private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions) { // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User - if (type == OrganizationUserType.Custom) + if (type == OrganizationUserType.Custom && permissions is not null) { if ((permissions.EditAssignedCollections || permissions.DeleteAssignedCollections) && permissions is diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 8fa41a2f5f..55c1d9cb19 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -74,7 +74,7 @@ public class ProfileOrganizationResponseModel : ResponseModel if (FlexibleCollections) { // Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User - if (Type == OrganizationUserType.Custom) + if (Type == OrganizationUserType.Custom && Permissions is not null) { if ((Permissions.EditAssignedCollections || Permissions.DeleteAssignedCollections) && Permissions is @@ -98,8 +98,11 @@ public class ProfileOrganizationResponseModel : ResponseModel } // Set 'Edit/Delete Assigned Collections' custom permissions to false - Permissions.EditAssignedCollections = false; - Permissions.DeleteAssignedCollections = false; + if (Permissions is not null) + { + Permissions.EditAssignedCollections = false; + Permissions.DeleteAssignedCollections = false; + } } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 90e7d2ed17..80a453dfc4 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -45,6 +45,7 @@ public class CiphersController : Controller private readonly IFeatureService _featureService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections); @@ -61,7 +62,8 @@ public class CiphersController : Controller GlobalSettings globalSettings, IFeatureService featureService, IOrganizationCiphersQuery organizationCiphersQuery, - IApplicationCacheService applicationCacheService) + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; @@ -75,6 +77,7 @@ public class CiphersController : Controller _featureService = featureService; _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; } [HttpGet("{id}")] @@ -342,6 +345,45 @@ public class CiphersController : Controller return false; } + /// + /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 + /// + private async Task CanEditAllCiphersAsync(Guid organizationId) + { + var org = _currentContext.GetOrganization(organizationId); + + // If not using V1, owners, admins, and users with EditAnyCollection permissions, and providers can always edit all ciphers + if (!await UseFlexibleCollectionsV1Async(organizationId)) + { + return org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true } || + await _currentContext.ProviderUserForOrgAsync(organizationId); + } + + // Custom users with EditAnyCollection permissions can always edit all ciphers + if (org is { Type: OrganizationUserType.Custom, Permissions.EditAnyCollection: true }) + { + return true; + } + + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + + // Owners/Admins can only edit all ciphers if the organization has the setting enabled + if (orgAbility is { AllowAdminAccessToAllCollectionItems: true } && org is + { Type: OrganizationUserType.Admin or OrganizationUserType.Owner }) + { + return true; + } + + // Provider users can edit all ciphers in V1 (to change later) + if (await _currentContext.ProviderUserForOrgAsync(organizationId)) + { + return true; + } + + return false; + } + /// /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 /// @@ -388,6 +430,97 @@ public class CiphersController : Controller return false; } + /// + /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 + /// + private async Task CanEditCiphersAsync(Guid organizationId, IEnumerable cipherIds) + { + // If the user can edit all ciphers for the organization, just check they all belong to the org + if (await CanEditAllCiphersAsync(organizationId)) + { + // TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org + var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id); + + // Ensure all requested ciphers are in orgCiphers + if (cipherIds.Any(c => !orgCiphers.ContainsKey(c))) + { + return false; + } + + return true; + } + + // The user cannot access any ciphers for the organization, we're done + if (!await CanAccessOrganizationCiphersAsync(organizationId)) + { + return false; + } + + var userId = _userService.GetProperUserId(User).Value; + // Select all editable ciphers for this user belonging to the organization + var editableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(userId, true)) + .Where(c => c.OrganizationId == organizationId && c.UserId == null && c.Edit).ToList(); + + // Special case for unassigned ciphers + if (await CanAccessUnassignedCiphersAsync(organizationId)) + { + var unassignedCiphers = + (await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync( + organizationId)); + + // Users that can access unassigned ciphers can also edit them + editableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Edit = true })); + } + + var editableOrgCiphers = editableOrgCipherList + .ToDictionary(c => c.Id); + + if (cipherIds.Any(c => !editableOrgCiphers.ContainsKey(c))) + { + return false; + } + + return true; + } + + /// + /// TODO: Move this to its own authorization handler or equivalent service - AC-2062 + /// This likely belongs to the BulkCollectionAuthorizationHandler + /// + private async Task CanEditItemsInCollections(Guid organizationId, IEnumerable collectionIds) + { + if (await CanEditAllCiphersAsync(organizationId)) + { + // TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org + var orgCollections = (await _collectionRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id); + + // Ensure all requested collections are in orgCollections + if (collectionIds.Any(c => !orgCollections.ContainsKey(c))) + { + return false; + } + + return true; + } + + if (!await CanAccessOrganizationCiphersAsync(organizationId)) + { + return false; + } + + var userId = _userService.GetProperUserId(User).Value; + var editableCollections = (await _collectionRepository.GetManyByUserIdAsync(userId, true)) + .Where(c => c.OrganizationId == organizationId && !c.ReadOnly) + .ToDictionary(c => c.Id); + + if (collectionIds.Any(c => !editableCollections.ContainsKey(c))) + { + return false; + } + + return true; + } + [HttpPut("{id}/partial")] [HttpPost("{id}/partial")] public async Task PutPartial(Guid id, [FromBody] CipherPartialRequestModel model) @@ -457,6 +590,33 @@ public class CiphersController : Controller model.CollectionIds.Select(c => new Guid(c)), userId, true); } + [HttpPost("bulk-collections")] + public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) + { + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(model.OrganizationId); + + // Only available for organizations with flexible collections + if (orgAbility is null or { FlexibleCollections: false }) + { + throw new NotFoundException(); + } + + if (!await CanEditCiphersAsync(model.OrganizationId, model.CipherIds) || + !await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds)) + { + throw new NotFoundException(); + } + + if (model.RemoveCollections) + { + await _collectionCipherRepository.RemoveCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds); + } + else + { + await _collectionCipherRepository.AddCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds); + } + } + [HttpDelete("{id}")] [HttpPost("{id}/delete")] public async Task Delete(Guid id) diff --git a/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs new file mode 100644 index 0000000000..54d67995d2 --- /dev/null +++ b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs @@ -0,0 +1,15 @@ +namespace Bit.Api.Vault.Models.Request; + +public class CipherBulkUpdateCollectionsRequestModel +{ + public Guid OrganizationId { get; set; } + + public IEnumerable CipherIds { get; set; } + + public IEnumerable CollectionIds { get; set; } + + /// + /// If true, the collections will be removed from the ciphers. Otherwise, they will be added. + /// + public bool RemoveCollections { get; set; } +} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index dcefd6256c..742b4a2cb6 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1277,7 +1277,7 @@ public class OrganizationService : IOrganizationService orgUser.Email = null; await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email); + await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); succeededUsers.Add(orgUser); result.Add(Tuple.Create(orgUser, "")); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 457b47d458..e7685891ad 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -147,7 +147,8 @@ public static class FeatureFlagKeys { { TrustedDeviceEncryption, "true" }, { Fido2VaultCredentials, "true" }, - { DuoRedirect, "true" } + { DuoRedirect, "true" }, + { FlexibleCollectionsSignup, "true" } }; } } diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index 0fc65d91ef..f34f414ce8 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -22,14 +22,18 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel return new OrganizationUserInvitedViewModel { TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ", - TitleSecondBold = orgInvitesInfo.IsFreeOrg ? string.Empty : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), + TitleSecondBold = + orgInvitesInfo.IsFreeOrg + ? string.Empty + : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!", OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status, Email = WebUtility.UrlEncode(orgUser.Email), OrganizationId = orgUser.OrganizationId.ToString(), OrganizationUserId = orgUser.Id.ToString(), Token = WebUtility.UrlEncode(expiringToken.Token), - ExpirationDate = $"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC", + ExpirationDate = + $"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC", OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName), WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash, SiteName = globalSettings.SiteName, diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 0023484bf9..bd198ded3c 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -279,6 +279,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand if (success) { + var upgradePath = GetUpgradePath(existingPlan.Product, newPlan.Product); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext) { @@ -287,6 +288,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand OldPlanName = existingPlan.Name, OldPlanType = existingPlan.Type, Seats = organization.Seats, + SignupInitiationPath = "Upgrade in-product", + PlanUpgradePath = upgradePath, Storage = organization.MaxStorageGb, // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 }); @@ -338,4 +341,26 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand { return await _organizationRepository.GetByIdAsync(id); } + + private static string GetUpgradePath(ProductType oldProductType, ProductType newProductType) + { + var oldDescription = _upgradePath.TryGetValue(oldProductType, out var description) + ? description + : $"{oldProductType:G}"; + + var newDescription = _upgradePath.TryGetValue(newProductType, out description) + ? description + : $"{newProductType:G}"; + + return $"{oldDescription} → {newDescription}"; + } + + private static readonly Dictionary _upgradePath = new() + { + [ProductType.Free] = "2-person org", + [ProductType.Families] = "Families", + [ProductType.TeamsStarter] = "Teams Starter", + [ProductType.Teams] = "Teams", + [ProductType.Enterprise] = "Enterprise" + }; } diff --git a/src/Core/Repositories/ICollectionCipherRepository.cs b/src/Core/Repositories/ICollectionCipherRepository.cs index 5bf00c614b..aa38814398 100644 --- a/src/Core/Repositories/ICollectionCipherRepository.cs +++ b/src/Core/Repositories/ICollectionCipherRepository.cs @@ -11,4 +11,22 @@ public interface ICollectionCipherRepository Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable collectionIds); Task UpdateCollectionsForCiphersAsync(IEnumerable cipherIds, Guid userId, Guid organizationId, IEnumerable collectionIds, bool useFlexibleCollections); + + /// + /// Add the specified collections to the specified ciphers. If a cipher already belongs to a requested collection, + /// no action is taken. + /// + /// + /// This method does not perform any authorization checks. + /// + Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, IEnumerable collectionIds); + + /// + /// Remove the specified collections from the specified ciphers. If a cipher does not belong to a requested collection, + /// no action is taken. + /// + /// + /// This method does not perform any authorization checks. + /// + Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, IEnumerable collectionIds); } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 9300e1b131..30c28ddd73 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -24,8 +24,8 @@ public interface IMailService Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo); Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable ownerEmails); Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable ownerEmails); - Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails); - Task SendOrganizationConfirmedEmailAsync(string organizationName, string email); + Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails, bool hasAccessSecretsManager = false); + Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false); Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email); Task SendPasswordlessSignInAsync(string returnUrl, string token, string email); Task SendInvoiceUpcoming( diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 19407af0d1..93f427c362 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -173,7 +173,7 @@ public class HandlebarsMailService : IMailService } public async Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, - IEnumerable adminEmails) + IEnumerable adminEmails, bool hasAccessSecretsManager = false) { var message = CreateDefaultMessage($"Action Required: {userIdentifier} Needs to Be Confirmed", adminEmails); var model = new OrganizationUserAcceptedViewModel @@ -189,7 +189,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendOrganizationConfirmedEmailAsync(string organizationName, string email) + public async Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false) { var message = CreateDefaultMessage($"You Have Been Confirmed To {organizationName}", email); var model = new OrganizationUserConfirmedViewModel @@ -198,7 +198,9 @@ public class HandlebarsMailService : IMailService TitleSecondBold = CoreHelpers.SanitizeForEmail(organizationName, false), TitleThird = "!", OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false), - WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + WebVaultUrl = hasAccessSecretsManager + ? _globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct + : _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName }; await AddMessageContentAsync(message, "OrganizationUserConfirmed", model); @@ -216,6 +218,7 @@ public class HandlebarsMailService : IMailService var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair => { + var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo( orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings); return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); @@ -256,7 +259,7 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage("Welcome to Bitwarden!", userEmail); var model = new BaseMailModel { - WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct, SiteName = _globalSettings.SiteName }; await AddMessageContentAsync(message, "TrialInitiation", model); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index b6dbdc6acb..4bf15488c4 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -43,12 +43,13 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails) + public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, + IEnumerable adminEmails, bool hasAccessSecretsManager = false) { return Task.FromResult(0); } - public Task SendOrganizationConfirmedEmailAsync(string organizationName, string email) + public Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false) { return Task.FromResult(0); } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 0739a4c81a..84037a0a1c 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -146,6 +146,7 @@ public class GlobalSettings : IGlobalSettings public string CloudRegion { get; set; } public string Vault { get; set; } public string VaultWithHash => $"{Vault}/#"; + public string VaultWithHashAndSecretManagerProduct => $"{Vault}/#/sm"; public string Api { diff --git a/src/Core/Settings/IBaseServiceUriSettings.cs b/src/Core/Settings/IBaseServiceUriSettings.cs index 0acb504a2b..0c2ed15f66 100644 --- a/src/Core/Settings/IBaseServiceUriSettings.cs +++ b/src/Core/Settings/IBaseServiceUriSettings.cs @@ -6,6 +6,7 @@ public interface IBaseServiceUriSettings string CloudRegion { get; set; } string Vault { get; set; } string VaultWithHash { get; } + string VaultWithHashAndSecretManagerProduct { get; } string Api { get; set; } public string Identity { get; set; } public string Admin { get; set; } diff --git a/src/Core/Tools/Models/Business/ReferenceEvent.cs b/src/Core/Tools/Models/Business/ReferenceEvent.cs index 03a0b3e1da..5e68b8cce7 100644 --- a/src/Core/Tools/Models/Business/ReferenceEvent.cs +++ b/src/Core/Tools/Models/Business/ReferenceEvent.cs @@ -243,4 +243,15 @@ public class ReferenceEvent /// the value should be . /// public string SignupInitiationPath { get; set; } + + /// + /// The upgrade applied to an account. The current plan is listed first, + /// followed by the plan they are migrating to. For example, + /// "Teams Starter → Teams, Enterprise". + /// + /// + /// when the event was not originated by an application, + /// or when a downgrade occurred. + /// + public string PlanUpgradePath { get; set; } } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index b54cbc3f52..5d0becf7b4 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -763,6 +763,14 @@ public static class CoreHelpers return claims; } + /// + /// Deserializes JSON data into the specified type. + /// If the JSON data is a null reference, it will still return an instantiated class. + /// However, if the JSON data is a string "null", it will return null. + /// + /// The JSON data + /// The type to deserialize into + /// public static T LoadClassFromJsonData(string jsonData) where T : new() { if (string.IsNullOrWhiteSpace(jsonData)) diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs index a1b6e7ea09..716b49ca4f 100644 --- a/src/Core/Vault/Models/Data/CipherDetails.cs +++ b/src/Core/Vault/Models/Data/CipherDetails.cs @@ -8,6 +8,26 @@ public class CipherDetails : CipherOrganizationDetails public bool Favorite { get; set; } public bool Edit { get; set; } public bool ViewPassword { get; set; } + + public CipherDetails() { } + + public CipherDetails(CipherOrganizationDetails cipher) + { + Id = cipher.Id; + UserId = cipher.UserId; + OrganizationId = cipher.OrganizationId; + Type = cipher.Type; + Data = cipher.Data; + Favorites = cipher.Favorites; + Folders = cipher.Folders; + Attachments = cipher.Attachments; + CreationDate = cipher.CreationDate; + RevisionDate = cipher.RevisionDate; + DeletedDate = cipher.DeletedDate; + Reprompt = cipher.Reprompt; + Key = cipher.Key; + OrganizationUseTotp = cipher.OrganizationUseTotp; + } } public class CipherDetailsWithCollections : CipherDetails diff --git a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs index 7d80ee1294..754a45faf6 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs @@ -111,4 +111,28 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos commandType: CommandType.StoredProcedure); } } + + public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[CollectionCipher_AddCollectionsForManyCiphers]", + new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[CollectionCipher_RemoveCollectionsForManyCiphers]", + new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + } + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index 7ef9b1967b..df854dc611 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -248,4 +248,47 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec await dbContext.SaveChangesAsync(); } } + + public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var availableCollections = await (from c in dbContext.Collections + join o in dbContext.Organizations on c.OrganizationId equals o.Id + where o.Id == organizationId && o.Enabled + select c).ToListAsync(); + + var currentCollectionCiphers = await (from cc in dbContext.CollectionCiphers + where cipherIds.Contains(cc.CipherId) + select cc).ToListAsync(); + + var insertData = from collectionId in collectionIds + from cipherId in cipherIds + where + availableCollections.Select(c => c.Id).Contains(collectionId) && + !currentCollectionCiphers.Any(cc => cc.CipherId == cipherId && cc.CollectionId == collectionId) + select new Models.CollectionCipher { CollectionId = collectionId, CipherId = cipherId, }; + + await dbContext.AddRangeAsync(insertData); + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); + await dbContext.SaveChangesAsync(); + } + } + + public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable cipherIds, + IEnumerable collectionIds) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var currentCollectionCiphersToBeRemoved = await (from cc in dbContext.CollectionCiphers + where cipherIds.Contains(cc.CipherId) && collectionIds.Contains(cc.CollectionId) + select cc).ToListAsync(); + dbContext.RemoveRange(currentCollectionCiphersToBeRemoved); + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); + await dbContext.SaveChangesAsync(); + } + } } diff --git a/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_AddCollectionsForManyCiphers.sql b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_AddCollectionsForManyCiphers.sql new file mode 100644 index 0000000000..229d00436a --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_AddCollectionsForManyCiphers.sql @@ -0,0 +1,56 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_AddCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #AvailableCollections ( + [Id] UNIQUEIDENTIFIER + ) + + INSERT INTO #AvailableCollections + SELECT + C.[Id] + FROM + [dbo].[Collection] C + INNER JOIN + [dbo].[Organization] O ON O.[Id] = C.[OrganizationId] + WHERE + O.[Id] = @OrganizationId AND O.[Enabled] = 1 + + IF (SELECT COUNT(1) FROM #AvailableCollections) < 1 + BEGIN + -- No collections available + RETURN + END + + ;WITH [SourceCollectionCipherCTE] AS( + SELECT + [Collection].[Id] AS [CollectionId], + [Cipher].[Id] AS [CipherId] + FROM + @CollectionIds AS [Collection] + CROSS JOIN + @CipherIds AS [Cipher] + WHERE + [Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections) + ) + MERGE + [CollectionCipher] AS [Target] + USING + [SourceCollectionCipherCTE] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[CipherId] = [Source].[CipherId] + WHEN NOT MATCHED BY TARGET THEN + INSERT VALUES + ( + [Source].[CollectionId], + [Source].[CipherId] + ) + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END diff --git a/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_RemoveCollectionsFromManyCiphers.sql b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_RemoveCollectionsFromManyCiphers.sql new file mode 100644 index 0000000000..b0b0ffc4f9 --- /dev/null +++ b/src/Sql/Vault/dbo/Stored Procedures/CollectionCipher/CollectionCipher_RemoveCollectionsFromManyCiphers.sql @@ -0,0 +1,26 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_RemoveCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @BatchSize INT = 100 + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionCipher_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[CollectionCipher] + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) AND + [CollectionId] IN (SELECT [Id] FROM @CollectionIds) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionCipher_DeleteMany + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 5af1a86192..363d6db351 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -8,14 +8,17 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; using NSubstitute; using Xunit; @@ -201,4 +204,104 @@ public class OrganizationUsersControllerTests cas.All(c => model.Collections.Any(m => m.Id == c.Id))), model.Groups); } + + [Theory] + [BitAutoData] + public async Task Get_WithFlexibleCollections_ReturnsUsers( + ICollection organizationUsers, OrganizationAbility organizationAbility, + SutProvider sutProvider) + { + Get_Setup(organizationAbility, organizationUsers, sutProvider); + var response = await sutProvider.Sut.Get(organizationAbility.Id); + + Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); + } + + [Theory] + [BitAutoData] + public async Task Get_WithFlexibleCollections_HandlesNullPermissionsObject( + ICollection organizationUsers, OrganizationAbility organizationAbility, + SutProvider sutProvider) + { + Get_Setup(organizationAbility, organizationUsers, sutProvider); + organizationUsers.First().Permissions = "null"; + var response = await sutProvider.Sut.Get(organizationAbility.Id); + + Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); + } + + [Theory] + [BitAutoData] + public async Task Get_WithFlexibleCollections_SetsDeprecatedCustomPermissionstoFalse( + ICollection organizationUsers, OrganizationAbility organizationAbility, + SutProvider sutProvider) + { + Get_Setup(organizationAbility, organizationUsers, sutProvider); + + var customUser = organizationUsers.First(); + customUser.Type = OrganizationUserType.Custom; + customUser.Permissions = CoreHelpers.ClassToJsonData(new Permissions + { + AccessReports = true, + EditAssignedCollections = true, + DeleteAssignedCollections = true, + AccessEventLogs = true + }); + + var response = await sutProvider.Sut.Get(organizationAbility.Id); + + var customUserResponse = response.Data.First(r => r.Id == organizationUsers.First().Id); + Assert.Equal(OrganizationUserType.Custom, customUserResponse.Type); + Assert.True(customUserResponse.Permissions.AccessReports); + Assert.True(customUserResponse.Permissions.AccessEventLogs); + Assert.False(customUserResponse.Permissions.EditAssignedCollections); + Assert.False(customUserResponse.Permissions.DeleteAssignedCollections); + } + + [Theory] + [BitAutoData] + public async Task Get_WithFlexibleCollections_DowngradesCustomUsersWithDeprecatedPermissions( + ICollection organizationUsers, OrganizationAbility organizationAbility, + SutProvider sutProvider) + { + Get_Setup(organizationAbility, organizationUsers, sutProvider); + + var customUser = organizationUsers.First(); + customUser.Type = OrganizationUserType.Custom; + customUser.Permissions = CoreHelpers.ClassToJsonData(new Permissions + { + EditAssignedCollections = true, + DeleteAssignedCollections = true, + }); + + var response = await sutProvider.Sut.Get(organizationAbility.Id); + + var customUserResponse = response.Data.First(r => r.Id == organizationUsers.First().Id); + Assert.Equal(OrganizationUserType.User, customUserResponse.Type); + Assert.False(customUserResponse.Permissions.EditAssignedCollections); + Assert.False(customUserResponse.Permissions.DeleteAssignedCollections); + } + + private void Get_Setup(OrganizationAbility organizationAbility, + ICollection organizationUsers, + SutProvider sutProvider) + { + organizationAbility.FlexibleCollections = true; + foreach (var orgUser in organizationUsers) + { + orgUser.Permissions = null; + } + sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) + .Returns(organizationAbility); + + sutProvider.GetDependency().AuthorizeAsync( + user: Arg.Any(), + resource: Arg.Any(), + requirements: Arg.Any>()) + .Returns(AuthorizationResult.Success()); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(organizationAbility.Id, Arg.Any(), Arg.Any()) + .Returns(organizationUsers); + } } diff --git a/util/Migrator/DbMigrator.cs b/util/Migrator/DbMigrator.cs index 24e78aaee6..a6ca53abdb 100644 --- a/util/Migrator/DbMigrator.cs +++ b/util/Migrator/DbMigrator.cs @@ -1,5 +1,6 @@ using System.Data; using System.Reflection; +using System.Text; using Bit.Core; using DbUp; using DbUp.Helpers; @@ -12,39 +13,35 @@ public class DbMigrator { private readonly string _connectionString; private readonly ILogger _logger; - private readonly string _masterConnectionString; - public DbMigrator(string connectionString, ILogger logger) + public DbMigrator(string connectionString, ILogger logger = null) { _connectionString = connectionString; - _logger = logger; - _masterConnectionString = new SqlConnectionStringBuilder(connectionString) - { - InitialCatalog = "master" - }.ConnectionString; + _logger = logger ?? CreateLogger(); } public bool MigrateMsSqlDatabaseWithRetries(bool enableLogging = true, bool repeatable = false, string folderName = MigratorConstants.DefaultMigrationsFolderName, - CancellationToken cancellationToken = default(CancellationToken)) + bool dryRun = false, + CancellationToken cancellationToken = default) { var attempt = 1; - while (attempt < 10) { try { - var success = MigrateDatabase(enableLogging, repeatable, folderName, cancellationToken); + PrepareDatabase(cancellationToken); + + var success = MigrateDatabase(enableLogging, repeatable, folderName, dryRun, cancellationToken); return success; } catch (SqlException ex) { - if (ex.Message.Contains("Server is in script upgrade mode")) + if (ex.Message.Contains("Server is in script upgrade mode.")) { attempt++; - _logger.LogInformation("Database is in script upgrade mode. " + - $"Trying again (attempt #{attempt})..."); + _logger.LogInformation($"Database is in script upgrade mode, trying again (attempt #{attempt})."); Thread.Sleep(20000); } else @@ -56,17 +53,14 @@ public class DbMigrator return false; } - public bool MigrateDatabase(bool enableLogging = true, - bool repeatable = false, - string folderName = MigratorConstants.DefaultMigrationsFolderName, - CancellationToken cancellationToken = default(CancellationToken)) + private void PrepareDatabase(CancellationToken cancellationToken = default) { - if (_logger != null) + var masterConnectionString = new SqlConnectionStringBuilder(_connectionString) { - _logger.LogInformation(Constants.BypassFiltersEventId, "Migrating database."); - } + InitialCatalog = "master" + }.ConnectionString; - using (var connection = new SqlConnection(_masterConnectionString)) + using (var connection = new SqlConnection(masterConnectionString)) { var databaseName = new SqlConnectionStringBuilder(_connectionString).InitialCatalog; if (string.IsNullOrWhiteSpace(databaseName)) @@ -89,9 +83,10 @@ public class DbMigrator } cancellationToken.ThrowIfCancellationRequested(); + using (var connection = new SqlConnection(_connectionString)) { - // Rename old migration scripts to new namespace. + // rename old migration scripts to new namespace var command = new SqlCommand( "IF OBJECT_ID('Migration','U') IS NOT NULL " + "UPDATE [dbo].[Migration] SET " + @@ -101,6 +96,21 @@ public class DbMigrator } cancellationToken.ThrowIfCancellationRequested(); + } + + private bool MigrateDatabase(bool enableLogging = true, + bool repeatable = false, + string folderName = MigratorConstants.DefaultMigrationsFolderName, + bool dryRun = false, + CancellationToken cancellationToken = default) + { + if (enableLogging) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Migrating database."); + } + + cancellationToken.ThrowIfCancellationRequested(); + var builder = DeployChanges.To .SqlDatabase(_connectionString) .WithScriptsAndCodeEmbeddedInAssembly(Assembly.GetExecutingAssembly(), @@ -119,20 +129,26 @@ public class DbMigrator if (enableLogging) { - if (_logger != null) - { - builder.LogTo(new DbUpLogger(_logger)); - } - else - { - builder.LogToConsole(); - } + builder.LogTo(new DbUpLogger(_logger)); } var upgrader = builder.Build(); + + if (dryRun) + { + var scriptsToExec = upgrader.GetScriptsToExecute(); + var stringBuilder = new StringBuilder("Scripts that will be applied:"); + foreach (var script in scriptsToExec) + { + stringBuilder.AppendLine(script.Name); + } + _logger.LogInformation(Constants.BypassFiltersEventId, stringBuilder.ToString()); + return true; + } + var result = upgrader.PerformUpgrade(); - if (_logger != null) + if (enableLogging) { if (result.Successful) { @@ -145,6 +161,22 @@ public class DbMigrator } cancellationToken.ThrowIfCancellationRequested(); + return result.Successful; } + + private ILogger CreateLogger() + { + var loggerFactory = LoggerFactory.Create(builder => + { + builder + .AddFilter("Microsoft", LogLevel.Warning) + .AddFilter("System", LogLevel.Warning) + .AddConsole(); + + builder.AddFilter("DbMigrator.DbMigrator", LogLevel.Information); + }); + + return loggerFactory.CreateLogger(); + } } diff --git a/util/Migrator/DbScripts/2024-03-20_00_BulkCipherCollectionAssignment.sql b/util/Migrator/DbScripts/2024-03-20_00_BulkCipherCollectionAssignment.sql new file mode 100644 index 0000000000..899f39f08a --- /dev/null +++ b/util/Migrator/DbScripts/2024-03-20_00_BulkCipherCollectionAssignment.sql @@ -0,0 +1,85 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_AddCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #AvailableCollections ( + [Id] UNIQUEIDENTIFIER + ) + + INSERT INTO #AvailableCollections + SELECT + C.[Id] + FROM + [dbo].[Collection] C + INNER JOIN + [dbo].[Organization] O ON O.[Id] = C.[OrganizationId] + WHERE + O.[Id] = @OrganizationId AND O.[Enabled] = 1 + + IF (SELECT COUNT(1) FROM #AvailableCollections) < 1 + BEGIN + -- No collections available + RETURN + END + + ;WITH [SourceCollectionCipherCTE] AS( + SELECT + [Collection].[Id] AS [CollectionId], + [Cipher].[Id] AS [CipherId] + FROM + @CollectionIds AS [Collection] + CROSS JOIN + @CipherIds AS [Cipher] + WHERE + [Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections) + ) + MERGE + [CollectionCipher] AS [Target] + USING + [SourceCollectionCipherCTE] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[CipherId] = [Source].[CipherId] + WHEN NOT MATCHED BY TARGET THEN + INSERT VALUES + ( + [Source].[CollectionId], + [Source].[CipherId] + ) + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_RemoveCollectionsForManyCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + DECLARE @BatchSize INT = 100 + + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION CollectionCipher_DeleteMany + DELETE TOP(@BatchSize) + FROM + [dbo].[CollectionCipher] + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) AND + [CollectionId] IN (SELECT [Id] FROM @CollectionIds) + + SET @BatchSize = @@ROWCOUNT + COMMIT TRANSACTION CollectionCipher_DeleteMany + END + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO diff --git a/util/Migrator/DbScripts/2024-03-25_00_EnableAllOrgCollectionEnhancements.sql b/util/Migrator/DbScripts/2024-03-25_00_EnableAllOrgCollectionEnhancements.sql new file mode 100644 index 0000000000..eb5b44c5d1 --- /dev/null +++ b/util/Migrator/DbScripts/2024-03-25_00_EnableAllOrgCollectionEnhancements.sql @@ -0,0 +1,37 @@ +-- This script will enable collection enhancements for organizations that don't have Collection Enhancements enabled. + +-- Step 1: Insert into a temporary table, selecting a percentage of the rows for each distinct PlanType +SELECT [Id] AS [OrganizationId] +INTO #TempOrg +FROM [dbo].[Organization] +WHERE [FlexibleCollections] = 0 + +-- Step 2: Execute the stored procedure for each OrganizationId +DECLARE @OrganizationId UNIQUEIDENTIFIER; + +DECLARE OrgCursor CURSOR FOR +SELECT [OrganizationId] +FROM #TempOrg; + +OPEN OrgCursor; + +FETCH NEXT FROM OrgCursor INTO @OrganizationId; + +WHILE (@@FETCH_STATUS = 0) +BEGIN + -- Execute the stored procedure for the current OrganizationId + EXEC [dbo].[Organization_EnableCollectionEnhancements] @OrganizationId; + + -- Update the Organization to set FlexibleCollections = 1 + UPDATE [dbo].[Organization] + SET [FlexibleCollections] = 1 + WHERE [Id] = @OrganizationId; + + FETCH NEXT FROM OrgCursor INTO @OrganizationId; +END; + +CLOSE OrgCursor; +DEALLOCATE OrgCursor; + +-- Step 3: Drop the temporary table +DROP TABLE #TempOrg; diff --git a/util/Migrator/SqlServerDbMigrator.cs b/util/Migrator/SqlServerDbMigrator.cs index 3885a6f6ca..b443260820 100644 --- a/util/Migrator/SqlServerDbMigrator.cs +++ b/util/Migrator/SqlServerDbMigrator.cs @@ -1,109 +1,22 @@ -using System.Data; -using System.Reflection; -using Bit.Core; -using Bit.Core.Settings; +using Bit.Core.Settings; using Bit.Core.Utilities; -using DbUp; -using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; namespace Bit.Migrator; public class SqlServerDbMigrator : IDbMigrator { - private readonly string _connectionString; - private readonly ILogger _logger; - private readonly string _masterConnectionString; + private readonly DbMigrator _migrator; - public SqlServerDbMigrator(GlobalSettings globalSettings, ILogger logger) + public SqlServerDbMigrator(GlobalSettings globalSettings, ILogger logger) { - _connectionString = globalSettings.SqlServer.ConnectionString; - _logger = logger; - _masterConnectionString = new SqlConnectionStringBuilder(_connectionString) - { - InitialCatalog = "master" - }.ConnectionString; + _migrator = new DbMigrator(globalSettings.SqlServer.ConnectionString, logger); } public bool MigrateDatabase(bool enableLogging = true, - CancellationToken cancellationToken = default(CancellationToken)) + CancellationToken cancellationToken = default) { - if (enableLogging && _logger != null) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Migrating database."); - } - - using (var connection = new SqlConnection(_masterConnectionString)) - { - var databaseName = new SqlConnectionStringBuilder(_connectionString).InitialCatalog; - if (string.IsNullOrWhiteSpace(databaseName)) - { - databaseName = "vault"; - } - - var databaseNameQuoted = new SqlCommandBuilder().QuoteIdentifier(databaseName); - var command = new SqlCommand( - "IF ((SELECT COUNT(1) FROM sys.databases WHERE [name] = @DatabaseName) = 0) " + - "CREATE DATABASE " + databaseNameQuoted + ";", connection); - command.Parameters.Add("@DatabaseName", SqlDbType.VarChar).Value = databaseName; - command.Connection.Open(); - command.ExecuteNonQuery(); - - command.CommandText = "IF ((SELECT DATABASEPROPERTYEX([name], 'IsAutoClose') " + - "FROM sys.databases WHERE [name] = @DatabaseName) = 1) " + - "ALTER DATABASE " + databaseNameQuoted + " SET AUTO_CLOSE OFF;"; - command.ExecuteNonQuery(); - } - - cancellationToken.ThrowIfCancellationRequested(); - using (var connection = new SqlConnection(_connectionString)) - { - // Rename old migration scripts to new namespace. - var command = new SqlCommand( - "IF OBJECT_ID('Migration','U') IS NOT NULL " + - "UPDATE [dbo].[Migration] SET " + - "[ScriptName] = REPLACE([ScriptName], 'Bit.Setup.', 'Bit.Migrator.');", connection); - command.Connection.Open(); - command.ExecuteNonQuery(); - } - - cancellationToken.ThrowIfCancellationRequested(); - var builder = DeployChanges.To - .SqlDatabase(_connectionString) - .JournalToSqlTable("dbo", MigratorConstants.SqlTableJournalName) - .WithScriptsAndCodeEmbeddedInAssembly(Assembly.GetExecutingAssembly(), - s => s.Contains($".DbScripts.") && !s.Contains(".Archive.")) - .WithTransaction() - .WithExecutionTimeout(TimeSpan.FromMinutes(5)); - - if (enableLogging) - { - if (_logger != null) - { - builder.LogTo(new DbUpLogger(_logger)); - } - else - { - builder.LogToConsole(); - } - } - - var upgrader = builder.Build(); - var result = upgrader.PerformUpgrade(); - - if (enableLogging && _logger != null) - { - if (result.Successful) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Migration successful."); - } - else - { - _logger.LogError(Constants.BypassFiltersEventId, result.Error, "Migration failed."); - } - } - - cancellationToken.ThrowIfCancellationRequested(); - return result.Successful; + return _migrator.MigrateMsSqlDatabaseWithRetries(enableLogging, + cancellationToken: cancellationToken); } } diff --git a/util/MsSqlMigratorUtility/Dockerfile b/util/MsSqlMigratorUtility/Dockerfile index 7b53905f49..b3da6a53f0 100644 --- a/util/MsSqlMigratorUtility/Dockerfile +++ b/util/MsSqlMigratorUtility/Dockerfile @@ -5,4 +5,4 @@ LABEL com.bitwarden.product="bitwarden" WORKDIR /app COPY obj/build-output/publish . -ENTRYPOINT ["sh", "-c", "dotnet /app/MsSqlMigratorUtility.dll \"${MSSQL_CONN_STRING}\" -v ${@}", "--" ] +ENTRYPOINT ["sh", "-c", "dotnet /app/MsSqlMigratorUtility.dll \"${MSSQL_CONN_STRING}\" ${@}", "--" ] diff --git a/util/MsSqlMigratorUtility/Program.cs b/util/MsSqlMigratorUtility/Program.cs index 681225ca30..47deab256e 100644 --- a/util/MsSqlMigratorUtility/Program.cs +++ b/util/MsSqlMigratorUtility/Program.cs @@ -1,11 +1,8 @@ using Bit.Migrator; using CommandDotNet; -using Microsoft.Extensions.Logging; internal class Program { - private static IDictionary Parameters { get; set; } - private static int Main(string[] args) { return new AppRunner().Run(args); @@ -15,60 +12,28 @@ internal class Program public void Execute( [Operand(Description = "Database connection string")] string databaseConnectionString, - [Option('v', "verbose", Description = "Enable verbose output of migrator logs")] - bool verbose = false, [Option('r', "repeatable", Description = "Mark scripts as repeatable")] bool repeatable = false, [Option('f', "folder", Description = "Folder name of database scripts")] - string folderName = MigratorConstants.DefaultMigrationsFolderName) => MigrateDatabase(databaseConnectionString, verbose, repeatable, folderName); + string folderName = MigratorConstants.DefaultMigrationsFolderName, + [Option('d', "dry-run", Description = "Print the scripts that will be applied without actually executing them")] + bool dryRun = false + ) => MigrateDatabase(databaseConnectionString, repeatable, folderName, dryRun); - private static void WriteUsageToConsole() + private static bool MigrateDatabase(string databaseConnectionString, + bool repeatable = false, string folderName = "", bool dryRun = false) { - Console.WriteLine("Usage: MsSqlMigratorUtility "); - Console.WriteLine("Usage: MsSqlMigratorUtility -v|--verbose (for verbose output of migrator logs)"); - Console.WriteLine("Usage: MsSqlMigratorUtility -r|--repeatable (for marking scripts as repeatable) -f|--folder (for specifying folder name of scripts)"); - Console.WriteLine("Usage: MsSqlMigratorUtility -v|--verbose (for verbose output of migrator logs) -r|--repeatable (for marking scripts as repeatable) -f|--folder (for specifying folder name of scripts)"); - } - - private static bool MigrateDatabase(string databaseConnectionString, bool verbose = false, bool repeatable = false, string folderName = "") - { - var logger = CreateLogger(verbose); - - logger.LogInformation($"Migrating database with repeatable: {repeatable} and folderName: {folderName}."); - - var migrator = new DbMigrator(databaseConnectionString, logger); - bool success = false; + var migrator = new DbMigrator(databaseConnectionString); + bool success; if (!string.IsNullOrWhiteSpace(folderName)) { - success = migrator.MigrateMsSqlDatabaseWithRetries(verbose, repeatable, folderName); + success = migrator.MigrateMsSqlDatabaseWithRetries(true, repeatable, folderName, dryRun); } else { - success = migrator.MigrateMsSqlDatabaseWithRetries(verbose, repeatable); + success = migrator.MigrateMsSqlDatabaseWithRetries(true, repeatable, dryRun: dryRun); } return success; } - - private static ILogger CreateLogger(bool verbose) - { - var loggerFactory = LoggerFactory.Create(builder => - { - builder - .AddFilter("Microsoft", LogLevel.Warning) - .AddFilter("System", LogLevel.Warning) - .AddConsole(); - - if (verbose) - { - builder.AddFilter("DbMigrator.DbMigrator", LogLevel.Debug); - } - else - { - builder.AddFilter("DbMigrator.DbMigrator", LogLevel.Information); - } - }); - var logger = loggerFactory.CreateLogger(); - return logger; - } } diff --git a/util/Setup/Program.cs b/util/Setup/Program.cs index 93304c6bb5..5768db7abb 100644 --- a/util/Setup/Program.cs +++ b/util/Setup/Program.cs @@ -17,6 +17,7 @@ public class Program { Args = args }; + ParseParameters(); if (_context.Parameters.ContainsKey("q")) @@ -155,7 +156,7 @@ public class Program if (_context.Parameters.ContainsKey("db")) { - MigrateDatabase(); + PrepareAndMigrateDatabase(); } else { @@ -185,17 +186,19 @@ public class Program Console.WriteLine("\n"); } - private static void MigrateDatabase(int attempt = 1) + private static void PrepareAndMigrateDatabase() { var vaultConnectionString = Helpers.GetValueFromEnvFile("global", "globalSettings__sqlServer__connectionString"); - var migrator = new DbMigrator(vaultConnectionString, null); + var migrator = new DbMigrator(vaultConnectionString); - var log = false; + var enableLogging = false; - migrator.MigrateMsSqlDatabaseWithRetries(log); + // execute all general migration scripts (will detect those not yet applied) + migrator.MigrateMsSqlDatabaseWithRetries(enableLogging); - migrator.MigrateMsSqlDatabaseWithRetries(log, true, MigratorConstants.TransitionMigrationsFolderName); + // execute explicit transition migration scripts, per EDD + migrator.MigrateMsSqlDatabaseWithRetries(enableLogging, true, MigratorConstants.TransitionMigrationsFolderName); } private static bool ValidateInstallation()