Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions bitwarden_license/src/Scim/Users/PostUserCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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.StagedUsers;
using Bit.Core.AdminConsole.Utilities.Commands;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
Expand All @@ -27,11 +28,18 @@ public class PostUserCommand(
IScimContext scimContext,
IFeatureService featureService,
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
ICreateStagedOrganizationUsersCommand createStagedOrganizationUsersCommand,
TimeProvider timeProvider)
: IPostUserCommand
{
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
{
var inviteUsersAfterProvisioning = scimContext.ScimConfiguration?.InviteUsersAfterProvisioning ?? true;
if (!inviteUsersAfterProvisioning && featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus))
{
return await StageScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
}

if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
{
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
Expand All @@ -40,6 +48,50 @@ public class PostUserCommand(
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
}

private async Task<OrganizationUserUserDetails?> StageScimOrganizationUserAsync(
ScimUserRequestModel model,
Guid organizationId,
ScimProviderType scimProvider)
{
var email = model.ToOrganizationUserInvite(scimProvider).Emails.Single();
var externalId = model.ExternalIdForInvite();

if (string.IsNullOrWhiteSpace(email) || !model.Active)
{
throw new BadRequestException();
}

var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
if (orgUsers.Any(ou => ou.Email?.ToLowerInvariant() == email || ou.ExternalId == externalId))
{
throw new ConflictException();
}

var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
throw new NotFoundException();
}

var result = await createStagedOrganizationUsersCommand.RunAsync(new CreateStagedOrganizationUsersRequest
{
Organization = organization,
Users = [new StagedOrganizationUserRequest { Email = email, ExternalId = externalId }],
EventSystemUser = EventSystemUser.SCIM
});

if (result.IsError)
{
throw new BadRequestException(result.AsError.Message);
}

// The command skips emails already present in the organization, so an empty result means the user
// was added by a concurrent request after the conflict check above.
var stagedUser = result.AsSuccess.FirstOrDefault() ?? throw new ConflictException();

return await organizationUserRepository.GetDetailsByIdAsync(stagedUser.Id);
}

private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
ScimUserRequestModel model,
Guid organizationId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ο»Ώusing System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Scim.IntegrationTest.Factories;
Expand Down Expand Up @@ -394,6 +395,122 @@ public async Task Post_ExistingData_Conflict(string email, string externalId)
Assert.Equal(_initialUserCount, databaseContext.OrganizationUsers.Count());
}

[Fact]
public async Task Post_InviteUsersAfterProvisioningDisabled_CreatesStagedUser()
{
var localFactory = new ScimApplicationFactory();
localFactory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus)
.Returns(true));

localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
SeedScimConnection(localFactory, inviteUsersAfterProvisioning: false);

var email = "user5@example.com";
var externalId = "UE";
var inputModel = new ScimUserRequestModel
{
DisplayName = "Test User 5",
Emails = new List<BaseScimUserModel.EmailModel> { new BaseScimUserModel.EmailModel(email) },
ExternalId = externalId,
Active = true,
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};

var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);

Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
Assert.Contains(context.Response.Headers, h => h.Key == "Location");

var databaseContext = localFactory.GetDatabaseContext();
Assert.Equal(_initialUserCount + 1, databaseContext.OrganizationUsers.Count());

var stagedUser = databaseContext.OrganizationUsers.Single(ou => ou.Email == email);
Assert.Equal(OrganizationUserStatusType.Staged, stagedUser.Status);
Assert.Equal(externalId, stagedUser.ExternalId);
Assert.Null(stagedUser.UserId);
}

[Fact]
public async Task Post_InviteUsersAfterProvisioningDisabled_WithoutFeatureFlag_InvitesUser()
{
var localFactory = new ScimApplicationFactory();
localFactory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus)
.Returns(false));

localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
SeedScimConnection(localFactory, inviteUsersAfterProvisioning: false);

var email = "user5@example.com";
var inputModel = new ScimUserRequestModel
{
DisplayName = "Test User 5",
Emails = new List<BaseScimUserModel.EmailModel> { new BaseScimUserModel.EmailModel(email) },
ExternalId = "UE",
Active = true,
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};

var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);

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

var databaseContext = localFactory.GetDatabaseContext();
var newUser = databaseContext.OrganizationUsers.Single(ou => ou.Email == email);
Assert.Equal(OrganizationUserStatusType.Invited, newUser.Status);
}

[Fact]
public async Task Post_InviteUsersAfterProvisioningEnabled_InvitesUser()
{
var localFactory = new ScimApplicationFactory();
localFactory.SubstituteService((IFeatureService featureService)
=> featureService.IsEnabled(FeatureFlagKeys.PM34423StagedStatus)
.Returns(true));

localFactory.ReinitializeDbForTests(localFactory.GetDatabaseContext());
SeedScimConnection(localFactory, inviteUsersAfterProvisioning: true);

var email = "user5@example.com";
var inputModel = new ScimUserRequestModel
{
DisplayName = "Test User 5",
Emails = new List<BaseScimUserModel.EmailModel> { new BaseScimUserModel.EmailModel(email) },
ExternalId = "UE",
Active = true,
Schemas = new List<string> { ScimConstants.Scim2SchemaUser }
};

var context = await localFactory.UsersPostAsync(ScimApplicationFactory.TestOrganizationId1, inputModel);

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

var databaseContext = localFactory.GetDatabaseContext();
var newUser = databaseContext.OrganizationUsers.Single(ou => ou.Email == email);
Assert.Equal(OrganizationUserStatusType.Invited, newUser.Status);
}

private static void SeedScimConnection(ScimApplicationFactory factory, bool inviteUsersAfterProvisioning)
{
var connection = new Infrastructure.EntityFramework.Models.OrganizationConnection
{
Id = Guid.NewGuid(),
OrganizationId = ScimApplicationFactory.TestOrganizationId1,
Type = OrganizationConnectionType.Scim,
Enabled = true
};
connection.SetConfig(new ScimConfig
{
Enabled = true,
InviteUsersAfterProvisioning = inviteUsersAfterProvisioning
});

var databaseContext = factory.GetDatabaseContext();
databaseContext.OrganizationConnections.Add(connection);
databaseContext.SaveChanges();
}

[Fact]
public async Task Put_RevokeUser_Success()
{
Expand Down
Loading
Loading