1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

Separated old and new code explicitly. Moved old code checks down into new code as well. Added error and mapper to Failure<T>

This commit is contained in:
jrmccannon 2025-03-24 12:07:28 -05:00
parent 7be2e2bd07
commit 44b817ad03
No known key found for this signature in database
GPG Key ID: CF03F3DB01CE96A6
9 changed files with 163 additions and 56 deletions

View File

@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
@ -30,12 +31,21 @@ public class ScimUserRequestModel : BaseScimUserModel
public OrganizationUserSingleEmailInvite ToRequest(
ScimProviderType scimProvider,
InviteOrganization inviteOrganization,
DateTimeOffset performedAt) =>
new(
email: EmailForInvite(scimProvider),
inviteOrganization: inviteOrganization,
performedAt: performedAt,
externalId: ExternalIdForInvite());
DateTimeOffset performedAt)
{
var email = EmailForInvite(scimProvider);
if (string.IsNullOrWhiteSpace(email) || !Active)
{
throw new BadRequestException();
}
return new(
email: email,
inviteOrganization: inviteOrganization,
performedAt: performedAt,
externalId: ExternalIdForInvite());
}
private string EmailForInvite(ScimProviderType scimProvider)
{

View File

@ -1,7 +1,6 @@
#nullable enable
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@ -16,6 +15,7 @@ using Bit.Core.Services;
using Bit.Scim.Context;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
namespace Bit.Scim.Users;
@ -34,7 +34,63 @@ public class PostUserCommand(
{
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
{
var scimProvider = scimContext.RequestScimProvider;
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
{
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
}
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
}
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
ScimUserRequestModel model,
Guid organizationId,
ScimProviderType scimProvider)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization is null)
{
throw new NotFoundException();
}
var plan = await pricingClient.GetPlan(organization.PlanType);
if (plan == null)
{
logger.LogError("Plan {planType} not found for organization {organizationId}",
organization.PlanType, organization.Id);
return null;
}
var request = model.ToRequest(
scimProvider: scimProvider,
inviteOrganization: new InviteOrganization(organization, plan),
performedAt: timeProvider.GetUtcNow());
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
var invitedOrganizationUserId = result switch
{
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
Failure<ScimInviteOrganizationUsersResponse> { ErrorMessage: InviteOrganizationUsersCommand.NoUsersToInvite } => (Guid?)null,
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
Failure<ScimInviteOrganizationUsersResponse> failure when failure.ErrorMessages.Count != 0 => throw new BadRequestException(failure.ErrorMessage),
_ => throw new InvalidOperationException()
};
var organizationUser = invitedOrganizationUserId.HasValue
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
: null;
return organizationUser;
}
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
ScimUserRequestModel model,
Guid organizationId,
ScimProviderType scimProvider)
{
var invite = model.ToOrganizationUserInvite(scimProvider);
var email = invite.Emails.Single();
@ -65,56 +121,15 @@ public class PostUserCommand(
throw new NotFoundException();
}
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization))
{
return await InviteScimOrganizationUserAsync(model, organization, scimProvider);
}
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
invite.AccessSecretsManager = hasStandaloneSecretsManager;
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
EventSystemUser.SCIM,
invite, externalId);
invite,
externalId);
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser;
}
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(ScimUserRequestModel model,
Organization organization, ScimProviderType scimProvider)
{
var plan = await pricingClient.GetPlan(organization.PlanType);
if (plan == null)
{
logger.LogError("Plan {planType} not found for organization {organizationId}", organization.PlanType,
organization.Id);
return null;
}
var request = model.ToRequest(
scimProvider: scimProvider,
inviteOrganization: new InviteOrganization(organization, plan),
performedAt: timeProvider.GetUtcNow());
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
var invitedOrganizationUserId = result switch
{
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
Failure<ScimInviteOrganizationUsersResponse> { ErrorMessage: InviteOrganizationUsersCommand.NoUsersToInvite } => (Guid?)null,
Failure<ScimInviteOrganizationUsersResponse> failure when failure.ErrorMessages.Count != 0 => throw new BadRequestException(failure.ErrorMessage),
_ => throw new InvalidOperationException()
};
var organizationUser = invitedOrganizationUserId.HasValue
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
: null;
return organizationUser;
}
}

View File

@ -27,7 +27,7 @@ public class PostUserCommandTests
ExternalId = externalId,
Emails = emails,
Active = true,
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
Schemas = [ScimConstants.Scim2SchemaUser]
};
sutProvider.GetDependency<IOrganizationUserRepository>()
@ -39,13 +39,16 @@ public class PostUserCommandTests
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IOrganizationService>()
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
.InviteUserAsync(organizationId,
invitingUserId: null,
EventSystemUser.SCIM,
Arg.Is<OrganizationUserInvite>(i =>
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
i.Type == OrganizationUserType.User &&
!i.Collections.Any() &&
!i.Groups.Any() &&
i.AccessSecretsManager), externalId)
i.AccessSecretsManager),
externalId)
.Returns(newUser);
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);

View File

@ -0,0 +1,37 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.Exceptions;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
public static class ErrorMapper
{
/// <summary>
/// Maps the Error<T> to a Bit.Exception class.
/// </summary>
/// <param name="error"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public static Exception MapToBitException<T>(Error<T> error) =>
error switch
{
UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message),
_ => new BadRequestException(error.Message)
};
/// <summary>
/// This maps the Error<T> object to the Bit.Exception class.
///
/// This should be replaced by an IActionResult mapper when possible.
/// </summary>
/// <param name="errors"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public static Exception MapToBitException<T>(IEnumerable<Error<T>> errors) =>
errors switch
{
not null when errors.Count() == 1 => MapToBitException(errors.First()),
not null when errors.Count() > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))),
_ => new BadRequestException()
};
}

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
public record UserAlreadyExistsError(ScimInviteOrganizationUsersResponse Response) : Error<ScimInviteOrganizationUsersResponse>(Code, Response)
{
public const string Code = "User already exists";
}

View File

@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.AdminConsole.Shared.Validation;
@ -39,10 +40,21 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
public const string NoUsersToInvite = "No users to invite.";
public const string InvalidResultType = "Invalid result type.";
public async Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(OrganizationUserSingleEmailInvite request)
{
var hasSecretsManager = await paymentService.HasSecretsManagerStandalone(request.InviteOrganization);
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
if (orgUsers.Any(existingUser =>
request.Email.Equals(existingUser.Email, StringComparison.InvariantCultureIgnoreCase) ||
request.ExternalId.Equals(existingUser.ExternalId, StringComparison.InvariantCultureIgnoreCase)))
{
return new Failure<ScimInviteOrganizationUsersResponse>(
new UserAlreadyExistsError(new ScimInviteOrganizationUsersResponse(request)));
}
var result = await InviteOrganizationUsersAsync(new InviteOrganizationUsersRequest(request, hasSecretsManager));
switch (result)

View File

@ -11,4 +11,17 @@ public class ScimInviteOrganizationUsersResponse
{
public OrganizationUser InvitedUser { get; init; }
public ScimInviteOrganizationUsersResponse()
{
}
public ScimInviteOrganizationUsersResponse(OrganizationUserSingleEmailInvite request)
{
InvitedUser = new OrganizationUser
{
Email = request.Email,
ExternalId = request.ExternalId
};
}
}

View File

@ -5,5 +5,5 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
public record CannotAutoScaleOnSelfHostError(IGlobalSettings InvalidSettings) : Error<IGlobalSettings>(Code, InvalidSettings)
{
public const string Code = "CannotAutoScaleOnSelfHost";
public const string Code = "Cannot auto scale self-host.";
}

View File

@ -40,10 +40,18 @@ public class Success<T>(T value) : CommandResult<T>
public class Failure<T>(IEnumerable<string> errorMessages) : CommandResult<T>
{
public List<string> ErrorMessages { get; } = errorMessages.ToList();
public Error<T>[] Errors { get; set; } = [];
public string ErrorMessage => string.Join(" ", ErrorMessages);
public Failure(string error) : this([error]) { }
public Failure(string error) : this([error])
{
}
public Failure(Error<T> error) : this([error.Message])
{
Errors = [error];
}
}
public class Partial<T> : CommandResult<T>