1
0
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:
Rui Tome
2024-03-25 21:28:32 +00:00
30 changed files with 763 additions and 199 deletions

View File

@ -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>

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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)

View File

@ -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; }
}

View File

@ -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, ""));

View File

@ -147,7 +147,8 @@ public static class FeatureFlagKeys
{
{ TrustedDeviceEncryption, "true" },
{ Fido2VaultCredentials, "true" },
{ DuoRedirect, "true" }
{ DuoRedirect, "true" },
{ FlexibleCollectionsSignup, "true" }
};
}
}

View File

@ -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,

View File

@ -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"
};
}

View File

@ -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);
}

View File

@ -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(

View File

@ -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);

View File

@ -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);
}

View File

@ -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
{

View File

@ -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; }

View File

@ -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; }
}

View File

@ -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))

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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();
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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);
}
}

View File

@ -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>();
}
}

View File

@ -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

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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}\" ${@}", "--" ]

View File

@ -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;
}
}

View File

@ -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()