mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
[PM-2383] Bulk collection assignment (#3919)
* [PM-2383] Add bulk add/remove collection cipher repository methods * [PM-2383] Add additional authorization helpers for CiphersControlle * [PM-2383] Add /bulk-collections endpoint to CiphersController.cs * [PM-2383] Add EF implementation for new CollectionCipherRepository methods * [PM-2383] Ensure V1 logic only applies when the flag is enabled for new bulk functionality
This commit is contained in:
parent
5dd1a9410a
commit
6a0f6e1dac
@ -45,6 +45,7 @@ public class CiphersController : Controller
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
|
||||
private bool UseFlexibleCollections =>
|
||||
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
|
||||
@ -61,7 +62,8 @@ public class CiphersController : Controller
|
||||
GlobalSettings globalSettings,
|
||||
IFeatureService featureService,
|
||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||
IApplicationCacheService applicationCacheService)
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
_cipherRepository = cipherRepository;
|
||||
_collectionCipherRepository = collectionCipherRepository;
|
||||
@ -75,6 +77,7 @@ public class CiphersController : Controller
|
||||
_featureService = featureService;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_collectionRepository = collectionRepository;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -342,6 +345,45 @@ public class CiphersController : Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// </summary>
|
||||
private async Task<bool> CanEditAllCiphersAsync(Guid organizationId)
|
||||
{
|
||||
var org = _currentContext.GetOrganization(organizationId);
|
||||
|
||||
// If not using V1, owners, admins, and users with EditAnyCollection permissions, and providers can always edit all ciphers
|
||||
if (!await UseFlexibleCollectionsV1Async(organizationId))
|
||||
{
|
||||
return org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.EditAnyCollection: true } ||
|
||||
await _currentContext.ProviderUserForOrgAsync(organizationId);
|
||||
}
|
||||
|
||||
// Custom users with EditAnyCollection permissions can always edit all ciphers
|
||||
if (org is { Type: OrganizationUserType.Custom, Permissions.EditAnyCollection: true })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
|
||||
|
||||
// Owners/Admins can only edit all ciphers if the organization has the setting enabled
|
||||
if (orgAbility is { AllowAdminAccessToAllCollectionItems: true } && org is
|
||||
{ Type: OrganizationUserType.Admin or OrganizationUserType.Owner })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Provider users can edit all ciphers in V1 (to change later)
|
||||
if (await _currentContext.ProviderUserForOrgAsync(organizationId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// </summary>
|
||||
@ -388,6 +430,97 @@ public class CiphersController : Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// </summary>
|
||||
private async Task<bool> CanEditCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
|
||||
{
|
||||
// If the user can edit all ciphers for the organization, just check they all belong to the org
|
||||
if (await CanEditAllCiphersAsync(organizationId))
|
||||
{
|
||||
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
|
||||
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
|
||||
|
||||
// Ensure all requested ciphers are in orgCiphers
|
||||
if (cipherIds.Any(c => !orgCiphers.ContainsKey(c)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// The user cannot access any ciphers for the organization, we're done
|
||||
if (!await CanAccessOrganizationCiphersAsync(organizationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
// Select all editable ciphers for this user belonging to the organization
|
||||
var editableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(userId, true))
|
||||
.Where(c => c.OrganizationId == organizationId && c.UserId == null && c.Edit).ToList();
|
||||
|
||||
// Special case for unassigned ciphers
|
||||
if (await CanAccessUnassignedCiphersAsync(organizationId))
|
||||
{
|
||||
var unassignedCiphers =
|
||||
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
|
||||
organizationId));
|
||||
|
||||
// Users that can access unassigned ciphers can also edit them
|
||||
editableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Edit = true }));
|
||||
}
|
||||
|
||||
var editableOrgCiphers = editableOrgCipherList
|
||||
.ToDictionary(c => c.Id);
|
||||
|
||||
if (cipherIds.Any(c => !editableOrgCiphers.ContainsKey(c)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
|
||||
/// This likely belongs to the BulkCollectionAuthorizationHandler
|
||||
/// </summary>
|
||||
private async Task<bool> CanEditItemsInCollections(Guid organizationId, IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
if (await CanEditAllCiphersAsync(organizationId))
|
||||
{
|
||||
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
|
||||
var orgCollections = (await _collectionRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
|
||||
|
||||
// Ensure all requested collections are in orgCollections
|
||||
if (collectionIds.Any(c => !orgCollections.ContainsKey(c)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!await CanAccessOrganizationCiphersAsync(organizationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var editableCollections = (await _collectionRepository.GetManyByUserIdAsync(userId, true))
|
||||
.Where(c => c.OrganizationId == organizationId && !c.ReadOnly)
|
||||
.ToDictionary(c => c.Id);
|
||||
|
||||
if (collectionIds.Any(c => !editableCollections.ContainsKey(c)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[HttpPut("{id}/partial")]
|
||||
[HttpPost("{id}/partial")]
|
||||
public async Task<CipherResponseModel> PutPartial(Guid id, [FromBody] CipherPartialRequestModel model)
|
||||
@ -457,6 +590,33 @@ public class CiphersController : Controller
|
||||
model.CollectionIds.Select(c => new Guid(c)), userId, true);
|
||||
}
|
||||
|
||||
[HttpPost("bulk-collections")]
|
||||
public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model)
|
||||
{
|
||||
var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(model.OrganizationId);
|
||||
|
||||
// Only available for organizations with flexible collections
|
||||
if (orgAbility is null or { FlexibleCollections: false })
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!await CanEditCiphersAsync(model.OrganizationId, model.CipherIds) ||
|
||||
!await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (model.RemoveCollections)
|
||||
{
|
||||
await _collectionCipherRepository.RemoveCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _collectionCipherRepository.AddCollectionsForManyCiphersAsync(model.OrganizationId, model.CipherIds, model.CollectionIds);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[HttpPost("{id}/delete")]
|
||||
public async Task Delete(Guid id)
|
||||
|
@ -0,0 +1,15 @@
|
||||
namespace Bit.Api.Vault.Models.Request;
|
||||
|
||||
public class CipherBulkUpdateCollectionsRequestModel
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
|
||||
public IEnumerable<Guid> CipherIds { get; set; }
|
||||
|
||||
public IEnumerable<Guid> CollectionIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If true, the collections will be removed from the ciphers. Otherwise, they will be added.
|
||||
/// </summary>
|
||||
public bool RemoveCollections { get; set; }
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -8,6 +8,26 @@ public class CipherDetails : CipherOrganizationDetails
|
||||
public bool Favorite { get; set; }
|
||||
public bool Edit { get; set; }
|
||||
public bool ViewPassword { get; set; }
|
||||
|
||||
public CipherDetails() { }
|
||||
|
||||
public CipherDetails(CipherOrganizationDetails cipher)
|
||||
{
|
||||
Id = cipher.Id;
|
||||
UserId = cipher.UserId;
|
||||
OrganizationId = cipher.OrganizationId;
|
||||
Type = cipher.Type;
|
||||
Data = cipher.Data;
|
||||
Favorites = cipher.Favorites;
|
||||
Folders = cipher.Folders;
|
||||
Attachments = cipher.Attachments;
|
||||
CreationDate = cipher.CreationDate;
|
||||
RevisionDate = cipher.RevisionDate;
|
||||
DeletedDate = cipher.DeletedDate;
|
||||
Reprompt = cipher.Reprompt;
|
||||
Key = cipher.Key;
|
||||
OrganizationUseTotp = cipher.OrganizationUseTotp;
|
||||
}
|
||||
}
|
||||
|
||||
public class CipherDetailsWithCollections : CipherDetails
|
||||
|
@ -111,4 +111,28 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
|
||||
IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[CollectionCipher_AddCollectionsForManyCiphers]",
|
||||
new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
|
||||
IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[CollectionCipher_RemoveCollectionsForManyCiphers]",
|
||||
new { CipherIds = cipherIds.ToGuidIdArrayTVP(), OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP() },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -248,4 +248,47 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
|
||||
IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var availableCollections = await (from c in dbContext.Collections
|
||||
join o in dbContext.Organizations on c.OrganizationId equals o.Id
|
||||
where o.Id == organizationId && o.Enabled
|
||||
select c).ToListAsync();
|
||||
|
||||
var currentCollectionCiphers = await (from cc in dbContext.CollectionCiphers
|
||||
where cipherIds.Contains(cc.CipherId)
|
||||
select cc).ToListAsync();
|
||||
|
||||
var insertData = from collectionId in collectionIds
|
||||
from cipherId in cipherIds
|
||||
where
|
||||
availableCollections.Select(c => c.Id).Contains(collectionId) &&
|
||||
!currentCollectionCiphers.Any(cc => cc.CipherId == cipherId && cc.CollectionId == collectionId)
|
||||
select new Models.CollectionCipher { CollectionId = collectionId, CipherId = cipherId, };
|
||||
|
||||
await dbContext.AddRangeAsync(insertData);
|
||||
await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemoveCollectionsForManyCiphersAsync(Guid organizationId, IEnumerable<Guid> cipherIds,
|
||||
IEnumerable<Guid> collectionIds)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var currentCollectionCiphersToBeRemoved = await (from cc in dbContext.CollectionCiphers
|
||||
where cipherIds.Contains(cc.CipherId) && collectionIds.Contains(cc.CollectionId)
|
||||
select cc).ToListAsync();
|
||||
dbContext.RemoveRange(currentCollectionCiphersToBeRemoved);
|
||||
await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,56 @@
|
||||
CREATE PROCEDURE [dbo].[CollectionCipher_AddCollectionsForManyCiphers]
|
||||
@CipherIds AS [dbo].[GuidIdArray] READONLY,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CollectionIds AS [dbo].[GuidIdArray] READONLY
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
CREATE TABLE #AvailableCollections (
|
||||
[Id] UNIQUEIDENTIFIER
|
||||
)
|
||||
|
||||
INSERT INTO #AvailableCollections
|
||||
SELECT
|
||||
C.[Id]
|
||||
FROM
|
||||
[dbo].[Collection] C
|
||||
INNER JOIN
|
||||
[dbo].[Organization] O ON O.[Id] = C.[OrganizationId]
|
||||
WHERE
|
||||
O.[Id] = @OrganizationId AND O.[Enabled] = 1
|
||||
|
||||
IF (SELECT COUNT(1) FROM #AvailableCollections) < 1
|
||||
BEGIN
|
||||
-- No collections available
|
||||
RETURN
|
||||
END
|
||||
|
||||
;WITH [SourceCollectionCipherCTE] AS(
|
||||
SELECT
|
||||
[Collection].[Id] AS [CollectionId],
|
||||
[Cipher].[Id] AS [CipherId]
|
||||
FROM
|
||||
@CollectionIds AS [Collection]
|
||||
CROSS JOIN
|
||||
@CipherIds AS [Cipher]
|
||||
WHERE
|
||||
[Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections)
|
||||
)
|
||||
MERGE
|
||||
[CollectionCipher] AS [Target]
|
||||
USING
|
||||
[SourceCollectionCipherCTE] AS [Source]
|
||||
ON
|
||||
[Target].[CollectionId] = [Source].[CollectionId]
|
||||
AND [Target].[CipherId] = [Source].[CipherId]
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT VALUES
|
||||
(
|
||||
[Source].[CollectionId],
|
||||
[Source].[CipherId]
|
||||
)
|
||||
;
|
||||
|
||||
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
|
||||
END
|
@ -0,0 +1,26 @@
|
||||
CREATE PROCEDURE [dbo].[CollectionCipher_RemoveCollectionsForManyCiphers]
|
||||
@CipherIds AS [dbo].[GuidIdArray] READONLY,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CollectionIds AS [dbo].[GuidIdArray] READONLY
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DECLARE @BatchSize INT = 100
|
||||
|
||||
WHILE @BatchSize > 0
|
||||
BEGIN
|
||||
BEGIN TRANSACTION CollectionCipher_DeleteMany
|
||||
DELETE TOP(@BatchSize)
|
||||
FROM
|
||||
[dbo].[CollectionCipher]
|
||||
WHERE
|
||||
[CipherId] IN (SELECT [Id] FROM @CipherIds) AND
|
||||
[CollectionId] IN (SELECT [Id] FROM @CollectionIds)
|
||||
|
||||
SET @BatchSize = @@ROWCOUNT
|
||||
COMMIT TRANSACTION CollectionCipher_DeleteMany
|
||||
END
|
||||
|
||||
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId
|
||||
END
|
@ -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
|
Loading…
Reference in New Issue
Block a user