mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 08:02:49 -05:00
Merge branch 'ac/ac-2323/sql-automatic-data-migrations' into ac/ac-1682/ef-migrations
This commit is contained in:
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2024.3.0</Version>
|
||||
<Version>2024.3.1</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// </summary>
|
||||
private async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// </summary>
|
||||
@ -388,6 +430,97 @@ public class CiphersController : Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// </summary>
|
||||
private async Task<bool> CanEditCiphersAsync(Guid organizationId, IEnumerable<Guid> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// This likely belongs to the BulkCollectionAuthorizationHandler
|
||||
/// </summary>
|
||||
private async Task<bool> CanEditItemsInCollections(Guid organizationId, IEnumerable<Guid> 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<CipherResponseModel> 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)
|
||||
|
@ -0,0 +1,15 @@
|
||||
namespace Bit.Api.Vault.Models.Request;
|
||||
|
||||
public class CipherBulkUpdateCollectionsRequestModel
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
|
||||
public IEnumerable<Guid> CipherIds { get; set; }
|
||||
|
||||
public IEnumerable<Guid> CollectionIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If true, the collections will be removed from the ciphers. Otherwise, they will be added.
|
||||
/// </summary>
|
||||
public bool RemoveCollections { get; set; }
|
||||
}
|
@ -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, ""));
|
||||
|
@ -147,7 +147,8 @@ public static class FeatureFlagKeys
|
||||
{
|
||||
{ TrustedDeviceEncryption, "true" },
|
||||
{ Fido2VaultCredentials, "true" },
|
||||
{ DuoRedirect, "true" }
|
||||
{ DuoRedirect, "true" },
|
||||
{ FlexibleCollectionsSignup, "true" }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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<ProductType, string> _upgradePath = new()
|
||||
{
|
||||
[ProductType.Free] = "2-person org",
|
||||
[ProductType.Families] = "Families",
|
||||
[ProductType.TeamsStarter] = "Teams Starter",
|
||||
[ProductType.Teams] = "Teams",
|
||||
[ProductType.Enterprise] = "Enterprise"
|
||||
};
|
||||
}
|
||||
|
@ -11,4 +11,22 @@ public interface ICollectionCipherRepository
|
||||
Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable<Guid> collectionIds);
|
||||
Task UpdateCollectionsForCiphersAsync(IEnumerable<Guid> cipherIds, Guid userId, Guid organizationId,
|
||||
IEnumerable<Guid> collectionIds, bool useFlexibleCollections);
|
||||
|
||||
/// <summary>
|
||||
/// Add the specified collections to the specified ciphers. If a cipher already belongs to a requested collection,
|
||||
/// no action is taken.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method does not perform any authorization checks.
|
||||
/// </remarks>
|
||||
Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds, IEnumerable<Guid> collectionIds);
|
||||
|
||||
/// <summary>
|
||||
/// Remove the specified collections from the specified ciphers. If a cipher does not belong to a requested collection,
|
||||
/// no action is taken.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method does not perform any authorization checks.
|
||||
/// </remarks>
|
||||
Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds, IEnumerable<Guid> collectionIds);
|
||||
}
|
||||
|
@ -24,8 +24,8 @@ public interface IMailService
|
||||
Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo);
|
||||
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
|
||||
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
|
||||
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails);
|
||||
Task SendOrganizationConfirmedEmailAsync(string organizationName, string email);
|
||||
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> 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(
|
||||
|
@ -173,7 +173,7 @@ public class HandlebarsMailService : IMailService
|
||||
}
|
||||
|
||||
public async Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier,
|
||||
IEnumerable<string> adminEmails)
|
||||
IEnumerable<string> 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);
|
||||
|
@ -43,12 +43,13 @@ public class NoopMailService : IMailService
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails)
|
||||
public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier,
|
||||
IEnumerable<string> 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);
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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; }
|
||||
|
@ -243,4 +243,15 @@ public class ReferenceEvent
|
||||
/// the value should be <see langword="null" />.
|
||||
/// </value>
|
||||
public string SignupInitiationPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <see langword="null"/> when the event was not originated by an application,
|
||||
/// or when a downgrade occurred.
|
||||
/// </value>
|
||||
public string PlanUpgradePath { get; set; }
|
||||
}
|
||||
|
@ -763,6 +763,14 @@ public static class CoreHelpers
|
||||
return claims;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="jsonData">The JSON data</param>
|
||||
/// <typeparam name="T">The type to deserialize into</typeparam>
|
||||
/// <returns></returns>
|
||||
public static T LoadClassFromJsonData<T>(string jsonData) where T : new()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jsonData))
|
||||
|
@ -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
|
||||
|
@ -111,4 +111,28 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
|
||||
IEnumerable<Guid> 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<Guid> cipherIds,
|
||||
IEnumerable<Guid> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -248,4 +248,47 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
|
||||
IEnumerable<Guid> 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<Guid> cipherIds,
|
||||
IEnumerable<Guid> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -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
|
@ -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<OrganizationUserUserDetails> organizationUsers, OrganizationAbility organizationAbility,
|
||||
SutProvider<OrganizationUsersController> 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<OrganizationUserUserDetails> organizationUsers, OrganizationAbility organizationAbility,
|
||||
SutProvider<OrganizationUsersController> 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<OrganizationUserUserDetails> organizationUsers, OrganizationAbility organizationAbility,
|
||||
SutProvider<OrganizationUsersController> 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<OrganizationUserUserDetails> organizationUsers, OrganizationAbility organizationAbility,
|
||||
SutProvider<OrganizationUsersController> 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<OrganizationUserUserDetails> organizationUsers,
|
||||
SutProvider<OrganizationUsersController> sutProvider)
|
||||
{
|
||||
organizationAbility.FlexibleCollections = true;
|
||||
foreach (var orgUser in organizationUsers)
|
||||
{
|
||||
orgUser.Permissions = null;
|
||||
}
|
||||
sutProvider.GetDependency<IApplicationCacheService>().GetOrganizationAbilityAsync(organizationAbility.Id)
|
||||
.Returns(organizationAbility);
|
||||
|
||||
sutProvider.GetDependency<IAuthorizationService>().AuthorizeAsync(
|
||||
user: Arg.Any<ClaimsPrincipal>(),
|
||||
resource: Arg.Any<Object>(),
|
||||
requirements: Arg.Any<IEnumerable<IAuthorizationRequirement>>())
|
||||
.Returns(AuthorizationResult.Success());
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(organizationAbility.Id, Arg.Any<bool>(), Arg.Any<bool>())
|
||||
.Returns(organizationUsers);
|
||||
}
|
||||
}
|
||||
|
@ -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<DbMigrator> _logger;
|
||||
private readonly string _masterConnectionString;
|
||||
|
||||
public DbMigrator(string connectionString, ILogger<DbMigrator> logger)
|
||||
public DbMigrator(string connectionString, ILogger<DbMigrator> 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<DbMigrator> 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<DbMigrator>();
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -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;
|
@ -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<SqlServerDbMigrator> _logger;
|
||||
private readonly string _masterConnectionString;
|
||||
private readonly DbMigrator _migrator;
|
||||
|
||||
public SqlServerDbMigrator(GlobalSettings globalSettings, ILogger<SqlServerDbMigrator> logger)
|
||||
public SqlServerDbMigrator(GlobalSettings globalSettings, ILogger<DbMigrator> 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);
|
||||
}
|
||||
}
|
||||
|
@ -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}\" ${@}", "--" ]
|
||||
|
@ -1,11 +1,8 @@
|
||||
using Bit.Migrator;
|
||||
using CommandDotNet;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
internal class Program
|
||||
{
|
||||
private static IDictionary<string, string> Parameters { get; set; }
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
return new AppRunner<Program>().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 <database-connection-string>");
|
||||
Console.WriteLine("Usage: MsSqlMigratorUtility <database-connection-string> -v|--verbose (for verbose output of migrator logs)");
|
||||
Console.WriteLine("Usage: MsSqlMigratorUtility <database-connection-string> -r|--repeatable (for marking scripts as repeatable) -f|--folder <folder-name-in-migrator-project> (for specifying folder name of scripts)");
|
||||
Console.WriteLine("Usage: MsSqlMigratorUtility <database-connection-string> -v|--verbose (for verbose output of migrator logs) -r|--repeatable (for marking scripts as repeatable) -f|--folder <folder-name-in-migrator-project> (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<DbMigrator> 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<DbMigrator>();
|
||||
return logger;
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
Reference in New Issue
Block a user