Skip to content
Draft
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
Expand Up @@ -451,9 +451,11 @@ public async Task<IResult> Put([BindOrganization] Organization organization, Gui
model.AccessSecretsManager,
collectionsToSave,
groupsToSave,
model.Email,
model.Name,
model.DefaultUserCollectionName,
new StandardUser(userId, await _currentContext.OrganizationOwner(organization.Id)),
savingOrganizationUser,
model.DefaultUserCollectionName);
savingOrganizationUser);

var result = await _updateOrganizationUserCommandVNext.UpdateUserAsync(request);
return Handle(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public class OrganizationDomainAllowEmailChangeQuery(
IOrganizationDomainRepository organizationDomainRepository)
: IOrganizationDomainAllowEmailChangeQuery
{
public const string EmailNotOnVerifiedDomainError =
"Your account is managed by an organization, and this email address isn't on one of the organization's verified domains.";
public const string EmailClaimedByOrganizationError =
"This email address is claimed by an organization using Bitwarden.";

/// <inheritdoc />
public async Task ValidateAllowedAsync(User user, string newEmail)
{
Expand All @@ -33,8 +38,7 @@ public async Task ValidateAllowedAsync(User user, string newEmail)

if (!verifiedDomains.Any(verifiedDomain => verifiedDomain.DomainName == newDomain))
{
throw new BadRequestException(
"Your account is managed by an organization, and this email address isn't on one of the organization's verified domains.");
throw new BadRequestException(EmailNotOnVerifiedDomainError);
}

return;
Expand All @@ -45,8 +49,7 @@ public async Task ValidateAllowedAsync(User user, string newEmail)
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(newDomain);
if (isDomainBlocked)
{
throw new BadRequestException(
"This email address is claimed by an organization using Bitwarden.");
throw new BadRequestException(EmailClaimedByOrganizationError);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;

Expand All @@ -18,3 +19,21 @@ public record CustomPermissionsNotEnabled() : BadRequestError("To enable custom
public record CannotAssignDefaultCollection() : BadRequestError("Default collections cannot be assigned to a member.");
public record CannotAutoscaleSecretsManagerSeatsOnSelfHost() : BadRequestError("Cannot autoscale on a self-hosted instance.");
public record CouldNotIncreaseSeatsOfSecretManager(string Message) : BadRequestError(Message);

public abstract record EmailValidationError(string Message, string Type) : BadRequestError(Message), IValidationError
{
public string PropertyName => "email";
}

public record MemberHasMasterPasswordError()
: EmailValidationError("Cannot change the email of a member who has a master password.", "member_has_master_password");

@BTreston BTreston Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jrmccannon Are these the strings + Keys (and then mapped to real client owned keys) that need to be added to the client for i18n? Are these final?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of now, yes. It is the "member_has_master_password" type should be mapped over to the i18n key. I don't plan changing any of those, but if that happens i'll be sure to loop you in.

public record MemberNotClaimedError()
: EmailValidationError("Cannot change the email of a member who is not claimed by the organization.", "member_not_claimed");
public record NewEmailDomainNotClaimedError()
: EmailValidationError("The new email address must be on a domain claimed by the organization.", "new_email_domain_not_claimed");
public record EmailAlreadyInUseError()
: EmailValidationError("Email already in use.", "email_already_in_use");
public record EmailClaimedByAnotherOrganizationError()
: EmailValidationError("This email address is claimed by an organization using Bitwarden.", "email_claimed_by_another_organization");
public record EmailChangeFailedError(string Message)
: EmailValidationError(Message, "email_change_failed");
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
ο»Ώusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
ο»Ώusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Auth.UserFeatures.UserEmail;
using Bit.Core.Billing.Pricing;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
Expand All @@ -26,27 +29,24 @@ public class UpdateOrganizationUserCommand(
IGlobalSettings globalSettings,
ICollectionRepository collectionRepository,
IPolicyRequirementQuery policyRequirementQuery,
IUserRepository userRepository,
IChangeEmailCommand changeEmailCommand,
IPushNotificationService pushNotificationService,
TimeProvider timeProvider)
: IUpdateOrganizationUserCommand
{
public async Task<CommandResult> UpdateUserAsync(UpdateOrganizationUserRequest request)
{
request = await LoadUserToUpdateAsync(request);

var validationResult = await validator.ValidateAsync(request);

if (validationResult.IsError)
{
return validationResult.AsError;
}

var wasDemotedFromPrivilegedRole = IsDemotingFromPrivilegedRole(request);
var enablingSecretsManager = IsEnablingSecretsManager(request);

var organizationUser = request.OrganizationUserToUpdate;
organizationUser.Type = request.NewType;
organizationUser.Permissions = CoreHelpers.ClassToJsonData(request.NewPermissions);
organizationUser.AccessSecretsManager = request.NewAccessSecretsManager;

if (enablingSecretsManager)
if (IsEnablingSecretsManager(request))
{
var commandError = await TryEnablingSecretsManagerAsync(request);
if (commandError is not null)
Expand All @@ -55,6 +55,23 @@ public async Task<CommandResult> UpdateUserAsync(UpdateOrganizationUserRequest r
}
}

var isEmailChanging = IsEmailChanging(request);
if (isEmailChanging || IsNameChanging(request))
{
var commandError = await TryApplyAccountChangesAsync(request, isEmailChanging);
if (commandError is not null)
{
return commandError;
}
}

var wasDemotedFromPrivilegedRole = IsDemotingFromPrivilegedRole(request);

var organizationUser = request.OrganizationUserToUpdate;
organizationUser.Type = request.NewType;
organizationUser.Permissions = CoreHelpers.ClassToJsonData(request.NewPermissions);
organizationUser.AccessSecretsManager = request.NewAccessSecretsManager;

await organizationUserRepository.ReplaceAsync(organizationUser, request.NewCollections?.ToList() ?? []);

if (request.NewGroups != null)
Expand All @@ -76,6 +93,64 @@ await collectionRepository.CreateDefaultCollectionsAsync(
return new None();
}

private async Task<CommandError?> TryApplyAccountChangesAsync(UpdateOrganizationUserRequest request,
bool isEmailChanging)
{
if (request.UserToUpdate is null)
{
return null;
}

var userToUpdate = request.UserToUpdate;
userToUpdate.Name = string.IsNullOrWhiteSpace(request.NewName) ? null : request.NewName;

try
{
if (isEmailChanging)
{
await changeEmailCommand.ChangeEmailAsync(request.UserToUpdate, request.NewEmail!);
}
else
{
userToUpdate.RevisionDate = userToUpdate.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await userRepository.ReplaceAsync(userToUpdate);
}

// Notify the member's devices that their account state changed so clients re-sync.
await pushNotificationService.PushSyncSettingsAsync(userToUpdate.Id);
return null;
}
catch (BadRequestException ex)
{
return MapEmailChangeError(ex);
}
}

// Map known errors and passthrough unknown errors.
private static CommandError MapEmailChangeError(BadRequestException ex) => ex.Message switch
{
ChangeEmailCommand.EmailAlreadyInUseError => new EmailAlreadyInUseError(),
OrganizationDomainAllowEmailChangeQuery.EmailClaimedByOrganizationError => new EmailClaimedByAnotherOrganizationError(),
OrganizationDomainAllowEmailChangeQuery.EmailNotOnVerifiedDomainError => new NewEmailDomainNotClaimedError(),
_ => new EmailChangeFailedError(ex.Message)
};

private static bool IsEmailChanging(UpdateOrganizationUserRequest request) =>
!string.IsNullOrWhiteSpace(request.NewEmail)
&& request.UserToUpdate is not null
&& !string.Equals(request.UserToUpdate.Email, request.NewEmail, StringComparison.InvariantCultureIgnoreCase);

private static bool IsNameChanging(UpdateOrganizationUserRequest request)
{
if (request.NewName is null || request.UserToUpdate is null)
{
return false;
}

var normalizedName = string.IsNullOrWhiteSpace(request.NewName) ? null : request.NewName;
return !string.Equals(request.UserToUpdate.Name, normalizedName, StringComparison.Ordinal);
}

private static bool IsEnablingSecretsManager(UpdateOrganizationUserRequest request) =>
!request.OrganizationUserToUpdate.AccessSecretsManager && request.NewAccessSecretsManager;

Expand All @@ -86,8 +161,7 @@ request.OrganizationUserToUpdate.Type is OrganizationUserType.Admin or Organizat
private async Task<CommandError?> TryEnablingSecretsManagerAsync(UpdateOrganizationUserRequest request)
{
var organization = request.Organization;
var additionalSmSeatsRequired =
await countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, 1);
var additionalSmSeatsRequired = await countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, 1);
if (additionalSmSeatsRequired > 0)
{
// Self-hosted instances can't autoscale their Stripe subscription.
Expand All @@ -112,6 +186,18 @@ request.OrganizationUserToUpdate.Type is OrganizationUserType.Admin or Organizat
return null;
}

private async Task<UpdateOrganizationUserRequest> LoadUserToUpdateAsync(UpdateOrganizationUserRequest request)
{
var wantsAccountChange = !string.IsNullOrWhiteSpace(request.NewEmail) || request.NewName is not null;
if (!wantsAccountChange || !request.OrganizationUserToUpdate.UserId.HasValue)
{
return request;
}

var userToUpdate = await userRepository.GetByIdAsync(request.OrganizationUserToUpdate.UserId.Value);
return request with { UserToUpdate = userToUpdate };
}

private async Task<bool> ShouldCreateDefaultCollectionAsync(UpdateOrganizationUserRequest request,
bool wasDemotedFromPrivilegedRole) =>
wasDemotedFromPrivilegedRole
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUse
/// <param name="NewPermissions">The requested custom permissions (used when <paramref name="NewType"/> is Custom).</param>
/// <param name="NewCollections">The updated collection access; null removes all collection access.</param>
/// <param name="NewGroups">The updated group access; null leaves groups unchanged.</param>
/// <param name="PerformedByOrganizationUser">The actor's own membership; null when the actor is not an organization member (e.g. a provider).</param>
/// <param name="NewEmail">The requested new email address; null or blank leaves the member's email unchanged.</param>
/// <param name="NewName">The requested new account name; null leaves the member's name unchanged, blank clears it.</param>
/// <param name="DefaultUserCollectionName">Default collection name used when applicable</param>
/// <param name="PerformedByOrganizationUser">The actor's own membership; null when the actor is not an organization member (e.g. a provider).</param>
/// <param name="UserToUpdate">The member's loaded account; populated by the command before validation, null when no email change is requested or the member has no account.</param>
public record UpdateOrganizationUserRequest(
OrganizationUser OrganizationUserToUpdate,
Organization Organization,
Expand All @@ -29,6 +32,9 @@ public record UpdateOrganizationUserRequest(
bool NewAccessSecretsManager,
List<CollectionAccessSelection>? NewCollections,
IEnumerable<Guid>? NewGroups,
string? NewEmail,
string? NewName,
string? DefaultUserCollectionName,
IActingUser PerformedBy,
OrganizationUser? PerformedByOrganizationUser,
string? DefaultUserCollectionName);
User? UserToUpdate = null);
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;
Expand All @@ -18,7 +19,9 @@ public class UpdateOrganizationUserValidator(
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IOrganizationUserValidationService organizationUserValidationService)
IOrganizationUserValidationService organizationUserValidationService,
IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery,
IOrganizationDomainRepository organizationDomainRepository)
: IUpdateOrganizationUserValidator
{
public async Task<ValidationResult<UpdateOrganizationUserRequest>> ValidateAsync(
Expand Down Expand Up @@ -98,9 +101,62 @@ public async Task<ValidationResult<UpdateOrganizationUserRequest>> ValidateAsync
return Invalid(request, new ManageMutuallyExclusive());
}

var emailChangeError = await ValidateEmailChangeAsync(request);
if (emailChangeError is not null)
{
return Invalid(request, emailChangeError);
}

return Valid(request);
}

/// <summary>
/// A member's email may only be changed when they are claimed by the organization, have no master
/// password, and the new email is on a domain the organization has verified. Returns null when no
/// email change is requested or the email is unchanged.
/// </summary>
private async Task<Error?> ValidateEmailChangeAsync(UpdateOrganizationUserRequest request)
{
if (string.IsNullOrWhiteSpace(request.NewEmail))
{
return null;
}

var organizationUser = request.OrganizationUserToUpdate;

if (request.UserToUpdate is null || !organizationUser.UserId.HasValue)
{
return new MemberNotClaimedError();
}

if (string.Equals(request.UserToUpdate.Email, request.NewEmail, StringComparison.InvariantCultureIgnoreCase))
{
return null;
}

if (request.UserToUpdate.HasMasterPassword())
{
return new MemberHasMasterPasswordError();
}

var claimedStatus = await getOrganizationUsersClaimedStatusQuery
.GetUsersOrganizationClaimedStatusAsync(request.Organization.Id, [organizationUser.Id]);
if (!claimedStatus.TryGetValue(organizationUser.Id, out var isClaimed) || !isClaimed)
{
return new MemberNotClaimedError();
}

var newDomain = EmailValidation.GetDomain(request.NewEmail);
var verifiedDomains = await organizationDomainRepository
.GetVerifiedDomainsByOrganizationIdsAsync([request.Organization.Id]);
if (!verifiedDomains.Any(d => string.Equals(d.DomainName, newDomain, StringComparison.InvariantCultureIgnoreCase)))
{
return new NewEmailDomainNotClaimedError();
}

return null;
}

private async Task<bool> IsValidFreeOrganizationAdminAsync(OrganizationUser organizationUser, OrganizationUserType newType, Organization organization)
{
if (organization.PlanType != PlanType.Free)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class ChangeEmailCommand(
private readonly TimeProvider _timeProvider = timeProvider;
private readonly ILogger<ChangeEmailCommand> _logger = logger;

public const string EmailAlreadyInUseError = "Email already in use.";

/// <inheritdoc />
public async Task ChangeEmailAsync(User user, string newEmail)
{
Expand All @@ -29,7 +31,7 @@ public async Task ChangeEmailAsync(User user, string newEmail)
var existingUser = await _userRepository.GetByEmailAsync(newEmail);
if (existingUser != null && existingUser.Id != user.Id)
{
throw new BadRequestException("Email already in use.");
throw new BadRequestException(EmailAlreadyInUseError);
}

var previousEmail = user.Email;
Expand Down
Loading
Loading