diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index e312f009c9b9..ab34f6506b2e 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -2,20 +2,28 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper; namespace Bit.Api.AdminConsole.Public.Controllers; @@ -36,6 +44,10 @@ public class MembersController : Controller private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; + private readonly IFeatureService _featureService; + private readonly IInviteOrganizationUsersCommand _inviteOrganizationUsersCommand; + private readonly IPricingClient _pricingClient; + private readonly TimeProvider _timeProvider; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -50,7 +62,11 @@ public MembersController( IRemoveOrganizationUserCommand removeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2, - IRestoreOrganizationUserCommand restoreOrganizationUserCommand) + IRestoreOrganizationUserCommand restoreOrganizationUserCommand, + IFeatureService featureService, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + IPricingClient pricingClient, + TimeProvider timeProvider) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -65,6 +81,10 @@ public MembersController( _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; + _featureService = featureService; + _inviteOrganizationUsersCommand = inviteOrganizationUsersCommand; + _pricingClient = pricingClient; + _timeProvider = timeProvider; } /// @@ -156,6 +176,10 @@ public async Task Post([FromBody] MemberCreateRequestModel model) } var invite = model.ToOrganizationUserInvite(); + if (_featureService.IsEnabled(FeatureFlagKeys.CommandResultRefactor)) + { + return await PostInviteUserAsync_vNext(model, organization!, hasStandaloneSecretsManager); + } invite.AccessSecretsManager = hasStandaloneSecretsManager; @@ -165,6 +189,33 @@ public async Task Post([FromBody] MemberCreateRequestModel model) return new JsonResult(response); } + private async Task PostInviteUserAsync_vNext( + MemberCreateRequestModel model, + Core.AdminConsole.Entities.Organization organization, + bool hasStandaloneSecretsManager) + { + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var inviteOrganization = new InviteOrganization(organization, plan); + var request = model.ToInviteRequest(inviteOrganization, hasStandaloneSecretsManager, Guid.Empty, _timeProvider.GetUtcNow()); + + var result = await _inviteOrganizationUsersCommand.InviteImportedOrganizationUsersAsync(request); + + switch (result) + { + case Success success: + var user = success.Value.InvitedUsers.First(); + var collections = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(); + var response = new MemberResponseModel(user, collections); + return new JsonResult(response); + case Failure { Error.Message: NoUsersToInviteError.Code }: + throw new BadRequestException("This user has already been invited."); + case Failure failure: + throw MapToBitException(failure.Error); + default: + throw new InvalidOperationException(); + } + } + /// /// Update a member. /// diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index b3182601b5fd..c3ce7ba1db6b 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -2,9 +2,12 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; +using Bit.Core.Models.Data; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Public.Models.Request; @@ -43,4 +46,32 @@ public OrganizationUserInvite ToOrganizationUserInvite() return invite; } + + public InviteOrganizationUsersRequest ToInviteRequest( + InviteOrganization inviteOrganization, + bool accessSecretsManager, + Guid performedBy, + DateTimeOffset performedAt) + { + // Permissions property is optional for backwards compatibility with existing usage + var permissions = (Type is OrganizationUserType.Custom && Permissions is not null) + ? Permissions.ToData() + : new Permissions(); + + return new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInviteCommandModel( + email: Email, + assignedCollections: Collections?.Select(c => c.ToCollectionAccessSelection()) ?? [], + groups: Groups ?? [], + type: Type!.Value, + permissions: permissions, + externalId: ExternalId, + accessSecretsManager: accessSecretsManager) + ], + inviteOrganization: inviteOrganization, + performedBy: performedBy, + performedAt: performedAt); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index 30fdb7c85a9a..550b115aca0f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -95,7 +95,7 @@ private async Task BuildOrganizationInvitesInfoAsync(IE private async Task GetInviterEmailAsync(Guid? invitingUserId) { - if (!invitingUserId.HasValue) + if (!invitingUserId.HasValue || invitingUserId.Value == Guid.Empty) { return null; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8baa4e1fc464..42964daab50a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,7 @@ public static class FeatureFlagKeys public const string BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements"; public const string UpdateJoinOrganizationEmailTemplate = "pm-28396-update-join-organization-email-template"; public const string RefactorOrgAcceptInit = "pm-33082-refactor-org-accept-init"; + public const string CommandResultRefactor = "pm-23401-commandresult-refactor"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs index e4bdbdb1749c..25c6a1cdda1c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -5,12 +5,15 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Public.Response; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Test.Common.Helpers; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Public.Controllers; @@ -28,6 +31,7 @@ public class MembersControllerTests : IClassFixture, IAsy public MembersControllerTests(ApiApplicationFactory factory) { _factory = factory; + _factory.SubstituteService(_ => { }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } @@ -398,4 +402,80 @@ public async Task Restore_DifferentOrganization_ReturnsNotFound() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public async Task Post_CustomMember_WithCommandResultRefactor_Success() + { + var featureService = _factory.GetService(); + featureService + .IsEnabled(FeatureFlagKeys.CommandResultRefactor) + .Returns(true); + + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var request = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.Custom, + ExternalId = "myCustomUser", + Collections = [], + Groups = [] + }; + + var response = await _client.PostAsync("/public/members", JsonContent.Create(request)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.Equal(email, result.Email); + Assert.Equal(OrganizationUserType.Custom, result.Type); + Assert.Equal("myCustomUser", result.ExternalId); + Assert.Empty(result.Collections); + + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByIdAsync(result.Id); + + Assert.NotNull(orgUser); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.Custom, orgUser.Type); + Assert.Equal("myCustomUser", orgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + } + + [Fact] + public async Task Post_UserMember_WithCommandResultRefactor_Success() + { + var featureService = _factory.GetService(); + featureService + .IsEnabled(FeatureFlagKeys.CommandResultRefactor) + .Returns(true); + + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var request = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.User, + Collections = [], + Groups = [] + }; + + var response = await _client.PostAsync("/public/members", JsonContent.Create(request)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.Equal(email, result.Email); + Assert.Equal(OrganizationUserType.User, result.Type); + + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByIdAsync(result.Id); + + Assert.NotNull(orgUser); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.User, orgUser.Type); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + } }