1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 13:08:17 -05:00

Added bulk procedure for saving users, collections and groups from inviting. Added test to validate Ef and Sproc

This commit is contained in:
jrmccannon 2025-02-17 16:32:23 -06:00
parent fcaa449f83
commit 926e786f82
No known key found for this signature in database
GPG Key ID: CF03F3DB01CE96A6
15 changed files with 416 additions and 29 deletions

View File

@ -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<IEnumerable<OrganizationUser>> failure)
{
return new Failure<OrganizationUser>(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<OrganizationUser>(result.Value.FirstOrDefault());
return new Success<OrganizationUser>(result.Value.FirstOrDefault());
}
private async Task<CommandResult<IEnumerable<OrganizationUser>>> 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<IEnumerable<OrganizationUser>>(validationResult.ErrorMessageString);
}
var organizationUserCollection = invitesToSend
.Select(MapToDataModel(request.PerformedAt));
try
{
await organizationUserRepository.CreateManyAsync(organizationUserCollection);
// save organization users
// org users
// collections

View File

@ -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<OrganizationUserInviteDto, CreateOrganizationUser> 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
};
}

View File

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

View File

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

View File

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

View File

@ -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 : IRepository<OrganizationUser, Gui
/// <param name="role">The role to search for</param>
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection);
}

View File

@ -10,7 +10,20 @@ public class CommandResult(IEnumerable<string> errors)
public CommandResult() : this(Array.Empty<string>()) { }
}
public class CommandResult<T>(T value) : CommandResult
public abstract class CommandResult<T> : CommandResult
{
public T Value { get; set; } = value;
public T Value { get; set; }
}
public class Success<T> : CommandResult<T>
{
public Success(T value) => Value = value;
}
public class Failure<T> : CommandResult<T>
{
public Failure(string error) => ErrorMessages.Add(error);
public string ErrorMessage => string.Join(" ", ErrorMessages);
}

View File

@ -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<OrganizationUser, Guid>, IO
return results.ToList();
}
}
public async Task CreateManyAsync(IEnumerable<CreateOrganizationUser> 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);
}
}

View File

@ -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<Core.Entities.OrganizationU
return await query.ToListAsync();
}
}
public async Task CreateManyAsync(IEnumerable<CreateOrganizationUser> organizationUserCollection)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var dbContext = GetDatabaseContext(scope);
dbContext.OrganizationUsers.AddRange(Mapper.Map<List<OrganizationUser>>(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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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