Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers;

public class CreateStagedOrganizationUsersCommand(
IOrganizationUserRepository organizationUserRepository,
IEventService eventService)
: ICreateStagedOrganizationUsersCommand
{
public async Task<CommandResult<ICollection<OrganizationUser>>> RunAsync(
CreateStagedOrganizationUsersRequest request)
{
var requestedUsers = request.Users.ToList();

var existingEmails = new HashSet<string>(
await organizationUserRepository.SelectKnownEmailsAsync(
request.Organization.Id,
requestedUsers.Select(u => u.Email), false),
StringComparer.InvariantCultureIgnoreCase);

var creationDate = DateTime.UtcNow;

var organizationUsersToCreate = requestedUsers
.Where(w => existingEmails.Add(w.Email))
.Select(s => new OrganizationUser
{
OrganizationId = request.Organization.Id,
UserId = null,
Email = s.Email.ToLowerInvariant(),
Key = null,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Staged,
// StatusNew is intentionally left null - it is only populated by the revoke flow
ExternalId = s.ExternalId,
CreationDate = creationDate,
RevisionDate = creationDate,
}).ToList();

if (organizationUsersToCreate.Count == 0)
{
return organizationUsersToCreate;
}

await organizationUserRepository.CreateManyAsync(organizationUsersToCreate);

await eventService.LogOrganizationUserEventsAsync(
organizationUsersToCreate.Select(organizationUser =>
(organizationUser, EventType.OrganizationUser_Staged, request.EventSystemUser, (DateTime?)creationDate)));

return organizationUsersToCreate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers;

/// <summary>
/// A single user to provision in <see cref="OrganizationUserStatusType.Staged"/> status.
/// </summary>
public record StagedOrganizationUserRequest
{
public required string Email { get; init; }
public required string ExternalId { get; init; }
}

/// <summary>
/// Request to create staged organization users.
/// </summary>
public record CreateStagedOrganizationUsersRequest
{
/// <summary>The organization to provision the staged users into.</summary>
public required Organization Organization { get; init; }

/// <summary>
/// The users to stage. Emails already present in the organization, and duplicate emails within
/// the batch, are skipped.
/// </summary>
public required IEnumerable<StagedOrganizationUserRequest> Users { get; init; }

/// <summary>The automated system performing the provisioning, used for event attribution.</summary>
public required EventSystemUser EventSystemUser { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Entities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers;

/// <summary>
/// Provisions organization users in <see cref="Bit.Core.Enums.OrganizationUserStatusType.Staged"/> status.
/// </summary>
public interface ICreateStagedOrganizationUsersCommand
{
/// <summary>
/// Creates a Staged <see cref="OrganizationUser"/> for each user in the request whose email does not
/// already belong to the organization. Unlike the invite flow, this performs no seat-count validation,
/// no seat autoscale, and sends no invitation email.
/// </summary>
/// <param name="request">The organization, the users to stage, and the system performing the provisioning.</param>
/// <returns>
/// A <see cref="CommandResult{T}"/> wrapping the created Staged organization users. The collection is
/// empty if every email was already present.
/// </returns>
Task<CommandResult<ICollection<OrganizationUser>>> RunAsync(CreateStagedOrganizationUsersRequest request);
}
1 change: 1 addition & 0 deletions src/Core/Dirt/Enums/EventType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public enum EventType : int
OrganizationUser_Revoked_TwoFactorNonCompliance = 1520,
OrganizationUser_Revoked_SingleOrganizationNonCompliance = 1521,
OrganizationUser_NotificationBannerActionClicked = 1522,
OrganizationUser_Staged = 1523, // Member provisioned without an invitation (e.g. via SCIM / Directory Connector)

Organization_Updated = 1600,
Organization_PurgedVault = 1601,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUserResetPasswordEnrollment;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
Expand Down Expand Up @@ -171,6 +172,7 @@ private static void AddOrganizationUserCommands(this IServiceCollection services
services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserValidator, V2_RevokeUsersCommand.RevokeOrganizationUsersValidator>();

services.AddScoped<ISelfRevokeOrganizationUserCommand, SelfRevokeOrganizationUserCommand>();
services.AddScoped<ICreateStagedOrganizationUsersCommand, CreateStagedOrganizationUsersCommand>();
}

private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
ο»Ώusing Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;

namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.StagedUsers;

[SutProviderCustomize]
public class CreateStagedOrganizationUsersCommandTests
{
private static CreateStagedOrganizationUsersRequest BuildRequest(
Organization organization, params (string Email, string ExternalId)[] users) =>
new()
{
Organization = organization,
EventSystemUser = EventSystemUser.SCIM,
Users = users.Select(u => new StagedOrganizationUserRequest { Email = u.Email, ExternalId = u.ExternalId }),
};

[Theory, BitAutoData]
public async Task RunAsync_CreatesStagedRows_WithExpectedFields(
Organization organization,
SutProvider<CreateStagedOrganizationUsersCommand> sutProvider)
{
var request = BuildRequest(organization, ("user1@example.com", "ext-1"), ("USER2@example.com", "ext-2"));

sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns(new List<string>());

var result = await sutProvider.Sut.RunAsync(request);

await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(created =>
created.Count() == 2 &&
created.All(ou =>
ou.OrganizationId == organization.Id &&
ou.Status == OrganizationUserStatusType.Staged &&
ou.Type == OrganizationUserType.User &&
ou.UserId == null &&
ou.Key == null &&
ou.StatusNew == null &&
!string.IsNullOrEmpty(ou.Email) &&
!string.IsNullOrEmpty(ou.ExternalId)) &&
created.Any(ou => ou.Email == "user1@example.com" && ou.ExternalId == "ext-1") &&
// Email is normalized to lower-case.
created.Any(ou => ou.Email == "user2@example.com" && ou.ExternalId == "ext-2")));

Assert.True(result.IsSuccess);
Assert.Equal(2, result.AsSuccess.Count);
}

[Theory, BitAutoData]
public async Task RunAsync_EmitsStagedEvent_NotInvitedEvent(
Organization organization,
SutProvider<CreateStagedOrganizationUsersCommand> sutProvider)
{
var request = BuildRequest(organization, ("user@example.com", "ext-1"));

sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns(new List<string>());

await sutProvider.Sut.RunAsync(request);

await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventsAsync(
Arg.Is<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>(events =>
events.Count() == 1 &&
events.All(e => e.Item2 == EventType.OrganizationUser_Staged &&
e.Item3 == EventSystemUser.SCIM)));

await sutProvider.GetDependency<IEventService>()
.DidNotReceive()
.LogOrganizationUserEventsAsync(
Arg.Is<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>(events =>
events.Any(e => e.Item2 == EventType.OrganizationUser_Invited)));
}

[Theory, BitAutoData]
public async Task RunAsync_DoesNotPerformSeatCheck(
Organization organization,
SutProvider<CreateStagedOrganizationUsersCommand> sutProvider)
{
var request = BuildRequest(organization, ("user@example.com", "ext-1"));

sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns(new List<string>());

await sutProvider.Sut.RunAsync(request);

// Staged users do not consume a seat, so no occupied-seat lookup should occur. The command also
// does not depend on IMailService or any autoscale command, so the absence of invite emails and
// seat autoscale is guaranteed by construction.
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceive()
.GetOccupiedSmSeatCountByOrganizationIdAsync(Arg.Any<Guid>());
}

[Theory, BitAutoData]
public async Task RunAsync_SkipsEmailsAlreadyInOrganization(
Organization organization,
SutProvider<CreateStagedOrganizationUsersCommand> sutProvider)
{
var request = BuildRequest(organization,
("existing@example.com", "ext-existing"),
("new@example.com", "ext-new"));

sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns(new List<string> { "existing@example.com" });

var result = await sutProvider.Sut.RunAsync(request);

await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(created =>
created.Count() == 1 && created.Single().Email == "new@example.com"));

Assert.Single(result.AsSuccess);
}

[Theory, BitAutoData]
public async Task RunAsync_DeduplicatesEmailsWithinBatch(
Organization organization,
SutProvider<CreateStagedOrganizationUsersCommand> sutProvider)
{
var request = BuildRequest(organization, ("dup@example.com", "ext-1"), ("DUP@example.com", "ext-2"));

sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns(new List<string>());

var result = await sutProvider.Sut.RunAsync(request);

await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.CreateManyAsync(Arg.Is<IEnumerable<OrganizationUser>>(created => created.Count() == 1));

Assert.Single(result.AsSuccess);
}

[Theory, BitAutoData]
public async Task RunAsync_WhenAllEmailsExist_CreatesNothingAndLogsNoEvents(
Organization organization,
SutProvider<CreateStagedOrganizationUsersCommand> sutProvider)
{
var request = BuildRequest(organization, ("existing@example.com", "ext-1"));

sutProvider.GetDependency<IOrganizationUserRepository>()
.SelectKnownEmailsAsync(organization.Id, Arg.Any<IEnumerable<string>>(), false)
.Returns(new List<string> { "existing@example.com" });

var result = await sutProvider.Sut.RunAsync(request);

Assert.Empty(result.AsSuccess);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceive()
.CreateManyAsync(Arg.Any<IEnumerable<OrganizationUser>>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceive()
.LogOrganizationUserEventsAsync(
Arg.Any<IEnumerable<(OrganizationUser, EventType, EventSystemUser, DateTime?)>>());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ await policyRepository.CreateAsync(new Policy
});

// Staged members are not subject to organization policies
await organizationUserRepository.CreateAsync(GetStagedOrganizationUser(org, user));
await organizationUserRepository.CreateStagedTestOrganizationUserAsync(org, user);

// Act
var results = await policyRepository.GetPolicyDetailsByUserIdAndPolicyTypeAsync(
Expand Down Expand Up @@ -493,12 +493,4 @@ private static async Task<Organization> CreateEnterpriseOrgAsync(IOrganizationRe
Status = OrganizationUserStatusType.Invited,
Type = OrganizationUserType.User
};

private static OrganizationUser GetStagedOrganizationUser(Organization organization, User user) => new()
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Staged,
Type = OrganizationUserType.User
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ await policyRepository.CreateAsync(new Policy

var confirmedOrgUser = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, confirmedUser));
// Staged members are not subject to organization policies
await organizationUserRepository.CreateAsync(GetStagedOrganizationUser(organization, stagedUser));
await organizationUserRepository.CreateStagedTestOrganizationUserAsync(organization, stagedUser);

// Act
var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType(
Expand Down Expand Up @@ -464,14 +464,6 @@ private static async Task<Organization> CreateEnterpriseOrgAsync(IOrganizationRe
Type = OrganizationUserType.User,
};

private static OrganizationUser GetStagedOrganizationUser(Organization organization, User user) => new()
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Staged,
Type = OrganizationUserType.User
};

private static Policy GetPolicy(PolicyType policyType, Organization organization) => new()
{
OrganizationId = organization.Id,
Expand Down
Loading