1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-02 16:42:50 -05:00

[PM-16811] - SCIM Invite Users Optimizations (#5398)

* WIP changes for Invite User optimization from Scim

* feature flag string

* Added plan validation to PasswordManagerInviteUserValidation. Cleaned up a few things.

* Added Secrets Manager Validations and Tests.

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

* Created SendOrganizationInvitesCommand and moved some tests from OrgServiceTests. Fixed some tests in org service in relation to moving out SendOrgInviteCommand code.

Added side effects to InviteOrganizationUsersCommand

* First test of new command.

* Added test to verify valid request with a user calls db method and sends the invite

* Added more tests for the updates

* Added integration test around enabling feature and sending invite via scim. Did a bit of refactoring on the SM validation. Fixed couple bugs found.

* Switching over to a local factory.

* created response model and split interface out.

* switched to initialization block

* Moved to private method. Made ScimInvite inherit the single invite base model. Moved create methods to constructors. A few more CR changes included.

* Moved `FromOrganization` mapper method to a constructor

* Updated to use new pricing client. Supressed null dereference errors.

* Fixing bad merge.

* Rename of OrgDto

* undoing this

* Moved into class

* turned into a switch statement

* Separated into separate files.

* Renamed dto and added ctor

* Dto rename. Moved from static methods to ctors

* Removed unused request model

* changes from main

* missed value

* Fixed some compilation errors.

* Fixed some changes.

* Removed comment

* fixed compiler warning.

* Refactored to use new ValidationResult pattern. added mapping method.

* Added throwing of Failure as the previous implementation would have.

* Cleaned up return.

* fixing test.

* Made HasSecretsManagerStandalone return if org doesn't have sm. Added overload for lighter weight model and moved common code to private method.

* Fixed tests.

* Made public method private. added some comments.

* Refactor validation parameter to improve clarity and consistency. Added XML doc

* fixed test

* Removed test only constructor from InviteOrganization

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

* Variable/Field/Property renames

* Renamed InviteUsersValidation to InviteUsersValidator

* Rename for InvitingUserOrganizationValidation to InvitingUserOrganizationValidator

* PasswordManagerInviteUserValidation to PasswordManagerInviteUserValidator

* Moved XML comment. Added check to see if additional seats are needed.

* Fixing name.

* Updated names.

* Corrected double negation.

* Added groups and collection and users checks.

* Fixed comment. Fixed multiple enumeration. Changed variable name.

* Cleaned up DTO models. Moved some validation steps around. A few quick fixes to address CR concerns. Still need to move a few things yet.

* Fixed naming in subscription update models.

* put back in the request for now.

* Quick rename

* Added provider email addresses as well.

* Removed valid wrapper to pass in to validation methods.

* fix tests

* Code Review changes.

* Removed unused classes

* Using GetPlanOrThrow instead.

* Switches to extension method

* Made Revert and Adjust Sm methods consistent. Corrected string comparer. Added comment for revert sm.

* Fixing compiler complaint.

* Adding XML docs

* Calculated seat addition for SM.

* Fixing compiler complaints.

* Renames for organization.

* Fixing comparison issue.

* Adding error and aligning message.

* fixing name of method.

* Made extension method.

* Rearranged some things. Fixed the tests.

* Added test around validating the revert.

* Added test to validate the provider email is sent if org is managed by a provider.

* Created new errors and removed references in business code to ErrorMessages property. This aligns Invite User code to use Errors instead of ErrorMessages

* Delayed the hasSecretsManagerStandalone call as long as possible.

* Corrected model name. Corrected SM seat calculation. Added test for it.

* Corrected logic and added more tests.
This commit is contained in:
Jared McCannon
2025-04-07 09:14:10 -05:00
committed by GitHub
parent 3c56866a76
commit 0d7363c6af
66 changed files with 3337 additions and 362 deletions

View File

@ -1,3 +1,8 @@
namespace Bit.Core.AdminConsole.Errors;
public record Error<T>(string Message, T ErroredValue);
public static class ErrorMappers
{
public static Error<B> ToError<A, B>(this Error<A> errorA, B erroredValue) => new(errorA.Message, erroredValue);
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.AdminConsole.Errors;
public record InvalidResultTypeError<T>(T Value) : Error<T>(Code, Value)
{
public const string Code = "Invalid result type.";
};

View File

@ -0,0 +1,35 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.AdminConsole.Models.Business;
public record InviteOrganization
{
public Guid OrganizationId { get; init; }
public int? Seats { get; init; }
public int? MaxAutoScaleSeats { get; init; }
public int? SmSeats { get; init; }
public int? SmMaxAutoScaleSeats { get; init; }
public Plan Plan { get; init; }
public string GatewayCustomerId { get; init; }
public string GatewaySubscriptionId { get; init; }
public bool UseSecretsManager { get; init; }
public InviteOrganization()
{
}
public InviteOrganization(Organization organization, Plan plan)
{
OrganizationId = organization.Id;
Seats = organization.Seats;
MaxAutoScaleSeats = organization.MaxAutoscaleSeats;
SmSeats = organization.SmSeats;
SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats;
Plan = plan;
GatewayCustomerId = organization.GatewayCustomerId;
GatewaySubscriptionId = organization.GatewaySubscriptionId;
UseSecretsManager = organization.UseSecretsManager;
}
}

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 ErrorT 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 ErrorT 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>(ICollection<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 FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
{
public const string Code = "Failed to invite users";
}

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 NoUsersToInviteError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
{
public const string Code = "No users to invite";
}

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

@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Models.Commands;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
/// <summary>
/// Defines the contract for inviting organization users via SCIM (System for Cross-domain Identity Management).
/// Provides functionality for handling single email invitation requests within an organization context.
/// </summary>
public interface IInviteOrganizationUsersCommand
{
/// <summary>
/// Sends an invitation to add an organization user via SCIM (System for Cross-domain Identity Management) system.
/// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value.
/// Success will be the successful return object.
/// </summary>
/// <param name="request">
/// Contains the details for inviting a single organization user via email.
/// </param>
/// <returns>Response from InviteScimOrganiation<see cref="ScimInviteOrganizationUsersResponse"/></returns>
Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request);
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
/// <summary>
/// This is for sending the invite to an organization user.
/// </summary>
public interface ISendOrganizationInvitesCommand
{
/// <summary>
/// This sends emails out to organization users for a given organization.
/// </summary>
/// <param name="request"><see cref="SendInvitesRequest"/></param>
/// <returns></returns>
Task SendInvitesAsync(SendInvitesRequest request);
}

View File

@ -0,0 +1,282 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.Interfaces;
using Bit.Core.AdminConsole.Models.Business;
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.Repositories;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Commands;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Microsoft.Extensions.Logging;
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public class InviteOrganizationUsersCommand(IEventService eventService,
IOrganizationUserRepository organizationUserRepository,
IInviteUsersValidator inviteUsersValidator,
IPaymentService paymentService,
IOrganizationRepository organizationRepository,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<InviteOrganizationUsersCommand> logger,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository
) : IInviteOrganizationUsersCommand
{
public const string IssueNotifyingOwnersOfSeatLimitReached = "Error encountered notifying organization owners of seat limit reached.";
public async Task<CommandResult<ScimInviteOrganizationUsersResponse>> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request)
{
var result = await InviteOrganizationUsersAsync(request);
switch (result)
{
case Failure<InviteOrganizationUsersResponse> failure:
return new Failure<ScimInviteOrganizationUsersResponse>(
failure.Errors.Select(error => new Error<ScimInviteOrganizationUsersResponse>(error.Message,
new ScimInviteOrganizationUsersResponse
{
InvitedUser = error.ErroredValue.InvitedUsers.FirstOrDefault()
})));
case Success<InviteOrganizationUsersResponse> success when success.Value.InvitedUsers.Any():
var user = success.Value.InvitedUsers.First();
await eventService.LogOrganizationUserEventAsync<IOrganizationUser>(
organizationUser: user,
type: EventType.OrganizationUser_Invited,
systemUser: EventSystemUser.SCIM,
date: request.PerformedAt.UtcDateTime);
return new Success<ScimInviteOrganizationUsersResponse>(new ScimInviteOrganizationUsersResponse
{
InvitedUser = user
});
default:
return new Failure<ScimInviteOrganizationUsersResponse>(
new InvalidResultTypeError<ScimInviteOrganizationUsersResponse>(
new ScimInviteOrganizationUsersResponse()));
}
}
private async Task<CommandResult<InviteOrganizationUsersResponse>> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request)
{
var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray();
if (invitesToSend.Length == 0)
{
return new Failure<InviteOrganizationUsersResponse>(new NoUsersToInviteError(
new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId)));
}
var validationResult = await inviteUsersValidator.ValidateAsync(new InviteOrganizationUsersValidationRequest
{
Invites = invitesToSend.ToArray(),
InviteOrganization = request.InviteOrganization,
PerformedBy = request.PerformedBy,
PerformedAt = request.PerformedAt,
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
});
if (validationResult is Invalid<InviteOrganizationUsersValidationRequest> invalid)
{
return invalid.MapToFailure(r => new InviteOrganizationUsersResponse(r));
}
var validatedRequest = validationResult as Valid<InviteOrganizationUsersValidationRequest>;
var organizationUserToInviteEntities = invitesToSend
.Select(x => x.MapToDataModel(request.PerformedAt, validatedRequest!.Value.InviteOrganization))
.ToArray();
var organization = await organizationRepository.GetByIdAsync(validatedRequest!.Value.InviteOrganization.OrganizationId);
try
{
await organizationUserRepository.CreateManyAsync(organizationUserToInviteEntities);
await AdjustPasswordManagerSeatsAsync(validatedRequest, organization);
await AdjustSecretsManagerSeatsAsync(validatedRequest);
await SendAdditionalEmailsAsync(validatedRequest, organization);
await SendInvitesAsync(organizationUserToInviteEntities, organization);
await PublishReferenceEventAsync(validatedRequest, organization);
}
catch (Exception ex)
{
logger.LogError(ex, FailedToInviteUsersError.Code);
await organizationUserRepository.DeleteManyAsync(organizationUserToInviteEntities.Select(x => x.OrganizationUser.Id));
// Do this first so that SmSeats never exceed PM seats (due to current billing requirements)
await RevertSecretsManagerChangesAsync(validatedRequest, organization, validatedRequest.Value.InviteOrganization.SmSeats);
await RevertPasswordManagerChangesAsync(validatedRequest, organization);
return new Failure<InviteOrganizationUsersResponse>(
new FailedToInviteUsersError(
new InviteOrganizationUsersResponse(validatedRequest.Value)));
}
return new Success<InviteOrganizationUsersResponse>(
new InviteOrganizationUsersResponse(
invitedOrganizationUsers: organizationUserToInviteEntities.Select(x => x.OrganizationUser).ToArray(),
organizationId: organization!.Id));
}
private async Task<IEnumerable<OrganizationUserInvite>> FilterExistingUsersAsync(InviteOrganizationUsersRequest request)
{
var existingEmails = new HashSet<string>(await organizationUserRepository.SelectKnownEmailsAsync(
request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false),
StringComparer.OrdinalIgnoreCase);
return request.Invites
.Where(invite => !existingEmails.Contains(invite.Email))
.ToArray();
}
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0)
{
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally.
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd;
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
await organizationRepository.ReplaceAsync(organization);
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
private async Task RevertSecretsManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization, int? initialSmSeats)
{
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
{
var smSubscriptionUpdateRevert = new SecretsManagerSubscriptionUpdate(
organization: organization,
plan: validatedResult.Value.InviteOrganization.Plan,
autoscaling: false)
{
SmSeats = initialSmSeats
};
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert);
}
}
private async Task PublishReferenceEventAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult,
Organization organization) =>
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext)
{
Users = validatedResult.Value.Invites.Length
});
private async Task SendInvitesAsync(IEnumerable<CreateOrganizationUser> users, Organization organization) =>
await sendOrganizationInvitesCommand.SendInvitesAsync(
new SendInvitesRequest(
users.Select(x => x.OrganizationUser),
organization));
private async Task SendAdditionalEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
await SendPasswordManagerMaxSeatLimitEmailsAsync(validatedResult, organization);
}
private async Task SendPasswordManagerMaxSeatLimitEmailsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (!validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxSeatsReached)
{
return;
}
try
{
var ownerEmails = await GetOwnerEmailAddressesAsync(validatedResult.Value.InviteOrganization);
await mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization,
validatedResult.Value.PasswordManagerSubscriptionUpdate.MaxAutoScaleSeats!.Value, ownerEmails);
}
catch (Exception ex)
{
logger.LogError(ex, IssueNotifyingOwnersOfSeatLimitReached);
}
}
private async Task<IEnumerable<string>> GetOwnerEmailAddressesAsync(InviteOrganization organization)
{
var providerOrganization = await providerOrganizationRepository
.GetByOrganizationId(organization.OrganizationId);
if (providerOrganization == null)
{
return (await organizationUserRepository
.GetManyByMinimumRoleAsync(organization.OrganizationId, OrganizationUserType.Owner))
.Select(x => x.Email)
.Distinct();
}
return (await providerUserRepository
.GetManyDetailsByProviderAsync(providerOrganization.ProviderId, ProviderUserStatusType.Confirmed))
.Select(u => u.Email).Distinct();
}
private async Task AdjustSecretsManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult)
{
if (validatedResult.Value.SecretsManagerSubscriptionUpdate?.SmSeatsChanged is true)
{
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(validatedResult.Value.SecretsManagerSubscriptionUpdate);
}
}
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0)
{
return;
}
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
{
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
});
}
}

View File

@ -0,0 +1,15 @@
using Bit.Core.Entities;
using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
/// <summary>
/// Object for associating the <see cref="OrganizationUser"/> with their assigned collections
/// <see cref="CollectionAccessSelection"/> and Group Ids.
/// </summary>
public class CreateOrganizationUser
{
public OrganizationUser OrganizationUser { get; set; }
public CollectionAccessSelection[] Collections { get; set; } = [];
public Guid[] Groups { get; set; } = [];
}

View File

@ -0,0 +1,30 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public static class CreateOrganizationUserExtensions
{
public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite,
DateTimeOffset performedAt,
InviteOrganization organization) =>
new()
{
OrganizationUser = new OrganizationUser
{
Id = CoreHelpers.GenerateComb(),
OrganizationId = organization.OrganizationId,
Email = organizationUserInvite.Email.ToLowerInvariant(),
Type = organizationUserInvite.Type,
Status = OrganizationUserStatusType.Invited,
AccessSecretsManager = organizationUserInvite.AccessSecretsManager,
ExternalId = string.IsNullOrWhiteSpace(organizationUserInvite.ExternalId) ? null : organizationUserInvite.ExternalId,
CreationDate = performedAt.UtcDateTime,
RevisionDate = performedAt.UtcDateTime
},
Collections = organizationUserInvite.AssignedCollections,
Groups = organizationUserInvite.Groups
};
}

View File

@ -0,0 +1,7 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public static class InviteOrganizationUserErrorMessages
{
public const string InvalidEmailErrorMessage = "The email address is not valid.";
public const string InvalidCollectionConfigurationErrorMessage = "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.";
}

View File

@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class InviteOrganizationUsersRequest
{
public OrganizationUserInvite[] Invites { get; } = [];
public InviteOrganization InviteOrganization { get; }
public Guid PerformedBy { get; }
public DateTimeOffset PerformedAt { get; }
public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites,
InviteOrganization inviteOrganization,
Guid performedBy,
DateTimeOffset performedAt)
{
Invites = invites;
InviteOrganization = inviteOrganization;
PerformedBy = performedBy;
PerformedAt = performedAt;
}
}

View File

@ -0,0 +1,42 @@
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class InviteOrganizationUsersResponse(Guid organizationId)
{
public IEnumerable<OrganizationUser> InvitedUsers { get; } = [];
public Guid OrganizationId { get; } = organizationId;
public InviteOrganizationUsersResponse(InviteOrganizationUsersValidationRequest usersValidationRequest)
: this(usersValidationRequest.InviteOrganization.OrganizationId)
{
InvitedUsers = usersValidationRequest.Invites.Select(x => new OrganizationUser { Email = x.Email });
}
public InviteOrganizationUsersResponse(IEnumerable<OrganizationUser> invitedOrganizationUsers, Guid organizationId)
: this(organizationId)
{
InvitedUsers = invitedOrganizationUsers;
}
}
public class ScimInviteOrganizationUsersResponse
{
public OrganizationUser InvitedUser { get; init; }
public ScimInviteOrganizationUsersResponse()
{
}
public ScimInviteOrganizationUsersResponse(InviteOrganizationUsersRequest request)
{
var userToInvite = request.Invites.First();
InvitedUser = new OrganizationUser
{
Email = userToInvite.Email,
ExternalId = userToInvite.ExternalId
};
}
}

View File

@ -0,0 +1,40 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class InviteOrganizationUsersValidationRequest
{
public InviteOrganizationUsersValidationRequest()
{
}
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request)
{
Invites = request.Invites;
InviteOrganization = request.InviteOrganization;
PerformedBy = request.PerformedBy;
PerformedAt = request.PerformedAt;
OccupiedPmSeats = request.OccupiedPmSeats;
OccupiedSmSeats = request.OccupiedSmSeats;
}
public InviteOrganizationUsersValidationRequest(InviteOrganizationUsersValidationRequest request,
PasswordManagerSubscriptionUpdate subscriptionUpdate,
SecretsManagerSubscriptionUpdate smSubscriptionUpdate)
: this(request)
{
PasswordManagerSubscriptionUpdate = subscriptionUpdate;
SecretsManagerSubscriptionUpdate = smSubscriptionUpdate;
}
public OrganizationUserInvite[] Invites { get; init; } = [];
public InviteOrganization InviteOrganization { get; init; }
public Guid PerformedBy { get; init; }
public DateTimeOffset PerformedAt { get; init; }
public int OccupiedPmSeats { get; init; }
public int OccupiedSmSeats { get; init; }
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; set; }
public SecretsManagerSubscriptionUpdate SecretsManagerSubscriptionUpdate { get; set; }
}

View File

@ -0,0 +1,77 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.InviteOrganizationUserErrorMessages;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
public class OrganizationUserInvite
{
public string Email { get; private init; }
public CollectionAccessSelection[] AssignedCollections { get; private init; }
public OrganizationUserType Type { get; private init; }
public Permissions Permissions { get; private init; }
public string ExternalId { get; private init; }
public bool AccessSecretsManager { get; private init; }
public Guid[] Groups { get; private init; }
public OrganizationUserInvite(string email, string externalId) :
this(
email: email,
assignedCollections: [],
groups: [],
type: OrganizationUserType.User,
permissions: new Permissions(),
externalId: externalId,
false)
{
}
public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) :
this(invite.Email,
invite.AssignedCollections,
invite.Groups,
invite.Type,
invite.Permissions,
invite.ExternalId,
accessSecretsManager)
{
}
public OrganizationUserInvite(string email,
IEnumerable<CollectionAccessSelection> assignedCollections,
IEnumerable<Guid> groups,
OrganizationUserType type,
Permissions permissions,
string externalId,
bool accessSecretsManager)
{
ValidateEmailAddress(email);
var collections = assignedCollections?.ToArray() ?? [];
if (collections.Any(x => x.IsValidCollectionAccessConfiguration()))
{
throw new BadRequestException(InvalidCollectionConfigurationErrorMessage);
}
Email = email;
AssignedCollections = collections;
Groups = groups.ToArray();
Type = type;
Permissions = permissions ?? new Permissions();
ExternalId = externalId;
AccessSecretsManager = accessSecretsManager;
}
private static void ValidateEmailAddress(string email)
{
if (!email.IsValidEmail())
{
throw new BadRequestException($"{email} {InvalidEmailErrorMessage}");
}
}
}

View File

@ -0,0 +1,33 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
/// <summary>
/// Represents a request to send invitations to a group of organization users.
/// </summary>
public class SendInvitesRequest
{
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization) =>
(Users, Organization) = (users.ToArray(), organization);
public SendInvitesRequest(IEnumerable<OrganizationUser> users, Organization organization, bool initOrganization) =>
(Users, Organization, InitOrganization) = (users.ToArray(), organization, initOrganization);
/// <summary>
/// Organization Users to send emails to.
/// </summary>
public OrganizationUser[] Users { get; set; } = [];
/// <summary>
/// The organization to invite the users to.
/// </summary>
public Organization Organization { get; init; }
/// <summary>
/// This is for when the organization is being created and this is the owners initial invite
/// </summary>
public bool InitOrganization { get; init; }
}

View File

@ -0,0 +1,80 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tokens;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public class SendOrganizationInvitesCommand(
IUserRepository userRepository,
ISsoConfigRepository ssoConfigurationRepository,
IPolicyRepository policyRepository,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService) : ISendOrganizationInvitesCommand
{
public async Task SendInvitesAsync(SendInvitesRequest request)
{
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(request.Users, request.Organization, request.InitOrganization);
await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(IEnumerable<OrganizationUser> orgUsers,
Organization organization, bool initOrganization = false)
{
// Materialize the sequence into a list to avoid multiple enumeration warnings
var orgUsersList = orgUsers.ToList();
// Email links must include information about the org and user for us to make routing decisions client side
// Given an org user, determine if existing BW user exists
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
var existingUsers = await userRepository.GetManyByEmailsAsync(orgUserEmails);
// hash existing users emails list for O(1) lookups
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
ou => ou.Id,
ou => existingUserEmailsHashSet.Contains(ou.Email)
);
// Determine if org has SSO enabled and if user is required to login with SSO
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
var orgSsoEnabled = organization.UseSso && (await ssoConfigurationRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = dataProtectorTokenFactory.Protect(orgUserInviteTokenable);
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
}
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
orgUserHasExistingUserDict,
initOrganization
);
}
}

View File

@ -0,0 +1,12 @@
using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public static class CollectionAccessSelectionExtensions
{
/// <summary>
/// This validates the permissions on the given assigned collection
/// </summary>
public static bool IsValidCollectionAccessConfiguration(this CollectionAccessSelection collectionAccessSelection) =>
collectionAccessSelection.Manage && (collectionAccessSelection.ReadOnly || collectionAccessSelection.HidePasswords);
}

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
public record CannotAutoScaleOnSelfHostError(EnvironmentRequest Invalid) : Error<EnvironmentRequest>(Code, Invalid)
{
public const string Code = "Cannot auto scale self-host.";
}

View File

@ -0,0 +1,18 @@
#nullable enable
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.Settings;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
public class EnvironmentRequest
{
public bool IsSelfHosted { get; init; }
public PasswordManagerSubscriptionUpdate PasswordManagerSubscriptionUpdate { get; init; }
public EnvironmentRequest(IGlobalSettings globalSettings, PasswordManagerSubscriptionUpdate passwordManagerSubscriptionUpdate)
{
IsSelfHosted = globalSettings.SelfHosted;
PasswordManagerSubscriptionUpdate = passwordManagerSubscriptionUpdate;
}
}

View File

@ -0,0 +1,14 @@
using Bit.Core.AdminConsole.Shared.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
public interface IInviteUsersEnvironmentValidator : IValidator<EnvironmentRequest>;
public class InviteUsersEnvironmentValidator : IInviteUsersEnvironmentValidator
{
public Task<ValidationResult<EnvironmentRequest>> ValidateAsync(EnvironmentRequest value) =>
Task.FromResult<ValidationResult<EnvironmentRequest>>(
value.IsSelfHosted && value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0 ?
new Invalid<EnvironmentRequest>(new CannotAutoScaleOnSelfHostError(value)) :
new Valid<EnvironmentRequest>(value));
}

View File

@ -0,0 +1,108 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public interface IInviteUsersValidator : IValidator<InviteOrganizationUsersValidationRequest>;
public class InviteOrganizationUsersValidator(
IOrganizationRepository organizationRepository,
IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator,
IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand,
IPaymentService paymentService) : IInviteUsersValidator
{
public async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateAsync(
InviteOrganizationUsersValidationRequest request)
{
var subscriptionUpdate = new PasswordManagerSubscriptionUpdate(request);
var passwordManagerValidationResult =
await inviteUsersPasswordManagerValidator.ValidateAsync(subscriptionUpdate);
if (passwordManagerValidationResult is Invalid<PasswordManagerSubscriptionUpdate> invalidSubscriptionUpdate)
{
return invalidSubscriptionUpdate.Map(request);
}
// If the organization has the Secrets Manager Standalone Discount, all users are added to secrets manager.
// This is an expensive call, so we're doing it now to delay the check as long as possible.
if (await paymentService.HasSecretsManagerStandalone(request.InviteOrganization))
{
request = new InviteOrganizationUsersValidationRequest(request)
{
Invites = request.Invites
.Select(x => new OrganizationUserInvite(x, accessSecretsManager: true))
.ToArray()
};
}
if (request.InviteOrganization.UseSecretsManager && request.Invites.Any(x => x.AccessSecretsManager))
{
return await ValidateSecretsManagerSubscriptionUpdateAsync(request, subscriptionUpdate);
}
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
request,
subscriptionUpdate,
null));
}
private async Task<ValidationResult<InviteOrganizationUsersValidationRequest>> ValidateSecretsManagerSubscriptionUpdateAsync(
InviteOrganizationUsersValidationRequest request,
PasswordManagerSubscriptionUpdate subscriptionUpdate)
{
try
{
var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(
organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId),
plan: request.InviteOrganization.Plan,
autoscaling: true);
var seatsToAdd = GetSecretManagerSeatAdjustment(request);
if (seatsToAdd > 0)
{
smSubscriptionUpdate.AdjustSeats(seatsToAdd);
await secretsManagerSubscriptionCommand.ValidateUpdateAsync(smSubscriptionUpdate);
}
return new Valid<InviteOrganizationUsersValidationRequest>(new InviteOrganizationUsersValidationRequest(
request,
subscriptionUpdate,
smSubscriptionUpdate));
}
catch (Exception ex)
{
return new Invalid<InviteOrganizationUsersValidationRequest>(
new Error<InviteOrganizationUsersValidationRequest>(ex.Message, request));
}
}
/// <summary>
/// This calculates the number of SM seats to add to the organization seat total.
///
/// If they have a current seat limit (it can be null), we want to figure out how many are available (seats -
/// occupied seats). Then, we'll subtract the available seats from the number of users we're trying to invite.
///
/// If it's negative, we have available seats and do not need to increase, so we go with 0.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
private static int GetSecretManagerSeatAdjustment(InviteOrganizationUsersValidationRequest request) =>
request.InviteOrganization.SmSeats.HasValue
? Math.Max(
request.Invites.Count(x => x.AccessSecretsManager) -
(request.InviteOrganization.SmSeats.Value -
request.OccupiedSmSeats),
0)
: 0;
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
public record OrganizationNoPaymentMethodFoundError(InviteOrganization InvalidRequest)
: Error<InviteOrganization>(Code, InvalidRequest)
{
public const string Code = "No payment method found.";
}
public record OrganizationNoSubscriptionFoundError(InviteOrganization InvalidRequest)
: Error<InviteOrganization>(Code, InvalidRequest)
{
public const string Code = "No subscription found.";
}

View File

@ -0,0 +1,32 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Shared.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
public interface IInviteUsersOrganizationValidator : IValidator<InviteOrganization>;
public class InviteUsersOrganizationValidator : IInviteUsersOrganizationValidator
{
public Task<ValidationResult<InviteOrganization>> ValidateAsync(InviteOrganization inviteOrganization)
{
if (inviteOrganization.Seats is null)
{
return Task.FromResult<ValidationResult<InviteOrganization>>(
new Valid<InviteOrganization>(inviteOrganization));
}
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewayCustomerId))
{
return Task.FromResult<ValidationResult<InviteOrganization>>(
new Invalid<InviteOrganization>(new OrganizationNoPaymentMethodFoundError(inviteOrganization)));
}
if (string.IsNullOrWhiteSpace(inviteOrganization.GatewaySubscriptionId))
{
return Task.FromResult<ValidationResult<InviteOrganization>>(
new Invalid<InviteOrganization>(new OrganizationNoSubscriptionFoundError(inviteOrganization)));
}
return Task.FromResult<ValidationResult<InviteOrganization>>(new Valid<InviteOrganization>(inviteOrganization));
}
}

View File

@ -0,0 +1,30 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
public record PasswordManagerSeatLimitHasBeenReachedError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
{
public const string Code = "Seat limit has been reached.";
}
public record PasswordManagerPlanDoesNotAllowAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
{
public const string Code = "Plan does not allow additional seats.";
}
public record PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(GetErrorMessage(InvalidRequest), InvalidRequest)
{
private static string GetErrorMessage(PasswordManagerSubscriptionUpdate invalidRequest) =>
string.Format(Code, invalidRequest.PasswordManagerPlan.MaxAdditionalSeats);
public const string Code = "Organization plan allows a maximum of {0} additional seats.";
}
public record PasswordManagerMustHaveSeatsError(PasswordManagerSubscriptionUpdate InvalidRequest)
: Error<PasswordManagerSubscriptionUpdate>(Code, InvalidRequest)
{
public const string Code = "You do not have any Password Manager seats!";
}

View File

@ -0,0 +1,117 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
public interface IInviteUsersPasswordManagerValidator : IValidator<PasswordManagerSubscriptionUpdate>;
public class InviteUsersPasswordManagerValidator(
IGlobalSettings globalSettings,
IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator,
IInviteUsersOrganizationValidator inviteUsersOrganizationValidator,
IProviderRepository providerRepository,
IPaymentService paymentService,
IOrganizationRepository organizationRepository
) : IInviteUsersPasswordManagerValidator
{
/// <summary>
/// This is for validating if the organization can add additional users.
/// </summary>
/// <param name="subscriptionUpdate"></param>
/// <returns></returns>
public static ValidationResult<PasswordManagerSubscriptionUpdate> ValidatePasswordManager(PasswordManagerSubscriptionUpdate subscriptionUpdate)
{
if (subscriptionUpdate.Seats is null)
{
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
if (subscriptionUpdate.SeatsRequiredToAdd == 0)
{
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
if (subscriptionUpdate.PasswordManagerPlan.BaseSeats + subscriptionUpdate.SeatsRequiredToAdd <= 0)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
}
if (subscriptionUpdate.MaxSeatsReached)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));
}
if (subscriptionUpdate.PasswordManagerPlan.HasAdditionalSeatsOption is false)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(
new PasswordManagerPlanDoesNotAllowAdditionalSeatsError(subscriptionUpdate));
}
// Apparently MaxAdditionalSeats is never set. Can probably be removed.
if (subscriptionUpdate.UpdatedSeatTotal - subscriptionUpdate.PasswordManagerPlan.BaseSeats > subscriptionUpdate.PasswordManagerPlan.MaxAdditionalSeats)
{
return new Invalid<PasswordManagerSubscriptionUpdate>(
new PasswordManagerPlanOnlyAllowsMaxAdditionalSeatsError(subscriptionUpdate));
}
return new Valid<PasswordManagerSubscriptionUpdate>(subscriptionUpdate);
}
public async Task<ValidationResult<PasswordManagerSubscriptionUpdate>> ValidateAsync(PasswordManagerSubscriptionUpdate request)
{
switch (ValidatePasswordManager(request))
{
case Valid<PasswordManagerSubscriptionUpdate> valid
when valid.Value.SeatsRequiredToAdd is 0:
return new Valid<PasswordManagerSubscriptionUpdate>(request);
case Invalid<PasswordManagerSubscriptionUpdate> invalid:
return invalid;
}
if (await inviteUsersEnvironmentValidator.ValidateAsync(new EnvironmentRequest(globalSettings, request)) is Invalid<EnvironmentRequest> invalidEnvironment)
{
return invalidEnvironment.Map(request);
}
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
{
return organizationValidation.Map(request);
}
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
if (provider is not null)
{
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
if (providerValidationResult is Invalid<InviteOrganizationProvider> invalidProviderValidation)
{
return invalidProviderValidation.Map(request);
}
}
var paymentSubscription = await paymentService.GetSubscriptionAsync(
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
var paymentValidationResult = InviteUserPaymentValidation.Validate(
new PaymentsSubscription(paymentSubscription, request.InviteOrganization));
if (paymentValidationResult is Invalid<PaymentsSubscription> invalidPaymentValidation)
{
return invalidPaymentValidation.Map(request);
}
return new Valid<PasswordManagerSubscriptionUpdate>(request);
}
}

View File

@ -0,0 +1,89 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
public class PasswordManagerSubscriptionUpdate
{
/// <summary>
/// Seats the organization has
/// </summary>
public int? Seats { get; }
/// <summary>
/// Max number of seats that the organization can have
/// </summary>
public int? MaxAutoScaleSeats { get; }
/// <summary>
/// Seats currently occupied by current users
/// </summary>
public int OccupiedSeats { get; }
/// <summary>
/// Users to add to the organization seats
/// </summary>
public int NewUsersToAdd { get; }
/// <summary>
/// Number of seats available for users
/// </summary>
public int? AvailableSeats => Seats - OccupiedSeats;
/// <summary>
/// Number of seats to scale the organization by.
///
/// If Organization has no seat limit (Seats is null), then there are no new seats to add.
/// </summary>
public int SeatsRequiredToAdd => AvailableSeats.HasValue ? Math.Max(NewUsersToAdd - AvailableSeats.Value, 0) : 0;
/// <summary>
/// New total of seats for the organization
/// </summary>
public int? UpdatedSeatTotal => Seats + SeatsRequiredToAdd;
/// <summary>
/// If the new seat total is equal to the organization's auto-scale seat count
/// </summary>
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
public InviteOrganization InviteOrganization { get; }
private PasswordManagerSubscriptionUpdate(int? organizationSeats,
int? organizationAutoScaleSeatLimit,
int currentSeats,
int newUsersToAdd,
Plan.PasswordManagerPlanFeatures plan,
InviteOrganization inviteOrganization)
{
Seats = organizationSeats;
MaxAutoScaleSeats = organizationAutoScaleSeatLimit;
OccupiedSeats = currentSeats;
NewUsersToAdd = newUsersToAdd;
PasswordManagerPlan = plan;
InviteOrganization = inviteOrganization;
}
public PasswordManagerSubscriptionUpdate(InviteOrganization inviteOrganization, int occupiedSeats, int newUsersToAdd) :
this(
organizationSeats: inviteOrganization.Seats,
organizationAutoScaleSeatLimit: inviteOrganization.MaxAutoScaleSeats,
currentSeats: occupiedSeats,
newUsersToAdd: newUsersToAdd,
plan: inviteOrganization.Plan.PasswordManager,
inviteOrganization: inviteOrganization)
{ }
public PasswordManagerSubscriptionUpdate(InviteOrganizationUsersValidationRequest usersValidationRequest) :
this(
organizationSeats: usersValidationRequest.InviteOrganization.Seats,
organizationAutoScaleSeatLimit: usersValidationRequest.InviteOrganization.MaxAutoScaleSeats,
currentSeats: usersValidationRequest.OccupiedPmSeats,
newUsersToAdd: usersValidationRequest.Invites.Length,
plan: usersValidationRequest.InviteOrganization.Plan.PasswordManager,
inviteOrganization: usersValidationRequest.InviteOrganization)
{ }
}

View File

@ -0,0 +1,10 @@
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
public record PaymentCancelledSubscriptionError(PaymentsSubscription InvalidRequest)
: Error<PaymentsSubscription>(Code, InvalidRequest)
{
public const string Code = "You do not have an active subscription. Reinstate your subscription to make changes.";
}

View File

@ -0,0 +1,25 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
public static class InviteUserPaymentValidation
{
public static ValidationResult<PaymentsSubscription> Validate(PaymentsSubscription subscription)
{
if (subscription.ProductTierType is ProductTierType.Free)
{
return new Valid<PaymentsSubscription>(subscription);
}
if (subscription.SubscriptionStatus == StripeConstants.SubscriptionStatus.Canceled)
{
return new Invalid<PaymentsSubscription>(new PaymentCancelledSubscriptionError(subscription));
}
return new Valid<PaymentsSubscription>(subscription);
}
}

View File

@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Enums;
using Bit.Core.Models.Business;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
public class PaymentsSubscription
{
public ProductTierType ProductTierType { get; init; }
public string SubscriptionStatus { get; init; }
public PaymentsSubscription() { }
public PaymentsSubscription(SubscriptionInfo subscriptionInfo, InviteOrganization inviteOrganization)
{
SubscriptionStatus = subscriptionInfo?.Subscription?.Status ?? string.Empty;
ProductTierType = inviteOrganization.Plan.ProductTier;
}
}

View File

@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Errors;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
public record ProviderBillableSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
{
public const string Code = "Seat limit has been reached. Please contact your provider to add more seats.";
}
public record ProviderResellerSeatLimitError(InviteOrganizationProvider InvalidRequest) : Error<InviteOrganizationProvider>(Code, InvalidRequest)
{
public const string Code = "Seat limit has been reached. Contact your provider to purchase additional seats.";
}

View File

@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Enums.Provider;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
public class InviteOrganizationProvider
{
public Guid ProviderId { get; init; }
public ProviderType Type { get; init; }
public ProviderStatusType Status { get; init; }
public bool Enabled { get; init; }
public InviteOrganizationProvider(Entities.Provider.Provider provider)
{
ProviderId = provider.Id;
Type = provider.Type;
Status = provider.Status;
Enabled = provider.Enabled;
}
}

View File

@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Shared.Validation;
using Bit.Core.Billing.Extensions;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
public static class InvitingUserOrganizationProviderValidator
{
public static ValidationResult<InviteOrganizationProvider> Validate(InviteOrganizationProvider inviteOrganizationProvider)
{
if (inviteOrganizationProvider is not { Enabled: true })
{
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
}
if (inviteOrganizationProvider.IsBillable())
{
return new Invalid<InviteOrganizationProvider>(new ProviderBillableSeatLimitError(inviteOrganizationProvider));
}
if (inviteOrganizationProvider.Type == ProviderType.Reseller)
{
return new Invalid<InviteOrganizationProvider>(new ProviderResellerSeatLimitError(inviteOrganizationProvider));
}
return new Valid<InviteOrganizationProvider>(inviteOrganizationProvider);
}
}

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

@ -6,13 +6,13 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Constants;
@ -26,18 +26,17 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.Mail;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
namespace Bit.Core.Services;
@ -58,7 +57,6 @@ public class OrganizationService : IOrganizationService
private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IReferenceEventService _referenceEventService;
private readonly IGlobalSettings _globalSettings;
@ -70,13 +68,12 @@ public class OrganizationService : IOrganizationService
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IProviderRepository _providerRepository;
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
public OrganizationService(
IOrganizationRepository organizationRepository,
@ -94,7 +91,6 @@ public class OrganizationService : IOrganizationService
IPaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyService policyService,
ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository,
IReferenceEventService referenceEventService,
IGlobalSettings globalSettings,
@ -104,15 +100,14 @@ public class OrganizationService : IOrganizationService
IProviderOrganizationRepository providerOrganizationRepository,
IProviderUserRepository providerUserRepository,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IProviderRepository providerRepository,
IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient,
IPolicyRequirementQuery policyRequirementQuery)
IPolicyRequirementQuery policyRequirementQuery,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -129,7 +124,6 @@ public class OrganizationService : IOrganizationService
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyService = policyService;
_ssoConfigRepository = ssoConfigRepository;
_ssoUserRepository = ssoUserRepository;
_referenceEventService = referenceEventService;
_globalSettings = globalSettings;
@ -141,13 +135,12 @@ public class OrganizationService : IOrganizationService
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_providerRepository = providerRepository;
_orgUserInviteTokenableFactory = orgUserInviteTokenableFactory;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
_policyRequirementQuery = policyRequirementQuery;
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -1055,74 +1048,14 @@ public class OrganizationService : IOrganizationService
await SendInviteAsync(orgUser, org, initOrganization);
}
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization)
{
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization);
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization)
{
// convert single org user into array of 1 org user
var orgUsers = new[] { orgUser };
var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync(orgUsers, organization, initOrganization);
await _mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo);
}
private async Task<OrganizationInvitesInfo> BuildOrganizationInvitesInfoAsync(
IEnumerable<OrganizationUser> orgUsers,
Organization organization,
bool initOrganization = false)
{
// Materialize the sequence into a list to avoid multiple enumeration warnings
var orgUsersList = orgUsers.ToList();
// Email links must include information about the org and user for us to make routing decisions client side
// Given an org user, determine if existing BW user exists
var orgUserEmails = orgUsersList.Select(ou => ou.Email).ToList();
var existingUsers = await _userRepository.GetManyByEmailsAsync(orgUserEmails);
// hash existing users emails list for O(1) lookups
var existingUserEmailsHashSet = new HashSet<string>(existingUsers.Select(u => u.Email));
// Create a dictionary of org user guids and bools for whether or not they have an existing BW user
var orgUserHasExistingUserDict = orgUsersList.ToDictionary(
ou => ou.Id,
ou => existingUserEmailsHashSet.Contains(ou.Email)
);
// Determine if org has SSO enabled and if user is required to login with SSO
// Note: we only want to call the DB after checking if the org can use SSO per plan and if they have any policies enabled.
var orgSsoEnabled = organization.UseSso && (await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id))?.Enabled == true;
// Even though the require SSO policy can be turned on regardless of SSO being enabled, for this logic, we only
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens
(OrganizationUser, ExpiringToken) MakeOrgUserExpiringTokenPair(OrganizationUser orgUser)
{
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
return (orgUser, new ExpiringToken(protectedToken, orgUserInviteTokenable.ExpirationDate));
}
var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair);
return new OrganizationInvitesInfo(
organization,
orgSsoEnabled,
orgSsoLoginRequiredPolicyEnabled,
orgUsersWithExpTokens,
orgUserHasExistingUserDict,
initOrganization
);
}
private async Task SendInviteAsync(OrganizationUser orgUser, Organization organization, bool initOrganization) =>
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(
users: [orgUser],
organization: organization,
initOrganization: initOrganization));
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
Organization organization,

View File

@ -6,10 +6,39 @@ public abstract record ValidationResult<T>;
public record Valid<T> : ValidationResult<T>
{
public Valid() { }
public Valid(T Value)
{
this.Value = Value;
}
public T Value { get; init; }
}
public record Invalid<T> : ValidationResult<T>
{
public IEnumerable<Error<T>> Errors { get; init; }
public IEnumerable<Error<T>> Errors { get; init; } = [];
public string ErrorMessageString => string.Join(" ", Errors.Select(e => e.Message));
public Invalid() { }
public Invalid(Error<T> error) : this([error]) { }
public Invalid(IEnumerable<Error<T>> errors)
{
Errors = errors;
}
}
public static class ValidationResultMappers
{
public static ValidationResult<B> Map<A, B>(this ValidationResult<A> validationResult, B invalidValue) =>
validationResult switch
{
Valid<A> => new Valid<B>(invalidValue),
Invalid<A> invalid => new Invalid<B>(invalid.Errors.Select(x => x.ToError(invalidValue))),
_ => throw new ArgumentOutOfRangeException(nameof(validationResult), "Unhandled validation result type")
};
}

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -28,6 +29,13 @@ public static class BillingExtensions
Status: ProviderStatusType.Billable
};
public static bool IsBillable(this InviteOrganizationProvider inviteOrganizationProvider) =>
inviteOrganizationProvider is
{
Type: ProviderType.Msp or ProviderType.MultiOrganizationEnterprise,
Status: ProviderStatusType.Billable
};
public static bool SupportsConsolidatedBilling(this ProviderType providerType)
=> providerType is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;

View File

@ -109,6 +109,7 @@ public static class FeatureFlagKeys
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
public const string PolicyRequirements = "pm-14439-policy-requirements";
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";

View File

@ -1,6 +1,7 @@
#nullable enable
using Bit.Core.AdminConsole.Errors;
using Bit.Core.AdminConsole.Shared.Validation;
namespace Bit.Core.Models.Commands;
@ -40,10 +41,23 @@ 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(IEnumerable<Error<T>> errors) : this(errors.Select(e => e.Message))
{
Errors = errors.ToArray();
}
public Failure(Error<T> error) : this([error.Message])
{
Errors = [error];
}
}
public class Partial<T> : CommandResult<T>
@ -57,3 +71,18 @@ public class Partial<T> : CommandResult<T>
Failures = failedItems.ToArray();
}
}
public static class CommandResultExtensions
{
/// <summary>
/// This is to help map between the InvalidT ValidationResult and the FailureT CommandResult types.
///
/// </summary>
/// <param name="invalidResult">This is the invalid type from validating the object.</param>
/// <param name="mappingFunction">This function will map between the two types for the inner ErrorT</param>
/// <typeparam name="A">Invalid object's type</typeparam>
/// <typeparam name="B">Failure object's type</typeparam>
/// <returns></returns>
public static CommandResult<B> MapToFailure<A, B>(this Invalid<A> invalidResult, Func<A, B> mappingFunction) =>
new Failure<B>(invalidResult.Errors.Select(errorA => errorA.ToError(mappingFunction(errorA.ErroredValue))));
}

View File

@ -13,6 +13,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.OrganizationFeatures.OrganizationCollections;
@ -174,6 +179,14 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IAuthorizationHandler, OrganizationUserUserMiniDetailsAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
services.AddScoped<IInviteUsersValidator, InviteOrganizationUsersValidator>();
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
}
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of

View File

@ -5,4 +5,5 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
public interface IUpdateSecretsManagerSubscriptionCommand
{
Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update);
Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update);
}

View File

@ -124,7 +124,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
}
private async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update)
public async Task ValidateUpdateAsync(SecretsManagerSubscriptionUpdate update)
{
if (_globalSettings.SelfHosted)
{

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Api.Requests.Accounts;
using Bit.Core.Billing.Models.Api.Requests.Organizations;
@ -38,9 +39,28 @@ public interface IPaymentService
Task<SubscriptionInfo> GetSubscriptionAsync(ISubscriber subscriber);
Task<TaxInfo> GetTaxInfoAsync(ISubscriber subscriber);
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats,
int additionalServiceAccount);
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats, int additionalServiceAccount);
/// <summary>
/// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager.
/// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password
/// Manager and Secrets Manger.
///
/// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager.
/// </summary>
/// <param name="organization">Organization Entity</param>
/// <returns>If the organization has Secrets Manager and has the Standalone Stripe Discount</returns>
Task<bool> HasSecretsManagerStandalone(Organization organization);
/// <summary>
/// Secrets Manager Standalone is a discount in Stripe that is used to give an organization access to Secrets Manager.
/// Usually, this also implies that when they invite a user to their organization, they are doing so for both Password
/// Manager and Secrets Manger.
///
/// This will not call out to Stripe if they don't have a GatewayId or if they don't have Secrets Manager.
/// </summary>
/// <param name="organization">Organization Representation used for Inviting Organization Users</param>
/// <returns>If the organization has Secrets Manager and has the Standalone Stripe Discount</returns>
Task<bool> HasSecretsManagerStandalone(InviteOrganization organization);
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
@ -1110,14 +1111,27 @@ public class StripePaymentService : IPaymentService
new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount),
true);
public async Task<bool> HasSecretsManagerStandalone(Organization organization)
public async Task<bool> HasSecretsManagerStandalone(Organization organization) =>
await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId,
organizationHasSecretsManager: organization.UseSecretsManager);
public async Task<bool> HasSecretsManagerStandalone(InviteOrganization organization) =>
await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId,
organizationHasSecretsManager: organization.UseSecretsManager);
private async Task<bool> HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager)
{
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
if (string.IsNullOrEmpty(gatewayCustomerId))
{
return false;
}
var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId);
if (organizationHasSecretsManager is false)
{
return false;
}
var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId);
return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId;
}

View File

@ -0,0 +1,44 @@
using System.Text.RegularExpressions;
using MimeKit;
namespace Bit.Core.Utilities;
public static class EmailValidation
{
public static bool IsValidEmail(this string emailAddress)
{
if (string.IsNullOrWhiteSpace(emailAddress))
{
return false;
}
try
{
var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address;
if (parsedEmailAddress != emailAddress)
{
return false;
}
}
catch (ParseException)
{
return false;
}
// The regex below is intended to catch edge cases that are not handled by the general parsing check above.
// This enforces the following rules:
// * Requires ASCII only in the local-part (code points 0-127)
// * Requires an @ symbol
// * Allows any char in second-level domain name, including unicode and symbols
// * Requires at least one period (.) separating SLD from TLD
// * Must end in a letter (including unicode)
// See the unit tests for examples of what is allowed.
var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$";
if (!Regex.IsMatch(emailAddress, emailFormat))
{
return false;
}
return true;
}
}

View File

@ -1,6 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using MimeKit;
namespace Bit.Core.Utilities;
@ -12,39 +10,8 @@ public class StrictEmailAddressAttribute : ValidationAttribute
public override bool IsValid(object value)
{
var emailAddress = value?.ToString();
if (emailAddress == null)
{
return false;
}
var emailAddress = value?.ToString() ?? string.Empty;
try
{
var parsedEmailAddress = MailboxAddress.Parse(emailAddress).Address;
if (parsedEmailAddress != emailAddress)
{
return false;
}
}
catch (ParseException)
{
return false;
}
// The regex below is intended to catch edge cases that are not handled by the general parsing check above.
// This enforces the following rules:
// * Requires ASCII only in the local-part (code points 0-127)
// * Requires an @ symbol
// * Allows any char in second-level domain name, including unicode and symbols
// * Requires at least one period (.) separating SLD from TLD
// * Must end in a letter (including unicode)
// See the unit tests for examples of what is allowed.
var emailFormat = @"^[\x00-\x7F]+@.+\.\p{L}+$";
if (!Regex.IsMatch(emailAddress, emailFormat))
{
return false;
}
return new EmailAddressAttribute().IsValid(emailAddress);
return emailAddress.IsValidEmail() && new EmailAddressAttribute().IsValid(emailAddress);
}
}