diff --git a/src/Api/Controllers/CiphersController.cs b/src/Api/Controllers/CiphersController.cs index 69fe8aef8f..1b853295e7 100644 --- a/src/Api/Controllers/CiphersController.cs +++ b/src/Api/Controllers/CiphersController.cs @@ -346,6 +346,36 @@ namespace Bit.Api.Controllers string.IsNullOrWhiteSpace(model.FolderId) ? (Guid?)null : new Guid(model.FolderId), userId); } + [HttpPut("share")] + [HttpPost("share")] + public async Task PutShareMany([FromBody]CipherBulkShareRequestModel model) + { + var organizationId = new Guid(model.Ciphers.First().OrganizationId); + if(!_currentContext.OrganizationUser(organizationId)) + { + throw new NotFoundException(); + } + + var userId = _userService.GetProperUserId(User).Value; + var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, false); + var ciphersDict = ciphers.ToDictionary(c => c.Id); + + var shareCiphers = new List(); + foreach(var cipher in model.Ciphers) + { + var cipherGuid = new Guid(cipher.Id); + if(!ciphersDict.ContainsKey(cipherGuid)) + { + throw new BadRequestException("Trying to share ciphers that you do not own."); + } + + shareCiphers.Add(cipher.ToCipher(ciphersDict[cipherGuid])); + } + + await _cipherService.ShareManyAsync(shareCiphers, organizationId, + model.CollectionIds.Select(c => new Guid(c)), userId); + } + [HttpPost("purge")] public async Task PostPurge([FromBody]CipherPurgeRequestModel model) { diff --git a/src/Core/Models/Api/Request/CipherRequestModel.cs b/src/Core/Models/Api/Request/CipherRequestModel.cs index 844fdb30aa..952f765196 100644 --- a/src/Core/Models/Api/Request/CipherRequestModel.cs +++ b/src/Core/Models/Api/Request/CipherRequestModel.cs @@ -182,4 +182,50 @@ namespace Bit.Core.Models.Api public IEnumerable Ids { get; set; } public string FolderId { get; set; } } + + public class CipherBulkShareRequestModel + { + [Required] + public IEnumerable CollectionIds { get; set; } + [Required] + public IEnumerable Ciphers { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if(!Ciphers?.Any() ?? false) + { + yield return new ValidationResult("You must select at least one cipher.", + new string[] { nameof(Ciphers) }); + } + else + { + var allHaveIds = true; + var organizationIds = new HashSet(); + foreach(var c in Ciphers) + { + organizationIds.Add(c.OrganizationId); + if(allHaveIds) + { + allHaveIds = !(string.IsNullOrWhiteSpace(c.Id) || string.IsNullOrWhiteSpace(c.OrganizationId)); + } + } + + if(!allHaveIds) + { + yield return new ValidationResult("All Ciphers must have an Id and OrganizationId.", + new string[] { nameof(Ciphers) }); + } + else if(organizationIds.Count != 1) + { + yield return new ValidationResult("All ciphers must be for the same organization."); + } + } + + if(!CollectionIds?.Any() ?? false) + { + yield return new ValidationResult("You must select at least one collection.", + new string[] { nameof(CollectionIds) }); + } + } + } } diff --git a/src/Core/Repositories/ICipherRepository.cs b/src/Core/Repositories/ICipherRepository.cs index c6843e92b0..bf937acf4f 100644 --- a/src/Core/Repositories/ICipherRepository.cs +++ b/src/Core/Repositories/ICipherRepository.cs @@ -25,6 +25,7 @@ namespace Bit.Core.Repositories Task MoveAsync(IEnumerable ids, Guid? folderId, Guid userId); Task DeleteByUserIdAsync(Guid userId); Task UpdateUserKeysAndCiphersAsync(User user, IEnumerable ciphers, IEnumerable folders); + Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers); Task CreateAsync(IEnumerable ciphers, IEnumerable folders); Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers); diff --git a/src/Core/Repositories/ICollectionCipherRepository.cs b/src/Core/Repositories/ICollectionCipherRepository.cs index 53d6a940d0..03409baf18 100644 --- a/src/Core/Repositories/ICollectionCipherRepository.cs +++ b/src/Core/Repositories/ICollectionCipherRepository.cs @@ -12,5 +12,7 @@ namespace Bit.Core.Repositories Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId); Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable collectionIds); Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable collectionIds); + Task UpdateCollectionsForCiphersAsync(IEnumerable cipherIds, Guid userId, Guid organizationId, + IEnumerable collectionIds); } } diff --git a/src/Core/Repositories/SqlServer/CipherRepository.cs b/src/Core/Repositories/SqlServer/CipherRepository.cs index 5779541b55..1ece812099 100644 --- a/src/Core/Repositories/SqlServer/CipherRepository.cs +++ b/src/Core/Repositories/SqlServer/CipherRepository.cs @@ -346,6 +346,86 @@ namespace Bit.Core.Repositories.SqlServer return Task.FromResult(0); } + public async Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers) + { + if(!ciphers.Any()) + { + return; + } + + using(var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using(var transaction = connection.BeginTransaction()) + { + try + { + // 1. Create temp tables to bulk copy into. + + var sqlCreateTemp = @" + SELECT TOP 0 * + INTO #TempCipher + FROM [dbo].[Cipher]"; + + using(var cmd = new SqlCommand(sqlCreateTemp, connection, transaction)) + { + cmd.ExecuteNonQuery(); + } + + // 2. Bulk copy into temp tables. + using(var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) + { + bulkCopy.DestinationTableName = "#TempCipher"; + var dataTable = BuildCiphersTable(ciphers); + bulkCopy.WriteToServer(dataTable); + } + + // 3. Insert into real tables from temp tables and clean up. + + // Intentionally not including Favorites, Folders, and CreationDate + // since those are not meant to be bulk updated at this time + var sql = @" + UPDATE + [dbo].[Cipher] + SET + [UserId] = TC.[UserId], + [OrganizationId] = TC.[OrganizationId], + [Type] = TC.[Type], + [Data] = TC.[Data], + [Attachments] = TC.[Attachments], + [RevisionDate] = TC.[RevisionDate] + FROM + [dbo].[Cipher] C + INNER JOIN + #TempCipher TC ON C.Id = TC.Id + WHERE + C.[UserId] = @UserId + + DROP TABLE #TempCipher"; + + using(var cmd = new SqlCommand(sql, connection, transaction)) + { + cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId; + cmd.ExecuteNonQuery(); + } + + await connection.ExecuteAsync( + $"[{Schema}].[User_BumpAccountRevisionDate]", + new { Id = userId }, + commandType: CommandType.StoredProcedure, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task CreateAsync(IEnumerable ciphers, IEnumerable folders) { if(!ciphers.Any()) diff --git a/src/Core/Repositories/SqlServer/CollectionCipherRepository.cs b/src/Core/Repositories/SqlServer/CollectionCipherRepository.cs index dc1411b01c..fd77453d28 100644 --- a/src/Core/Repositories/SqlServer/CollectionCipherRepository.cs +++ b/src/Core/Repositories/SqlServer/CollectionCipherRepository.cs @@ -80,5 +80,23 @@ namespace Bit.Core.Repositories.SqlServer commandType: CommandType.StoredProcedure); } } + + public async Task UpdateCollectionsForCiphersAsync(IEnumerable cipherIds, Guid userId, + Guid organizationId, IEnumerable collectionIds) + { + using(var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteAsync( + "[dbo].[CollectionCipher_UpdateCollectionsForCiphers]", + new + { + CipherIds = cipherIds.ToGuidIdArrayTVP(), + UserId = userId, + OrganizationId = organizationId, + CollectionIds = collectionIds.ToGuidIdArrayTVP() + }, + commandType: CommandType.StoredProcedure); + } + } } } diff --git a/src/Core/Services/ICipherService.cs b/src/Core/Services/ICipherService.cs index b81c76e139..d3d2285115 100644 --- a/src/Core/Services/ICipherService.cs +++ b/src/Core/Services/ICipherService.cs @@ -22,6 +22,7 @@ namespace Bit.Core.Services Task SaveFolderAsync(Folder folder); Task DeleteFolderAsync(Folder folder); Task ShareAsync(Cipher originalCipher, Cipher cipher, Guid organizationId, IEnumerable collectionIds, Guid userId); + Task ShareManyAsync(IEnumerable ciphers, Guid organizationId, IEnumerable collectionIds, Guid sharingUserId); Task SaveCollectionsAsync(Cipher cipher, IEnumerable collectionIds, Guid savingUserId, bool orgAdmin); Task ImportCiphersAsync(List folders, List ciphers, IEnumerable> folderRelationships); diff --git a/src/Core/Services/Implementations/CipherService.cs b/src/Core/Services/Implementations/CipherService.cs index 95436380e9..69897d691d 100644 --- a/src/Core/Services/Implementations/CipherService.cs +++ b/src/Core/Services/Implementations/CipherService.cs @@ -401,6 +401,52 @@ namespace Bit.Core.Services await _pushService.PushSyncCipherUpdateAsync(cipher); } + public async Task ShareManyAsync(IEnumerable ciphers, Guid organizationId, + IEnumerable collectionIds, Guid sharingUserId) + { + var cipherIds = new List(); + foreach(var cipher in ciphers) + { + if(cipher.Id == default(Guid)) + { + throw new BadRequestException("All ciphers must already exist."); + } + + if(cipher.OrganizationId.HasValue) + { + throw new BadRequestException("One or more ciphers already belong to an organization."); + } + + if(!cipher.UserId.HasValue || cipher.UserId.Value != sharingUserId) + { + throw new BadRequestException("One or more ciphers do not belong to you."); + } + + if(!string.IsNullOrWhiteSpace(cipher.Attachments)) + { + throw new BadRequestException("One or more ciphers have attachments."); + } + + cipher.UserId = null; + cipher.OrganizationId = organizationId; + cipher.RevisionDate = DateTime.UtcNow; + cipherIds.Add(cipher.Id); + } + + await _cipherRepository.UpdateCiphersAsync(sharingUserId, ciphers); + await _collectionCipherRepository.UpdateCollectionsForCiphersAsync(cipherIds, sharingUserId, + organizationId, collectionIds); + + // TODO: move this to a single event? + foreach(var cipher in ciphers) + { + await _eventService.LogCipherEventAsync(cipher, Enums.EventType.Cipher_Shared); + } + + // push + await _pushService.PushSyncCiphersAsync(sharingUserId); + } + public async Task SaveCollectionsAsync(Cipher cipher, IEnumerable collectionIds, Guid savingUserId, bool orgAdmin) { if(cipher.Id == default(Guid)) diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index c30cd482bd..de483ccba5 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -225,5 +225,6 @@ + \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsForCiphers.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsForCiphers.sql new file mode 100644 index 0000000000..4e0dccd646 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsForCiphers.sql @@ -0,0 +1,64 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_UpdateCollectionsForCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @UserId 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 + [Organization] O ON O.[Id] = C.[OrganizationId] + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[OrganizationId] = O.[Id] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND OU.[AccessAll] = 0 AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON G.[AccessAll] = 0 AND CG.[GroupId] = GU.[GroupId] + WHERE + O.[Id] = @OrganizationId + AND O.[Enabled] = 1 + AND OU.[Status] = 2 -- Confirmed + AND ( + OU.[AccessAll] = 1 + OR CU.[ReadOnly] = 0 + OR G.[AccessAll] = 1 + OR CG.[ReadOnly] = 0 + ) + + IF (SELECT COUNT(1) FROM #AvailableCollections) < 1 + BEGIN + -- No writable collections available to share with in this organization. + RETURN + END + + INSERT INTO [dbo].[CollectionCipher] + ( + [CollectionId], + [CipherId] + ) + SELECT + [Collection].[Id], + [Cipher].[Id] + FROM + @CollectionIds [Collection] + INNER JOIN + @CipherIds [Cipher] ON 1 = 1 + WHERE + [Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql index 2d082ab2d8..16d89913bf 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadByUserId.sql @@ -12,8 +12,8 @@ BEGIN OR G.[AccessAll] = 1 OR CU.[ReadOnly] = 0 OR CG.[ReadOnly] = 0 - THEN 1 - ELSE 0 + THEN 0 + ELSE 1 END [ReadOnly] FROM [dbo].[CollectionView] C diff --git a/src/Sql/dbo/Stored Procedures/Organization_UpdateStorage.sql b/src/Sql/dbo/Stored Procedures/Organization_UpdateStorage.sql index ece4bc9464..5640ee4474 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_UpdateStorage.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_UpdateStorage.sql @@ -6,13 +6,13 @@ BEGIN DECLARE @Storage BIGINT - CREATE TABLE #Temp + CREATE TABLE #OrgStorageUpdateTemp ( [Id] UNIQUEIDENTIFIER NOT NULL, [Attachments] VARCHAR(MAX) NULL ) - INSERT INTO #Temp + INSERT INTO #OrgStorageUpdateTemp SELECT [Id], [Attachments] @@ -32,14 +32,14 @@ BEGIN OPENJSON([Attachments]) ) [Size] FROM - #Temp + #OrgStorageUpdateTemp ) SELECT @Storage = SUM([Size]) FROM [CTE] - DROP TABLE #Temp + DROP TABLE #OrgStorageUpdateTemp UPDATE [dbo].[Organization] diff --git a/src/Sql/dbo/Stored Procedures/User_UpdateStorage.sql b/src/Sql/dbo/Stored Procedures/User_UpdateStorage.sql index 9d59c750fb..a58e2be728 100644 --- a/src/Sql/dbo/Stored Procedures/User_UpdateStorage.sql +++ b/src/Sql/dbo/Stored Procedures/User_UpdateStorage.sql @@ -6,13 +6,13 @@ BEGIN DECLARE @Storage BIGINT - CREATE TABLE #Temp + CREATE TABLE #UserStorageUpdateTemp ( [Id] UNIQUEIDENTIFIER NOT NULL, [Attachments] VARCHAR(MAX) NULL ) - INSERT INTO #Temp + INSERT INTO #UserStorageUpdateTemp SELECT [Id], [Attachments] @@ -31,14 +31,14 @@ BEGIN OPENJSON([Attachments]) ) [Size] FROM - #Temp + #UserStorageUpdateTemp ) SELECT @Storage = SUM([CTE].[Size]) FROM [CTE] - DROP TABLE #Temp + DROP TABLE #UserStorageUpdateTemp UPDATE [dbo].[User] diff --git a/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql b/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql index 44eb12a45d..4b32eb4011 100644 --- a/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql +++ b/util/Setup/DbScripts/2018-06-11_00_WebVaultUpdates.sql @@ -18,8 +18,8 @@ BEGIN OR G.[AccessAll] = 1 OR CU.[ReadOnly] = 0 OR CG.[ReadOnly] = 0 - THEN 1 - ELSE 0 + THEN 0 + ELSE 1 END [ReadOnly] FROM [dbo].[CollectionView] C @@ -62,13 +62,13 @@ BEGIN DECLARE @Storage BIGINT - CREATE TABLE #Temp + CREATE TABLE #OrgStorageUpdateTemp ( [Id] UNIQUEIDENTIFIER NOT NULL, [Attachments] VARCHAR(MAX) NULL ) - INSERT INTO #Temp + INSERT INTO #OrgStorageUpdateTemp SELECT [Id], [Attachments] @@ -88,14 +88,14 @@ BEGIN OPENJSON([Attachments]) ) [Size] FROM - #Temp + #OrgStorageUpdateTemp ) SELECT @Storage = SUM([Size]) FROM [CTE] - DROP TABLE #Temp + DROP TABLE #OrgStorageUpdateTemp UPDATE [dbo].[Organization] @@ -121,13 +121,13 @@ BEGIN DECLARE @Storage BIGINT - CREATE TABLE #Temp + CREATE TABLE #UserStorageUpdateTemp ( [Id] UNIQUEIDENTIFIER NOT NULL, [Attachments] VARCHAR(MAX) NULL ) - INSERT INTO #Temp + INSERT INTO #UserStorageUpdateTemp SELECT [Id], [Attachments] @@ -146,14 +146,14 @@ BEGIN OPENJSON([Attachments]) ) [Size] FROM - #Temp + #UserStorageUpdateTemp ) SELECT @Storage = SUM([CTE].[Size]) FROM [CTE] - DROP TABLE #Temp + DROP TABLE #UserStorageUpdateTemp UPDATE [dbo].[User] @@ -164,3 +164,75 @@ BEGIN [Id] = @Id END GO + +IF OBJECT_ID('[dbo].[CollectionCipher_UpdateCollectionsForCiphers]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[CollectionCipher_UpdateCollectionsForCiphers] +END +GO + +CREATE PROCEDURE [dbo].[CollectionCipher_UpdateCollectionsForCiphers] + @CipherIds AS [dbo].[GuidIdArray] READONLY, + @OrganizationId UNIQUEIDENTIFIER, + @UserId 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 + [Organization] O ON O.[Id] = C.[OrganizationId] + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[OrganizationId] = O.[Id] AND OU.[UserId] = @UserId + LEFT JOIN + [dbo].[CollectionUser] CU ON OU.[AccessAll] = 0 AND CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND OU.[AccessAll] = 0 AND GU.[OrganizationUserId] = OU.[Id] + LEFT JOIN + [dbo].[Group] G ON G.[Id] = GU.[GroupId] + LEFT JOIN + [dbo].[CollectionGroup] CG ON G.[AccessAll] = 0 AND CG.[GroupId] = GU.[GroupId] + WHERE + O.[Id] = @OrganizationId + AND O.[Enabled] = 1 + AND OU.[Status] = 2 -- Confirmed + AND ( + OU.[AccessAll] = 1 + OR CU.[ReadOnly] = 0 + OR G.[AccessAll] = 1 + OR CG.[ReadOnly] = 0 + ) + + IF (SELECT COUNT(1) FROM #AvailableCollections) < 1 + BEGIN + -- No writable collections available to share with in this organization. + RETURN + END + + INSERT INTO [dbo].[CollectionCipher] + ( + [CollectionId], + [CipherId] + ) + SELECT + [Collection].[Id], + [Cipher].[Id] + FROM + @CollectionIds [Collection] + INNER JOIN + @CipherIds [Cipher] ON 1 = 1 + WHERE + [Collection].[Id] IN (SELECT [Id] FROM #AvailableCollections) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO