Skip to content

Commit 8762fe1

Browse files
committed
Merge branch 'main' into ac/pm-15621/refactor-delete-command
2 parents dcc2cc7 + 0d7363c commit 8762fe1

File tree

76 files changed

+3865
-363
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+3865
-363
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<PropertyGroup>
44
<TargetFramework>net8.0</TargetFramework>
55

6-
<Version>2025.4.0</Version>
6+
<Version>2025.4.1</Version>
77

88
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
99
<ImplicitUsings>enable</ImplicitUsings>

bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
using Bit.Core.AdminConsole.Enums;
2+
using Bit.Core.AdminConsole.Models.Business;
3+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
24
using Bit.Core.Enums;
3-
using Bit.Core.Models.Business;
5+
using Bit.Core.Exceptions;
46
using Bit.Core.Models.Data;
57
using Bit.Core.Utilities;
8+
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
69

710
namespace Bit.Scim.Models;
811

912
public class ScimUserRequestModel : BaseScimUserModel
1013
{
1114
public ScimUserRequestModel()
1215
: base(false)
13-
{ }
16+
{
17+
}
1418

1519
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
1620
{
@@ -25,6 +29,31 @@ public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProv
2529
};
2630
}
2731

32+
public InviteOrganizationUsersRequest ToRequest(
33+
ScimProviderType scimProvider,
34+
InviteOrganization inviteOrganization,
35+
DateTimeOffset performedAt)
36+
{
37+
var email = EmailForInvite(scimProvider);
38+
39+
if (string.IsNullOrWhiteSpace(email) || !Active)
40+
{
41+
throw new BadRequestException();
42+
}
43+
44+
return new InviteOrganizationUsersRequest(
45+
invites:
46+
[
47+
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
48+
email: email,
49+
externalId: ExternalIdForInvite()
50+
)
51+
],
52+
inviteOrganization: inviteOrganization,
53+
performedBy: Guid.Empty, // SCIM does not have a user id
54+
performedAt: performedAt);
55+
}
56+
2857
private string EmailForInvite(ScimProviderType scimProvider)
2958
{
3059
var email = PrimaryEmail?.ToLowerInvariant();

bitwarden_license/src/Scim/Users/PostUserCommand.cs

Lines changed: 95 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,99 @@
1-
using Bit.Core.Enums;
1+
#nullable enable
2+
3+
using Bit.Core;
4+
using Bit.Core.AdminConsole.Enums;
5+
using Bit.Core.AdminConsole.Models.Business;
6+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
7+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
8+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
9+
using Bit.Core.Billing.Pricing;
10+
using Bit.Core.Enums;
211
using Bit.Core.Exceptions;
12+
using Bit.Core.Models.Commands;
313
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
414
using Bit.Core.Repositories;
515
using Bit.Core.Services;
616
using Bit.Scim.Context;
717
using Bit.Scim.Models;
818
using Bit.Scim.Users.Interfaces;
19+
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
920

1021
namespace Bit.Scim.Users;
1122

12-
public class PostUserCommand : IPostUserCommand
23+
public class PostUserCommand(
24+
IOrganizationRepository organizationRepository,
25+
IOrganizationUserRepository organizationUserRepository,
26+
IOrganizationService organizationService,
27+
IPaymentService paymentService,
28+
IScimContext scimContext,
29+
IFeatureService featureService,
30+
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
31+
TimeProvider timeProvider,
32+
IPricingClient pricingClient)
33+
: IPostUserCommand
1334
{
14-
private readonly IOrganizationRepository _organizationRepository;
15-
private readonly IOrganizationUserRepository _organizationUserRepository;
16-
private readonly IOrganizationService _organizationService;
17-
private readonly IPaymentService _paymentService;
18-
private readonly IScimContext _scimContext;
19-
20-
public PostUserCommand(
21-
IOrganizationRepository organizationRepository,
22-
IOrganizationUserRepository organizationUserRepository,
23-
IOrganizationService organizationService,
24-
IPaymentService paymentService,
25-
IScimContext scimContext)
35+
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
2636
{
27-
_organizationRepository = organizationRepository;
28-
_organizationUserRepository = organizationUserRepository;
29-
_organizationService = organizationService;
30-
_paymentService = paymentService;
31-
_scimContext = scimContext;
37+
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
38+
{
39+
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
40+
}
41+
42+
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
3243
}
3344

34-
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
45+
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
46+
ScimUserRequestModel model,
47+
Guid organizationId,
48+
ScimProviderType scimProvider)
49+
{
50+
var organization = await organizationRepository.GetByIdAsync(organizationId);
51+
52+
if (organization is null)
53+
{
54+
throw new NotFoundException();
55+
}
56+
57+
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
58+
59+
var request = model.ToRequest(
60+
scimProvider: scimProvider,
61+
inviteOrganization: new InviteOrganization(organization, plan),
62+
performedAt: timeProvider.GetUtcNow());
63+
64+
var orgUsers = await organizationUserRepository
65+
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
66+
67+
if (orgUsers.Any(existingUser =>
68+
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
69+
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
70+
{
71+
throw new ConflictException("User already exists.");
72+
}
73+
74+
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
75+
76+
var invitedOrganizationUserId = result switch
77+
{
78+
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
79+
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors
80+
.Any(x => x.Message == NoUsersToInviteError.Code) => (Guid?)null,
81+
Failure<ScimInviteOrganizationUsersResponse> failure when failure.Errors.Length != 0 => throw MapToBitException(failure.Errors),
82+
_ => throw new InvalidOperationException()
83+
};
84+
85+
var organizationUser = invitedOrganizationUserId.HasValue
86+
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
87+
: null;
88+
89+
return organizationUser;
90+
}
91+
92+
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
93+
ScimUserRequestModel model,
94+
Guid organizationId,
95+
ScimProviderType scimProvider)
3596
{
36-
var scimProvider = _scimContext.RequestScimProvider;
3797
var invite = model.ToOrganizationUserInvite(scimProvider);
3898

3999
var email = invite.Emails.Single();
@@ -44,7 +104,7 @@ public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId
44104
throw new BadRequestException();
45105
}
46106

47-
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
107+
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
48108
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
49109
if (orgUserByEmail != null)
50110
{
@@ -57,13 +117,21 @@ public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId
57117
throw new ConflictException();
58118
}
59119

60-
var organization = await _organizationRepository.GetByIdAsync(organizationId);
61-
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
120+
var organization = await organizationRepository.GetByIdAsync(organizationId);
121+
122+
if (organization == null)
123+
{
124+
throw new NotFoundException();
125+
}
126+
127+
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
62128
invite.AccessSecretsManager = hasStandaloneSecretsManager;
63129

64-
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
65-
invite, externalId);
66-
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
130+
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
131+
EventSystemUser.SCIM,
132+
invite,
133+
externalId);
134+
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
67135

68136
return orgUser;
69137
}

bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/UsersControllerTests.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
using System.Text.Json;
2+
using Bit.Core;
23
using Bit.Core.Enums;
4+
using Bit.Core.Services;
35
using Bit.Scim.IntegrationTest.Factories;
46
using Bit.Scim.Models;
57
using Bit.Scim.Utilities;
68
using Bit.Test.Common.Helpers;
9+
using NSubstitute;
710
using Xunit;
811

912
namespace Bit.Scim.IntegrationTest.Controllers.v2;
@@ -276,9 +279,18 @@ public async Task GetList_SearchUserNameWithoutOptionalParameters_Success()
276279
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
277280
}
278281

279-
[Fact]
280-
public async Task Post_Success()
282+
[Theory]
283+
[InlineData(true)]
284+
[InlineData(false)]
285+
public async Task Post_Success(bool isScimInviteUserOptimizationEnabled)
281286
{
287+
var localFactory = new ScimApplicationFactory();
288+
localFactory.SubstituteService((IFeatureService featureService)
289+
=> featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)
290+
.Returns(isScimInviteUserOptimizationEnabled));
291+
292+
localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
293+
282294
var email = "[email protected]";
283295
var displayName = "Test User 5";
284296
var externalId = "UE";
@@ -306,7 +318,7 @@ public async Task Post_Success()
306318
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
307319
};
308320

309-
var context = await _factory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
321+
var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);
310322

311323
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
312324

@@ -316,7 +328,7 @@ public async Task Post_Success()
316328
var responseModel = JsonSerializer.Deserialize<ScimUserResponseModel>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
317329
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel, "Id");
318330

319-
var databaseContext = _factory.GetDatabaseContext();
331+
var databaseContext = localFactory.GetDatabaseContext();
320332
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());
321333
}
322334

bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, str
2727
ExternalId = externalId,
2828
Emails = emails,
2929
Active = true,
30-
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
30+
Schemas = [ScimConstants.Scim2SchemaUser]
3131
};
3232

3333
sutProvider.GetDependency<IOrganizationUserRepository>()
@@ -39,13 +39,16 @@ public async Task PostUser_Success(SutProvider<PostUserCommand> sutProvider, str
3939
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
4040

4141
sutProvider.GetDependency<IOrganizationService>()
42-
.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
42+
.InviteUserAsync(organizationId,
43+
invitingUserId: null,
44+
EventSystemUser.SCIM,
4345
Arg.Is<OrganizationUserInvite>(i =>
4446
i.Emails.Single().Equals(scimUserRequestModel.PrimaryEmail.ToLowerInvariant()) &&
4547
i.Type == OrganizationUserType.User &&
4648
!i.Collections.Any() &&
4749
!i.Groups.Any() &&
48-
i.AccessSecretsManager), externalId)
50+
i.AccessSecretsManager),
51+
externalId)
4952
.Returns(newUser);
5053

5154
var user = await sutProvider.Sut.PostUserAsync(organizationId, scimUserRequestModel);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Bit.Core.AdminConsole.Errors;
2+
3+
public record InvalidResultTypeError<T>(T Value) : Error<T>(Code, Value)
4+
{
5+
public const string Code = "Invalid result type.";
6+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.Models.StaticStore;
3+
4+
namespace Bit.Core.AdminConsole.Models.Business;
5+
6+
public record InviteOrganization
7+
{
8+
public Guid OrganizationId { get; init; }
9+
public int? Seats { get; init; }
10+
public int? MaxAutoScaleSeats { get; init; }
11+
public int? SmSeats { get; init; }
12+
public int? SmMaxAutoScaleSeats { get; init; }
13+
public Plan Plan { get; init; }
14+
public string GatewayCustomerId { get; init; }
15+
public string GatewaySubscriptionId { get; init; }
16+
public bool UseSecretsManager { get; init; }
17+
18+
public InviteOrganization()
19+
{
20+
21+
}
22+
23+
public InviteOrganization(Organization organization, Plan plan)
24+
{
25+
OrganizationId = organization.Id;
26+
Seats = organization.Seats;
27+
MaxAutoScaleSeats = organization.MaxAutoscaleSeats;
28+
SmSeats = organization.SmSeats;
29+
SmMaxAutoScaleSeats = organization.MaxAutoscaleSmSeats;
30+
Plan = plan;
31+
GatewayCustomerId = organization.GatewayCustomerId;
32+
GatewaySubscriptionId = organization.GatewaySubscriptionId;
33+
UseSecretsManager = organization.UseSecretsManager;
34+
}
35+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Bit.Core.AdminConsole.Errors;
2+
using Bit.Core.Exceptions;
3+
4+
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
5+
6+
public static class ErrorMapper
7+
{
8+
9+
/// <summary>
10+
/// Maps the ErrorT to a Bit.Exception class.
11+
/// </summary>
12+
/// <param name="error"></param>
13+
/// <typeparam name="T"></typeparam>
14+
/// <returns></returns>
15+
public static Exception MapToBitException<T>(Error<T> error) =>
16+
error switch
17+
{
18+
UserAlreadyExistsError alreadyExistsError => new ConflictException(alreadyExistsError.Message),
19+
_ => new BadRequestException(error.Message)
20+
};
21+
22+
/// <summary>
23+
/// This maps the ErrorT object to the Bit.Exception class.
24+
///
25+
/// This should be replaced by an IActionResult mapper when possible.
26+
/// </summary>
27+
/// <param name="errors"></param>
28+
/// <typeparam name="T"></typeparam>
29+
/// <returns></returns>
30+
public static Exception MapToBitException<T>(ICollection<Error<T>> errors) =>
31+
errors switch
32+
{
33+
not null when errors.Count == 1 => MapToBitException(errors.First()),
34+
not null when errors.Count > 1 => new BadRequestException(string.Join(' ', errors.Select(e => e.Message))),
35+
_ => new BadRequestException()
36+
};
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Bit.Core.AdminConsole.Errors;
2+
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
3+
4+
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
5+
6+
public record FailedToInviteUsersError(InviteOrganizationUsersResponse Response) : Error<InviteOrganizationUsersResponse>(Code, Response)
7+
{
8+
public const string Code = "Failed to invite users";
9+
}

0 commit comments

Comments
 (0)