From 926e786f82f73d5c2f6469cae6b92f7e6ab0cf43 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 17 Feb 2025 16:32:23 -0600 Subject: [PATCH] Added bulk procedure for saving users, collections and groups from inviting. Added test to validate Ef and Sproc --- .../InviteOrganizationUsersCommand.cs | 19 +++- .../Models/CreateOrganizationUser.cs | 35 +++++++ .../Models/OrganizationUserInvite.cs | 28 +++--- .../Models/OrganizationUserInviteDto.cs | 10 +- .../OrganizationUserSingleEmailInvite.cs | 4 +- .../IOrganizationUserRepository.cs | 3 + src/Core/Models/Commands/CommandResult.cs | 17 +++- .../OrganizationUserRepository.cs | 29 ++++++ .../OrganizationUserRepository.cs | 25 +++++ ...onUser_CreateManyWithCollectionsGroups.sql | 97 +++++++++++++++++++ .../InviteOrganizationUserRequestTests.cs | 2 +- .../InviteOrganizationUsersRequestTests.cs | 2 +- ...SecretsManagerInviteUserValidationTests.cs | 4 +- .../OrganizationUserRepositoryTests.cs | 73 ++++++++++++++ ...Users_CreateManyUsersCollectionsGroups.sql | 97 +++++++++++++++++++ 15 files changed, 416 insertions(+), 29 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql create mode 100644 util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 6c7ec8b777..e0f90dfe8a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Commands; using Bit.Core.Repositories; using Bit.Core.Services; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.CreateOrganizationUserExtensions; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -22,6 +23,11 @@ public class InviteOrganizationUsersCommand(IEventService eventService, { var result = await InviteOrganizationUsersAsync(InviteOrganizationUsersRequest.Create(request)); + if (result is Failure> failure) + { + return new Failure(failure.ErrorMessage); + } + if (result.Value.Any()) { (OrganizationUser User, EventType type, EventSystemUser system, DateTime performedAt) log = (result.Value.First(), EventType.OrganizationUser_Invited, EventSystemUser.SCIM, request.PerformedAt.UtcDateTime); @@ -29,7 +35,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, await eventService.LogOrganizationUserEventsAsync([log]); } - return new CommandResult(result.Value.FirstOrDefault()); + return new Success(result.Value.FirstOrDefault()); } private async Task>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) @@ -41,7 +47,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, var invitesToSend = request.Invites .SelectMany(invite => invite.Emails .Where(email => !existingEmails.Contains(email)) - .Select(email => OrganizationUserInviteDto.Create(email, invite)) + .Select(email => OrganizationUserInviteDto.Create(email, invite, request.Organization.OrganizationId)) ); // Validate we can add those seats @@ -55,8 +61,17 @@ public class InviteOrganizationUsersCommand(IEventService eventService, OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.Organization.OrganizationId) }); + if (!validationResult.IsValid) + { + return new Failure>(validationResult.ErrorMessageString); + } + + var organizationUserCollection = invitesToSend + .Select(MapToDataModel(request.PerformedAt)); + try { + await organizationUserRepository.CreateManyAsync(organizationUserCollection); // save organization users // org users // collections diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs new file mode 100644 index 0000000000..cef2c94e00 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs @@ -0,0 +1,35 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; + +public class CreateOrganizationUser +{ + public OrganizationUser User { get; set; } + public CollectionAccessSelection[] Collections { get; set; } = []; + public Guid[] Groups { get; set; } = []; +} + +public static class CreateOrganizationUserExtensions +{ + public static Func MapToDataModel(DateTimeOffset performedAt) => + o => new CreateOrganizationUser + { + User = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = o.OrganizationId, + Email = o.Email.ToLowerInvariant(), + Type = o.Type, + Status = OrganizationUserStatusType.Invited, + AccessSecretsManager = o.AccessSecretsManager, + ExternalId = string.IsNullOrWhiteSpace(o.ExternalId) ? null : o.ExternalId, + CreationDate = performedAt.UtcDateTime, + RevisionDate = performedAt.UtcDateTime + }, + Collections = o.AccessibleCollections, + Groups = o.Groups + }; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs index d11b7d599d..025107183b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs @@ -10,12 +10,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class OrganizationUserInvite { public string[] Emails { get; private init; } = []; - public Guid[] AccessibleCollections { get; private init; } = []; + public CollectionAccessSelection[] AccessibleCollections { get; private init; } = []; public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; public Permissions Permissions { get; private init; } = new(); public string ExternalId { get; private init; } = string.Empty; public bool AccessSecretsManager { get; private init; } + public Guid[] Groups { get; private init; } = []; public static OrganizationUserInvite Create(string[] emails, IEnumerable accessibleCollections, @@ -24,26 +25,13 @@ public class OrganizationUserInvite string externalId, bool accessSecretsManager) { + ValidateEmailAddresses(emails); + if (accessibleCollections?.Any(ValidateCollectionConfiguration) ?? false) { throw new BadRequestException(InvalidCollectionConfigurationErrorMessage); } - return Create(emails, accessibleCollections?.Select(x => x.Id), type, permissions, externalId, accessSecretsManager); - } - - public static OrganizationUserInvite Create(OrganizationUserSingleEmailInvite invite, string externalId) => - Create([invite.Email], - invite.AccessibleCollections, - invite.Type, - invite.Permissions, - externalId, - invite.AccessSecretsManager); - - private static OrganizationUserInvite Create(string[] emails, IEnumerable accessibleCollections, OrganizationUserType type, Permissions permissions, string externalId, bool accessSecretsManager) - { - ValidateEmailAddresses(emails); - return new OrganizationUserInvite { Emails = emails, @@ -55,6 +43,14 @@ public class OrganizationUserInvite }; } + public static OrganizationUserInvite Create(OrganizationUserSingleEmailInvite invite, string externalId) => + Create([invite.Email], + invite.AccessibleCollections, + invite.Type, + invite.Permissions, + externalId, + invite.AccessSecretsManager); + private static void ValidateEmailAddresses(string[] emails) { foreach (var email in emails) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteDto.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteDto.cs index b7265e6caf..b8da695f4f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteDto.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteDto.cs @@ -6,13 +6,15 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class OrganizationUserInviteDto { public string Email { get; private init; } = string.Empty; - public Guid[] AccessibleCollections { get; private init; } = []; + public CollectionAccessSelection[] AccessibleCollections { get; private init; } = []; public string ExternalId { get; private init; } = string.Empty; public Permissions Permissions { get; private init; } = new(); public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; public bool AccessSecretsManager { get; private init; } + public Guid OrganizationId { get; private init; } = Guid.Empty; + public Guid[] Groups { get; private init; } = []; - public static OrganizationUserInviteDto Create(string email, OrganizationUserInvite invite) + public static OrganizationUserInviteDto Create(string email, OrganizationUserInvite invite, Guid organizationId) { return new OrganizationUserInviteDto { @@ -21,7 +23,9 @@ public class OrganizationUserInviteDto ExternalId = invite.ExternalId, Type = invite.Type, Permissions = invite.Permissions, - AccessSecretsManager = invite.AccessSecretsManager + AccessSecretsManager = invite.AccessSecretsManager, + OrganizationId = organizationId, + Groups = invite.Groups }; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserSingleEmailInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserSingleEmailInvite.cs index 76eaafdcfd..ac24fefbd8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserSingleEmailInvite.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserSingleEmailInvite.cs @@ -10,7 +10,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class OrganizationUserSingleEmailInvite { public string Email { get; private init; } = string.Empty; - public Guid[] AccessibleCollections { get; private init; } = []; + public CollectionAccessSelection[] AccessibleCollections { get; private init; } = []; public Permissions Permissions { get; private init; } = new(); public OrganizationUserType Type { get; private init; } = OrganizationUserType.User; public bool AccessSecretsManager { get; private init; } @@ -34,7 +34,7 @@ public class OrganizationUserSingleEmailInvite return new OrganizationUserSingleEmailInvite { Email = email, - AccessibleCollections = accessibleCollections.Select(x => x.Id).ToArray(), + AccessibleCollections = accessibleCollections.ToArray(), Type = type, Permissions = permissions, AccessSecretsManager = accessSecretsManager diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 8825f9722a..108641b5e6 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; @@ -68,4 +69,6 @@ public interface IOrganizationUserRepository : IRepositoryThe role to search for /// A list of OrganizationUsersUserDetails with the specified role Task> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role); + + Task CreateManyAsync(IEnumerable organizationUserCollection); } diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index f75b8ae008..19d0c5d671 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -10,7 +10,20 @@ public class CommandResult(IEnumerable errors) public CommandResult() : this(Array.Empty()) { } } -public class CommandResult(T value) : CommandResult +public abstract class CommandResult : CommandResult { - public T Value { get; set; } = value; + + public T Value { get; set; } +} + +public class Success : CommandResult +{ + public Success(T value) => Value = value; +} + +public class Failure : CommandResult +{ + public Failure(string error) => ErrorMessages.Add(error); + + public string ErrorMessage => string.Join(" ", ErrorMessages); } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 9b77fb216e..b26217e6ee 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; @@ -580,4 +581,32 @@ public class OrganizationUserRepository : Repository, IO return results.ToList(); } } + + public async Task CreateManyAsync(IEnumerable organizationUserCollection) + { + await using var connection = new SqlConnection(_marsConnectionString); + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]", + new + { + OrganizationUserData = JsonSerializer.Serialize(organizationUserCollection.Select(x => x.User)), + CollectionData = JsonSerializer.Serialize(organizationUserCollection + .SelectMany(x => x.Collections, (user, collection) => new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = user.User.Id, + ReadOnly = collection.ReadOnly, + HidePasswords = collection.HidePasswords, + Manage = collection.Manage + })), + GroupData = JsonSerializer.Serialize(organizationUserCollection + .SelectMany(x => x.Groups, (user, group) => new GroupUser + { + GroupId = group, + OrganizationUserId = user.User.Id + })) + }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index ef6460df0e..2fd5673b13 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -1,5 +1,6 @@ using AutoMapper; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -754,4 +755,28 @@ public class OrganizationUserRepository : Repository organizationUserCollection) + { + using var scope = ServiceScopeFactory.CreateScope(); + + await using var dbContext = GetDatabaseContext(scope); + + dbContext.OrganizationUsers.AddRange(Mapper.Map>(organizationUserCollection.Select(x => x.User))); + dbContext.CollectionUsers.AddRange(organizationUserCollection.SelectMany(x => x.Collections, (user, collection) => new CollectionUser + { + CollectionId = collection.Id, + HidePasswords = collection.HidePasswords, + OrganizationUserId = user.User.Id, + Manage = collection.Manage, + ReadOnly = collection.ReadOnly + })); + dbContext.GroupUsers.AddRange(organizationUserCollection.SelectMany(x => x.Groups, (user, group) => new GroupUser + { + GroupId = group, + OrganizationUserId = user.User.Id + })); + + await dbContext.SaveChangesAsync(); + } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql new file mode 100644 index 0000000000..78ff2933f6 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -0,0 +1,97 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC +END +go + diff --git a/test/Core.Test/AdminConsole/Models/InviteOrganizationUserRequestTests.cs b/test/Core.Test/AdminConsole/Models/InviteOrganizationUserRequestTests.cs index eb9a6d6be7..fcca3c353f 100644 --- a/test/Core.Test/AdminConsole/Models/InviteOrganizationUserRequestTests.cs +++ b/test/Core.Test/AdminConsole/Models/InviteOrganizationUserRequestTests.cs @@ -49,6 +49,6 @@ public class InviteOrganizationUserRequestTests Assert.NotNull(invite); Assert.Equal(validEmail, invite.Email); - Assert.Contains(validCollectionConfiguration.Id, invite.AccessibleCollections); + Assert.Contains(validCollectionConfiguration, invite.AccessibleCollections); } } diff --git a/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs index 6d2be18815..1398e58999 100644 --- a/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs +++ b/test/Core.Test/AdminConsole/Models/InviteOrganizationUsersRequestTests.cs @@ -50,6 +50,6 @@ public class InviteOrganizationUsersRequestTests Assert.NotNull(invite); Assert.Contains(validEmail, invite.Emails); - Assert.Contains(validCollectionConfiguration.Id, invite.AccessibleCollections); + Assert.Contains(validCollectionConfiguration, invite.AccessibleCollections); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/SecretsManagerInviteUserValidationTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/SecretsManagerInviteUserValidationTests.cs index 780fcb63d7..b7288b69e7 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/SecretsManagerInviteUserValidationTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/SecretsManagerInviteUserValidationTests.cs @@ -85,7 +85,7 @@ public class SecretsManagerInviteUserValidationTests var request = new InviteUserOrganizationValidationRequest { - Invites = [OrganizationUserInviteDto.Create("email@test.com", OrganizationUserInvite.Create(["email@test.com"], [], OrganizationUserType.User, new Permissions(), string.Empty, true))], + Invites = [OrganizationUserInviteDto.Create("email@test.com", OrganizationUserInvite.Create(["email@test.com"], [], OrganizationUserType.User, new Permissions(), string.Empty, true), organization.Id)], Organization = organizationDto, PerformedBy = Guid.Empty, PerformedAt = default, @@ -116,7 +116,7 @@ public class SecretsManagerInviteUserValidationTests var request = new InviteUserOrganizationValidationRequest { - Invites = [OrganizationUserInviteDto.Create("email@test.com", OrganizationUserInvite.Create(["email@test.com"], [], OrganizationUserType.User, new Permissions(), string.Empty, true))], + Invites = [OrganizationUserInviteDto.Create("email@test.com", OrganizationUserInvite.Create(["email@test.com"], [], OrganizationUserType.User, new Permissions(), string.Empty, true), organization.Id)], Organization = organizationDto, PerformedBy = Guid.Empty, PerformedAt = default, diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index aee4beb8ce..3363f79e06 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -1,7 +1,11 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Xunit; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -354,4 +358,73 @@ public class OrganizationUserRepositoryTests Assert.Single(responseModel); Assert.Equal(orgUser1.Id, responseModel.Single().Id); } + + [DatabaseTheory, DatabaseData] + public async Task CreateManyAsync_WithCollectionAndGroup_SaveSuccessfully( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + IGroupRepository groupRepository) + { + var requestTime = DateTime.UtcNow; + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@test.com", // TODO: EF does not enfore this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULl, + CreationDate = requestTime + }); + + var collection = await collectionRepository.CreateAsync(new Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Collection", + ExternalId = "external-collection-1", + CreationDate = requestTime, + RevisionDate = requestTime + }); + + var group = await groupRepository.CreateAsync(new Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Name = "Test Group", + ExternalId = "external-group-1" + }); + + var orgUserCollection = new List + { + new CreateOrganizationUser + { + User = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + Email = "test-user@test.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.Owner, + ExternalId = "externalid-1", + Permissions = CoreHelpers.ClassToJsonData(new Permissions()), + AccessSecretsManager = false + }, + Collections = + [ + new CollectionAccessSelection + { + Id = collection.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + ], + Groups = [group.Id] + } + }; + + await organizationUserRepository.CreateManyAsync(orgUserCollection); + + var orgUser = await organizationUserRepository.GetDetailsByIdAsync(orgUserCollection.First().User.Id); + } } diff --git a/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql b/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql new file mode 100644 index 0000000000..ab7ab9cc88 --- /dev/null +++ b/util/Migrator/DbScripts/2025-02-17_00_OrgUsers_CreateManyUsersCollectionsGroups.sql @@ -0,0 +1,97 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC +END +go +