mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12: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>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2024.3.0</Version>
|
<Version>2024.3.1</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
@ -94,8 +94,11 @@ public class OrganizationUsersController : Controller
|
|||||||
response.Type = GetFlexibleCollectionsUserType(response.Type, response.Permissions);
|
response.Type = GetFlexibleCollectionsUserType(response.Type, response.Permissions);
|
||||||
|
|
||||||
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
||||||
response.Permissions.EditAssignedCollections = false;
|
if (response.Permissions is not null)
|
||||||
response.Permissions.DeleteAssignedCollections = false;
|
{
|
||||||
|
response.Permissions.EditAssignedCollections = false;
|
||||||
|
response.Permissions.DeleteAssignedCollections = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeGroups)
|
if (includeGroups)
|
||||||
@ -552,8 +555,11 @@ public class OrganizationUsersController : Controller
|
|||||||
orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions);
|
orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions);
|
||||||
|
|
||||||
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
||||||
orgUser.Permissions.EditAssignedCollections = false;
|
if (orgUser.Permissions is not null)
|
||||||
orgUser.Permissions.DeleteAssignedCollections = false;
|
{
|
||||||
|
orgUser.Permissions.EditAssignedCollections = false;
|
||||||
|
orgUser.Permissions.DeleteAssignedCollections = false;
|
||||||
|
}
|
||||||
|
|
||||||
return orgUser;
|
return orgUser;
|
||||||
});
|
});
|
||||||
@ -565,7 +571,7 @@ public class OrganizationUsersController : Controller
|
|||||||
private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions)
|
private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions)
|
||||||
{
|
{
|
||||||
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
|
// 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) &&
|
if ((permissions.EditAssignedCollections || permissions.DeleteAssignedCollections) &&
|
||||||
permissions is
|
permissions is
|
||||||
|
@ -74,7 +74,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
if (FlexibleCollections)
|
if (FlexibleCollections)
|
||||||
{
|
{
|
||||||
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
|
// 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) &&
|
if ((Permissions.EditAssignedCollections || Permissions.DeleteAssignedCollections) &&
|
||||||
Permissions is
|
Permissions is
|
||||||
@ -98,8 +98,11 @@ public class ProfileOrganizationResponseModel : ResponseModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
// Set 'Edit/Delete Assigned Collections' custom permissions to false
|
||||||
Permissions.EditAssignedCollections = false;
|
if (Permissions is not null)
|
||||||
Permissions.DeleteAssignedCollections = false;
|
{
|
||||||
|
Permissions.EditAssignedCollections = false;
|
||||||
|
Permissions.DeleteAssignedCollections = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,7 @@ public class CiphersController : Controller
|
|||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||||
private readonly IApplicationCacheService _applicationCacheService;
|
private readonly IApplicationCacheService _applicationCacheService;
|
||||||
|
private readonly ICollectionRepository _collectionRepository;
|
||||||
|
|
||||||
private bool UseFlexibleCollections =>
|
private bool UseFlexibleCollections =>
|
||||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||||
@ -61,7 +62,8 @@ public class CiphersController : Controller
|
|||||||
GlobalSettings globalSettings,
|
GlobalSettings globalSettings,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||||
IApplicationCacheService applicationCacheService)
|
IApplicationCacheService applicationCacheService,
|
||||||
|
ICollectionRepository collectionRepository)
|
||||||
{
|
{
|
||||||
_cipherRepository = cipherRepository;
|
_cipherRepository = cipherRepository;
|
||||||
_collectionCipherRepository = collectionCipherRepository;
|
_collectionCipherRepository = collectionCipherRepository;
|
||||||
@ -75,6 +77,7 @@ public class CiphersController : Controller
|
|||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_organizationCiphersQuery = organizationCiphersQuery;
|
_organizationCiphersQuery = organizationCiphersQuery;
|
||||||
_applicationCacheService = applicationCacheService;
|
_applicationCacheService = applicationCacheService;
|
||||||
|
_collectionRepository = collectionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -342,6 +345,45 @@ public class CiphersController : Controller
|
|||||||
return false;
|
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>
|
/// <summary>
|
||||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -388,6 +430,97 @@ public class CiphersController : Controller
|
|||||||
return false;
|
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")]
|
[HttpPut("{id}/partial")]
|
||||||
[HttpPost("{id}/partial")]
|
[HttpPost("{id}/partial")]
|
||||||
public async Task<CipherResponseModel> PutPartial(Guid id, [FromBody] CipherPartialRequestModel model)
|
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);
|
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}")]
|
[HttpDelete("{id}")]
|
||||||
[HttpPost("{id}/delete")]
|
[HttpPost("{id}/delete")]
|
||||||
public async Task Delete(Guid id)
|
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;
|
orgUser.Email = null;
|
||||||
|
|
||||||
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
|
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);
|
await DeleteAndPushUserRegistrationAsync(organizationId, user.Id);
|
||||||
succeededUsers.Add(orgUser);
|
succeededUsers.Add(orgUser);
|
||||||
result.Add(Tuple.Create(orgUser, ""));
|
result.Add(Tuple.Create(orgUser, ""));
|
||||||
|
@ -147,7 +147,8 @@ public static class FeatureFlagKeys
|
|||||||
{
|
{
|
||||||
{ TrustedDeviceEncryption, "true" },
|
{ TrustedDeviceEncryption, "true" },
|
||||||
{ Fido2VaultCredentials, "true" },
|
{ Fido2VaultCredentials, "true" },
|
||||||
{ DuoRedirect, "true" }
|
{ DuoRedirect, "true" },
|
||||||
|
{ FlexibleCollectionsSignup, "true" }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,14 +22,18 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel
|
|||||||
return new OrganizationUserInvitedViewModel
|
return new OrganizationUserInvitedViewModel
|
||||||
{
|
{
|
||||||
TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ",
|
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!",
|
TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!",
|
||||||
OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status,
|
OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status,
|
||||||
Email = WebUtility.UrlEncode(orgUser.Email),
|
Email = WebUtility.UrlEncode(orgUser.Email),
|
||||||
OrganizationId = orgUser.OrganizationId.ToString(),
|
OrganizationId = orgUser.OrganizationId.ToString(),
|
||||||
OrganizationUserId = orgUser.Id.ToString(),
|
OrganizationUserId = orgUser.Id.ToString(),
|
||||||
Token = WebUtility.UrlEncode(expiringToken.Token),
|
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),
|
OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName),
|
||||||
WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash,
|
WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash,
|
||||||
SiteName = globalSettings.SiteName,
|
SiteName = globalSettings.SiteName,
|
||||||
|
@ -279,6 +279,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
|||||||
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
|
var upgradePath = GetUpgradePath(existingPlan.Product, newPlan.Product);
|
||||||
await _referenceEventService.RaiseEventAsync(
|
await _referenceEventService.RaiseEventAsync(
|
||||||
new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext)
|
new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext)
|
||||||
{
|
{
|
||||||
@ -287,6 +288,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
|
|||||||
OldPlanName = existingPlan.Name,
|
OldPlanName = existingPlan.Name,
|
||||||
OldPlanType = existingPlan.Type,
|
OldPlanType = existingPlan.Type,
|
||||||
Seats = organization.Seats,
|
Seats = organization.Seats,
|
||||||
|
SignupInitiationPath = "Upgrade in-product",
|
||||||
|
PlanUpgradePath = upgradePath,
|
||||||
Storage = organization.MaxStorageGb,
|
Storage = organization.MaxStorageGb,
|
||||||
// TODO: add reference events for SmSeats and Service Accounts - see AC-1481
|
// 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);
|
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 UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable<Guid> collectionIds);
|
||||||
Task UpdateCollectionsForCiphersAsync(IEnumerable<Guid> cipherIds, Guid userId, Guid organizationId,
|
Task UpdateCollectionsForCiphersAsync(IEnumerable<Guid> cipherIds, Guid userId, Guid organizationId,
|
||||||
IEnumerable<Guid> collectionIds, bool useFlexibleCollections);
|
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 SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo);
|
||||||
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
|
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
|
||||||
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
|
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
|
||||||
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails);
|
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails, bool hasAccessSecretsManager = false);
|
||||||
Task SendOrganizationConfirmedEmailAsync(string organizationName, string email);
|
Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false);
|
||||||
Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email);
|
Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email);
|
||||||
Task SendPasswordlessSignInAsync(string returnUrl, string token, string email);
|
Task SendPasswordlessSignInAsync(string returnUrl, string token, string email);
|
||||||
Task SendInvoiceUpcoming(
|
Task SendInvoiceUpcoming(
|
||||||
|
@ -173,7 +173,7 @@ public class HandlebarsMailService : IMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier,
|
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 message = CreateDefaultMessage($"Action Required: {userIdentifier} Needs to Be Confirmed", adminEmails);
|
||||||
var model = new OrganizationUserAcceptedViewModel
|
var model = new OrganizationUserAcceptedViewModel
|
||||||
@ -189,7 +189,7 @@ public class HandlebarsMailService : IMailService
|
|||||||
await _mailDeliveryService.SendEmailAsync(message);
|
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 message = CreateDefaultMessage($"You Have Been Confirmed To {organizationName}", email);
|
||||||
var model = new OrganizationUserConfirmedViewModel
|
var model = new OrganizationUserConfirmedViewModel
|
||||||
@ -198,7 +198,9 @@ public class HandlebarsMailService : IMailService
|
|||||||
TitleSecondBold = CoreHelpers.SanitizeForEmail(organizationName, false),
|
TitleSecondBold = CoreHelpers.SanitizeForEmail(organizationName, false),
|
||||||
TitleThird = "!",
|
TitleThird = "!",
|
||||||
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false),
|
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false),
|
||||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
WebVaultUrl = hasAccessSecretsManager
|
||||||
|
? _globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct
|
||||||
|
: _globalSettings.BaseServiceUri.VaultWithHash,
|
||||||
SiteName = _globalSettings.SiteName
|
SiteName = _globalSettings.SiteName
|
||||||
};
|
};
|
||||||
await AddMessageContentAsync(message, "OrganizationUserConfirmed", model);
|
await AddMessageContentAsync(message, "OrganizationUserConfirmed", model);
|
||||||
@ -216,6 +218,7 @@ public class HandlebarsMailService : IMailService
|
|||||||
|
|
||||||
var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair =>
|
var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair =>
|
||||||
{
|
{
|
||||||
|
|
||||||
var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(
|
var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(
|
||||||
orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings);
|
orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings);
|
||||||
return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel);
|
return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel);
|
||||||
@ -256,7 +259,7 @@ public class HandlebarsMailService : IMailService
|
|||||||
var message = CreateDefaultMessage("Welcome to Bitwarden!", userEmail);
|
var message = CreateDefaultMessage("Welcome to Bitwarden!", userEmail);
|
||||||
var model = new BaseMailModel
|
var model = new BaseMailModel
|
||||||
{
|
{
|
||||||
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
|
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct,
|
||||||
SiteName = _globalSettings.SiteName
|
SiteName = _globalSettings.SiteName
|
||||||
};
|
};
|
||||||
await AddMessageContentAsync(message, "TrialInitiation", model);
|
await AddMessageContentAsync(message, "TrialInitiation", model);
|
||||||
|
@ -43,12 +43,13 @@ public class NoopMailService : IMailService
|
|||||||
return Task.FromResult(0);
|
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);
|
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);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
@ -146,6 +146,7 @@ public class GlobalSettings : IGlobalSettings
|
|||||||
public string CloudRegion { get; set; }
|
public string CloudRegion { get; set; }
|
||||||
public string Vault { get; set; }
|
public string Vault { get; set; }
|
||||||
public string VaultWithHash => $"{Vault}/#";
|
public string VaultWithHash => $"{Vault}/#";
|
||||||
|
public string VaultWithHashAndSecretManagerProduct => $"{Vault}/#/sm";
|
||||||
|
|
||||||
public string Api
|
public string Api
|
||||||
{
|
{
|
||||||
|
@ -6,6 +6,7 @@ public interface IBaseServiceUriSettings
|
|||||||
string CloudRegion { get; set; }
|
string CloudRegion { get; set; }
|
||||||
string Vault { get; set; }
|
string Vault { get; set; }
|
||||||
string VaultWithHash { get; }
|
string VaultWithHash { get; }
|
||||||
|
string VaultWithHashAndSecretManagerProduct { get; }
|
||||||
string Api { get; set; }
|
string Api { get; set; }
|
||||||
public string Identity { get; set; }
|
public string Identity { get; set; }
|
||||||
public string Admin { get; set; }
|
public string Admin { get; set; }
|
||||||
|
@ -243,4 +243,15 @@ public class ReferenceEvent
|
|||||||
/// the value should be <see langword="null" />.
|
/// the value should be <see langword="null" />.
|
||||||
/// </value>
|
/// </value>
|
||||||
public string SignupInitiationPath { get; set; }
|
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;
|
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()
|
public static T LoadClassFromJsonData<T>(string jsonData) where T : new()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(jsonData))
|
if (string.IsNullOrWhiteSpace(jsonData))
|
||||||
|
@ -8,6 +8,26 @@ public class CipherDetails : CipherOrganizationDetails
|
|||||||
public bool Favorite { get; set; }
|
public bool Favorite { get; set; }
|
||||||
public bool Edit { get; set; }
|
public bool Edit { get; set; }
|
||||||
public bool ViewPassword { 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
|
public class CipherDetailsWithCollections : CipherDetails
|
||||||
|
@ -111,4 +111,28 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
|
|||||||
commandType: CommandType.StoredProcedure);
|
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();
|
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.AdminConsole.Repositories;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
using Bit.Core.Models.Data.Organizations;
|
using Bit.Core.Models.Data.Organizations;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@ -201,4 +204,104 @@ public class OrganizationUsersControllerTests
|
|||||||
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
||||||
model.Groups);
|
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.Data;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using DbUp;
|
using DbUp;
|
||||||
using DbUp.Helpers;
|
using DbUp.Helpers;
|
||||||
@ -12,39 +13,35 @@ public class DbMigrator
|
|||||||
{
|
{
|
||||||
private readonly string _connectionString;
|
private readonly string _connectionString;
|
||||||
private readonly ILogger<DbMigrator> _logger;
|
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;
|
_connectionString = connectionString;
|
||||||
_logger = logger;
|
_logger = logger ?? CreateLogger();
|
||||||
_masterConnectionString = new SqlConnectionStringBuilder(connectionString)
|
|
||||||
{
|
|
||||||
InitialCatalog = "master"
|
|
||||||
}.ConnectionString;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool MigrateMsSqlDatabaseWithRetries(bool enableLogging = true,
|
public bool MigrateMsSqlDatabaseWithRetries(bool enableLogging = true,
|
||||||
bool repeatable = false,
|
bool repeatable = false,
|
||||||
string folderName = MigratorConstants.DefaultMigrationsFolderName,
|
string folderName = MigratorConstants.DefaultMigrationsFolderName,
|
||||||
CancellationToken cancellationToken = default(CancellationToken))
|
bool dryRun = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var attempt = 1;
|
var attempt = 1;
|
||||||
|
|
||||||
while (attempt < 10)
|
while (attempt < 10)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var success = MigrateDatabase(enableLogging, repeatable, folderName, cancellationToken);
|
PrepareDatabase(cancellationToken);
|
||||||
|
|
||||||
|
var success = MigrateDatabase(enableLogging, repeatable, folderName, dryRun, cancellationToken);
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
catch (SqlException ex)
|
catch (SqlException ex)
|
||||||
{
|
{
|
||||||
if (ex.Message.Contains("Server is in script upgrade mode"))
|
if (ex.Message.Contains("Server is in script upgrade mode."))
|
||||||
{
|
{
|
||||||
attempt++;
|
attempt++;
|
||||||
_logger.LogInformation("Database is in script upgrade mode. " +
|
_logger.LogInformation($"Database is in script upgrade mode, trying again (attempt #{attempt}).");
|
||||||
$"Trying again (attempt #{attempt})...");
|
|
||||||
Thread.Sleep(20000);
|
Thread.Sleep(20000);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -56,17 +53,14 @@ public class DbMigrator
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool MigrateDatabase(bool enableLogging = true,
|
private void PrepareDatabase(CancellationToken cancellationToken = default)
|
||||||
bool repeatable = false,
|
|
||||||
string folderName = MigratorConstants.DefaultMigrationsFolderName,
|
|
||||||
CancellationToken cancellationToken = default(CancellationToken))
|
|
||||||
{
|
{
|
||||||
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;
|
var databaseName = new SqlConnectionStringBuilder(_connectionString).InitialCatalog;
|
||||||
if (string.IsNullOrWhiteSpace(databaseName))
|
if (string.IsNullOrWhiteSpace(databaseName))
|
||||||
@ -89,9 +83,10 @@ public class DbMigrator
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
using (var connection = new SqlConnection(_connectionString))
|
using (var connection = new SqlConnection(_connectionString))
|
||||||
{
|
{
|
||||||
// Rename old migration scripts to new namespace.
|
// rename old migration scripts to new namespace
|
||||||
var command = new SqlCommand(
|
var command = new SqlCommand(
|
||||||
"IF OBJECT_ID('Migration','U') IS NOT NULL " +
|
"IF OBJECT_ID('Migration','U') IS NOT NULL " +
|
||||||
"UPDATE [dbo].[Migration] SET " +
|
"UPDATE [dbo].[Migration] SET " +
|
||||||
@ -101,6 +96,21 @@ public class DbMigrator
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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
|
var builder = DeployChanges.To
|
||||||
.SqlDatabase(_connectionString)
|
.SqlDatabase(_connectionString)
|
||||||
.WithScriptsAndCodeEmbeddedInAssembly(Assembly.GetExecutingAssembly(),
|
.WithScriptsAndCodeEmbeddedInAssembly(Assembly.GetExecutingAssembly(),
|
||||||
@ -119,20 +129,26 @@ public class DbMigrator
|
|||||||
|
|
||||||
if (enableLogging)
|
if (enableLogging)
|
||||||
{
|
{
|
||||||
if (_logger != null)
|
builder.LogTo(new DbUpLogger(_logger));
|
||||||
{
|
|
||||||
builder.LogTo(new DbUpLogger(_logger));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.LogToConsole();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var upgrader = builder.Build();
|
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();
|
var result = upgrader.PerformUpgrade();
|
||||||
|
|
||||||
if (_logger != null)
|
if (enableLogging)
|
||||||
{
|
{
|
||||||
if (result.Successful)
|
if (result.Successful)
|
||||||
{
|
{
|
||||||
@ -145,6 +161,22 @@ public class DbMigrator
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
return result.Successful;
|
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 Bit.Core.Settings;
|
||||||
using System.Reflection;
|
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using DbUp;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Bit.Migrator;
|
namespace Bit.Migrator;
|
||||||
|
|
||||||
public class SqlServerDbMigrator : IDbMigrator
|
public class SqlServerDbMigrator : IDbMigrator
|
||||||
{
|
{
|
||||||
private readonly string _connectionString;
|
private readonly DbMigrator _migrator;
|
||||||
private readonly ILogger<SqlServerDbMigrator> _logger;
|
|
||||||
private readonly string _masterConnectionString;
|
|
||||||
|
|
||||||
public SqlServerDbMigrator(GlobalSettings globalSettings, ILogger<SqlServerDbMigrator> logger)
|
public SqlServerDbMigrator(GlobalSettings globalSettings, ILogger<DbMigrator> logger)
|
||||||
{
|
{
|
||||||
_connectionString = globalSettings.SqlServer.ConnectionString;
|
_migrator = new DbMigrator(globalSettings.SqlServer.ConnectionString, logger);
|
||||||
_logger = logger;
|
|
||||||
_masterConnectionString = new SqlConnectionStringBuilder(_connectionString)
|
|
||||||
{
|
|
||||||
InitialCatalog = "master"
|
|
||||||
}.ConnectionString;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool MigrateDatabase(bool enableLogging = true,
|
public bool MigrateDatabase(bool enableLogging = true,
|
||||||
CancellationToken cancellationToken = default(CancellationToken))
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (enableLogging && _logger != null)
|
return _migrator.MigrateMsSqlDatabaseWithRetries(enableLogging,
|
||||||
{
|
cancellationToken: cancellationToken);
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,4 +5,4 @@ LABEL com.bitwarden.product="bitwarden"
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY obj/build-output/publish .
|
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 Bit.Migrator;
|
||||||
using CommandDotNet;
|
using CommandDotNet;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
internal class Program
|
internal class Program
|
||||||
{
|
{
|
||||||
private static IDictionary<string, string> Parameters { get; set; }
|
|
||||||
|
|
||||||
private static int Main(string[] args)
|
private static int Main(string[] args)
|
||||||
{
|
{
|
||||||
return new AppRunner<Program>().Run(args);
|
return new AppRunner<Program>().Run(args);
|
||||||
@ -15,60 +12,28 @@ internal class Program
|
|||||||
public void Execute(
|
public void Execute(
|
||||||
[Operand(Description = "Database connection string")]
|
[Operand(Description = "Database connection string")]
|
||||||
string databaseConnectionString,
|
string databaseConnectionString,
|
||||||
[Option('v', "verbose", Description = "Enable verbose output of migrator logs")]
|
|
||||||
bool verbose = false,
|
|
||||||
[Option('r', "repeatable", Description = "Mark scripts as repeatable")]
|
[Option('r', "repeatable", Description = "Mark scripts as repeatable")]
|
||||||
bool repeatable = false,
|
bool repeatable = false,
|
||||||
[Option('f', "folder", Description = "Folder name of database scripts")]
|
[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>");
|
var migrator = new DbMigrator(databaseConnectionString);
|
||||||
Console.WriteLine("Usage: MsSqlMigratorUtility <database-connection-string> -v|--verbose (for verbose output of migrator logs)");
|
bool success;
|
||||||
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;
|
|
||||||
if (!string.IsNullOrWhiteSpace(folderName))
|
if (!string.IsNullOrWhiteSpace(folderName))
|
||||||
{
|
{
|
||||||
success = migrator.MigrateMsSqlDatabaseWithRetries(verbose, repeatable, folderName);
|
success = migrator.MigrateMsSqlDatabaseWithRetries(true, repeatable, folderName, dryRun);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
success = migrator.MigrateMsSqlDatabaseWithRetries(verbose, repeatable);
|
success = migrator.MigrateMsSqlDatabaseWithRetries(true, repeatable, dryRun: dryRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
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
|
Args = args
|
||||||
};
|
};
|
||||||
|
|
||||||
ParseParameters();
|
ParseParameters();
|
||||||
|
|
||||||
if (_context.Parameters.ContainsKey("q"))
|
if (_context.Parameters.ContainsKey("q"))
|
||||||
@ -155,7 +156,7 @@ public class Program
|
|||||||
|
|
||||||
if (_context.Parameters.ContainsKey("db"))
|
if (_context.Parameters.ContainsKey("db"))
|
||||||
{
|
{
|
||||||
MigrateDatabase();
|
PrepareAndMigrateDatabase();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -185,17 +186,19 @@ public class Program
|
|||||||
Console.WriteLine("\n");
|
Console.WriteLine("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MigrateDatabase(int attempt = 1)
|
private static void PrepareAndMigrateDatabase()
|
||||||
{
|
{
|
||||||
var vaultConnectionString = Helpers.GetValueFromEnvFile("global",
|
var vaultConnectionString = Helpers.GetValueFromEnvFile("global",
|
||||||
"globalSettings__sqlServer__connectionString");
|
"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()
|
private static bool ValidateInstallation()
|
||||||
|
Reference in New Issue
Block a user