1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 05:00:19 -05:00

[PM-10319] - Revoke Non Complaint Users for 2FA and Single Org Policy Enablement (#5037)

- Revoking users when enabling single org and 2fa policies.
- Updated emails sent when users are revoked via 2FA or Single Organization policy enablement

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com>
This commit is contained in:
Jared McCannon 2024-11-26 16:37:12 -06:00 committed by GitHub
parent 8f703a29ac
commit 1b75e35c31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1074 additions and 73 deletions

View File

@ -2,6 +2,7 @@
public enum EventSystemUser : byte public enum EventSystemUser : byte
{ {
Unknown = 0,
SCIM = 1, SCIM = 1,
DomainVerification = 2, DomainVerification = 2,
PublicApi = 3, PublicApi = 3,

View File

@ -0,0 +1,10 @@
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data;
public interface IActingUser
{
Guid? UserId { get; }
bool IsOrganizationOwnerOrProvider { get; }
EventSystemUser? SystemUserType { get; }
}

View File

@ -0,0 +1,16 @@
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data;
public class StandardUser : IActingUser
{
public StandardUser(Guid userId, bool isOrganizationOwner)
{
UserId = userId;
IsOrganizationOwnerOrProvider = isOrganizationOwner;
}
public Guid? UserId { get; }
public bool IsOrganizationOwnerOrProvider { get; }
public EventSystemUser? SystemUserType => throw new Exception($"{nameof(StandardUser)} does not have a {nameof(SystemUserType)}");
}

View File

@ -0,0 +1,16 @@
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data;
public class SystemUser : IActingUser
{
public SystemUser(EventSystemUser systemUser)
{
SystemUserType = systemUser;
}
public Guid? UserId => throw new Exception($"{nameof(SystemUserType)} does not have a {nameof(UserId)}.");
public bool IsOrganizationOwnerOrProvider => false;
public EventSystemUser? SystemUserType { get; }
}

View File

@ -1,7 +1,9 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -12,124 +14,121 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand public class VerifyOrganizationDomainCommand(
IOrganizationDomainRepository organizationDomainRepository,
IDnsResolverService dnsResolverService,
IEventService eventService,
IGlobalSettings globalSettings,
IPolicyService policyService,
IFeatureService featureService,
ICurrentContext currentContext,
ILogger<VerifyOrganizationDomainCommand> logger)
: IVerifyOrganizationDomainCommand
{ {
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IDnsResolverService _dnsResolverService;
private readonly IEventService _eventService;
private readonly IGlobalSettings _globalSettings;
private readonly IPolicyService _policyService;
private readonly IFeatureService _featureService;
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
public VerifyOrganizationDomainCommand(
IOrganizationDomainRepository organizationDomainRepository,
IDnsResolverService dnsResolverService,
IEventService eventService,
IGlobalSettings globalSettings,
IPolicyService policyService,
IFeatureService featureService,
ILogger<VerifyOrganizationDomainCommand> logger)
{
_organizationDomainRepository = organizationDomainRepository;
_dnsResolverService = dnsResolverService;
_eventService = eventService;
_globalSettings = globalSettings;
_policyService = policyService;
_featureService = featureService;
_logger = logger;
}
public async Task<OrganizationDomain> UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain) public async Task<OrganizationDomain> UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain)
{ {
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain); if (currentContext.UserId is null)
{
throw new InvalidOperationException(
$"{nameof(UserVerifyOrganizationDomainAsync)} can only be called by a user. " +
$"Please call {nameof(SystemVerifyOrganizationDomainAsync)} for system users.");
}
await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, var actingUser = new StandardUser(currentContext.UserId.Value, await currentContext.OrganizationOwner(organizationDomain.OrganizationId));
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain, actingUser);
await eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
domainVerificationResult.VerifiedDate != null domainVerificationResult.VerifiedDate != null
? EventType.OrganizationDomain_Verified ? EventType.OrganizationDomain_Verified
: EventType.OrganizationDomain_NotVerified); : EventType.OrganizationDomain_NotVerified);
await _organizationDomainRepository.ReplaceAsync(domainVerificationResult); await organizationDomainRepository.ReplaceAsync(domainVerificationResult);
return domainVerificationResult; return domainVerificationResult;
} }
public async Task<OrganizationDomain> SystemVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain) public async Task<OrganizationDomain> SystemVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain)
{ {
var actingUser = new SystemUser(EventSystemUser.DomainVerification);
organizationDomain.SetJobRunCount(); organizationDomain.SetJobRunCount();
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain); var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain, actingUser);
if (domainVerificationResult.VerifiedDate is not null) if (domainVerificationResult.VerifiedDate is not null)
{ {
_logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain"); logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain");
await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, await eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
EventType.OrganizationDomain_Verified, EventType.OrganizationDomain_Verified,
EventSystemUser.DomainVerification); EventSystemUser.DomainVerification);
} }
else else
{ {
domainVerificationResult.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); domainVerificationResult.SetNextRunDate(globalSettings.DomainVerification.VerificationInterval);
await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, await eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
EventType.OrganizationDomain_NotVerified, EventType.OrganizationDomain_NotVerified,
EventSystemUser.DomainVerification); EventSystemUser.DomainVerification);
_logger.LogInformation(Constants.BypassFiltersEventId, logger.LogInformation(Constants.BypassFiltersEventId,
"Verification for organization {OrgId} with domain {Domain} failed", "Verification for organization {OrgId} with domain {Domain} failed",
domainVerificationResult.OrganizationId, domainVerificationResult.DomainName); domainVerificationResult.OrganizationId, domainVerificationResult.DomainName);
} }
await _organizationDomainRepository.ReplaceAsync(domainVerificationResult); await organizationDomainRepository.ReplaceAsync(domainVerificationResult);
return domainVerificationResult; return domainVerificationResult;
} }
private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(OrganizationDomain domain) private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(OrganizationDomain domain, IActingUser actingUser)
{ {
domain.SetLastCheckedDate(); domain.SetLastCheckedDate();
if (domain.VerifiedDate is not null) if (domain.VerifiedDate is not null)
{ {
await _organizationDomainRepository.ReplaceAsync(domain); await organizationDomainRepository.ReplaceAsync(domain);
throw new ConflictException("Domain has already been verified."); throw new ConflictException("Domain has already been verified.");
} }
var claimedDomain = var claimedDomain =
await _organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName); await organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName);
if (claimedDomain.Count > 0) if (claimedDomain.Count > 0)
{ {
await _organizationDomainRepository.ReplaceAsync(domain); await organizationDomainRepository.ReplaceAsync(domain);
throw new ConflictException("The domain is not available to be claimed."); throw new ConflictException("The domain is not available to be claimed.");
} }
try try
{ {
if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt)) if (await dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
{ {
domain.SetVerifiedDate(); domain.SetVerifiedDate();
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId); await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser);
} }
} }
catch (Exception e) catch (Exception e)
{ {
_logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}", logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}",
domain.DomainName, e.Message); domain.DomainName, e.Message);
} }
return domain; return domain;
} }
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId) private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{ {
await _policyService.SaveAsync( await policyService.SaveAsync(
new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, null); new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true },
savingUserId: actingUser is StandardUser standardUser ? standardUser.UserId : null,
eventSystemUser: actingUser is SystemUser systemUser ? systemUser.SystemUserType : null);
} }
} }
} }

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.Models.Commands;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IRevokeNonCompliantOrganizationUserCommand
{
Task<CommandResult> RevokeNonCompliantOrganizationUsersAsync(RevokeOrganizationUsersRequest request);
}

View File

@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
public record RevokeOrganizationUsersRequest(
Guid OrganizationId,
IEnumerable<OrganizationUserUserDetails> OrganizationUsers,
IActingUser ActionPerformedBy)
{
public RevokeOrganizationUsersRequest(Guid organizationId, OrganizationUserUserDetails organizationUser, IActingUser actionPerformedBy)
: this(organizationId, [organizationUser], actionPerformedBy) { }
}

View File

@ -0,0 +1,112 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.Enums;
using Bit.Core.Models.Commands;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class RevokeNonCompliantOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
IHasConfirmedOwnersExceptQuery confirmedOwnersExceptQuery,
TimeProvider timeProvider) : IRevokeNonCompliantOrganizationUserCommand
{
public const string ErrorCannotRevokeSelf = "You cannot revoke yourself.";
public const string ErrorOnlyOwnersCanRevokeOtherOwners = "Only owners can revoke other owners.";
public const string ErrorUserAlreadyRevoked = "User is already revoked.";
public const string ErrorOrgMustHaveAtLeastOneOwner = "Organization must have at least one confirmed owner.";
public const string ErrorInvalidUsers = "Invalid users.";
public const string ErrorRequestedByWasNotValid = "Action was performed by an unexpected type.";
public async Task<CommandResult> RevokeNonCompliantOrganizationUsersAsync(RevokeOrganizationUsersRequest request)
{
var validationResult = await ValidateAsync(request);
if (validationResult.HasErrors)
{
return validationResult;
}
await organizationUserRepository.RevokeManyByIdAsync(request.OrganizationUsers.Select(x => x.Id));
var now = timeProvider.GetUtcNow();
switch (request.ActionPerformedBy)
{
case StandardUser:
await eventService.LogOrganizationUserEventsAsync(
request.OrganizationUsers.Select(x => GetRevokedUserEventTuple(x, now)));
break;
case SystemUser { SystemUserType: not null } loggableSystem:
await eventService.LogOrganizationUserEventsAsync(
request.OrganizationUsers.Select(x =>
GetRevokedUserEventBySystemUserTuple(x, loggableSystem.SystemUserType.Value, now)));
break;
}
return validationResult;
}
private static (OrganizationUserUserDetails organizationUser, EventType eventType, DateTime? time) GetRevokedUserEventTuple(
OrganizationUserUserDetails organizationUser, DateTimeOffset dateTimeOffset) =>
new(organizationUser, EventType.OrganizationUser_Revoked, dateTimeOffset.UtcDateTime);
private static (OrganizationUserUserDetails organizationUser, EventType eventType, EventSystemUser eventSystemUser, DateTime? time) GetRevokedUserEventBySystemUserTuple(
OrganizationUserUserDetails organizationUser, EventSystemUser systemUser, DateTimeOffset dateTimeOffset) => new(organizationUser,
EventType.OrganizationUser_Revoked, systemUser, dateTimeOffset.UtcDateTime);
private async Task<CommandResult> ValidateAsync(RevokeOrganizationUsersRequest request)
{
if (!PerformedByIsAnExpectedType(request.ActionPerformedBy))
{
return new CommandResult(ErrorRequestedByWasNotValid);
}
if (request.ActionPerformedBy is StandardUser user
&& request.OrganizationUsers.Any(x => x.UserId == user.UserId))
{
return new CommandResult(ErrorCannotRevokeSelf);
}
if (request.OrganizationUsers.Any(x => x.OrganizationId != request.OrganizationId))
{
return new CommandResult(ErrorInvalidUsers);
}
if (!await confirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
request.OrganizationId,
request.OrganizationUsers.Select(x => x.Id)))
{
return new CommandResult(ErrorOrgMustHaveAtLeastOneOwner);
}
return request.OrganizationUsers.Aggregate(new CommandResult(), (result, userToRevoke) =>
{
if (IsAlreadyRevoked(userToRevoke))
{
result.ErrorMessages.Add($"{ErrorUserAlreadyRevoked} Id: {userToRevoke.Id}");
return result;
}
if (NonOwnersCannotRevokeOwners(userToRevoke, request.ActionPerformedBy))
{
result.ErrorMessages.Add($"{ErrorOnlyOwnersCanRevokeOtherOwners}");
return result;
}
return result;
});
}
private static bool PerformedByIsAnExpectedType(IActingUser entity) => entity is SystemUser or StandardUser;
private static bool IsAlreadyRevoked(OrganizationUserUserDetails organizationUser) =>
organizationUser is { Status: OrganizationUserStatusType.Revoked };
private static bool NonOwnersCannotRevokeOwners(OrganizationUserUserDetails organizationUser,
IActingUser actingUser) =>
actingUser is StandardUser { IsOrganizationOwnerOrProvider: false } && organizationUser.Type == OrganizationUserType.Owner;
}

View File

@ -1,6 +1,7 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@ -15,6 +16,7 @@ public record PolicyUpdate
public PolicyType Type { get; set; } public PolicyType Type { get; set; }
public string? Data { get; set; } public string? Data { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
public IActingUser? PerformedBy { get; set; }
public T GetDataModel<T>() where T : IPolicyDataModel, new() public T GetDataModel<T>() where T : IPolicyDataModel, new()
{ {

View File

@ -2,8 +2,10 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
@ -18,6 +20,8 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class SingleOrgPolicyValidator : IPolicyValidator public class SingleOrgPolicyValidator : IPolicyValidator
{ {
public PolicyType Type => PolicyType.SingleOrg; public PolicyType Type => PolicyType.SingleOrg;
private const string OrganizationNotFoundErrorMessage = "Organization not found.";
private const string ClaimedDomainSingleOrganizationRequiredErrorMessage = "The Single organization policy is required for organizations that have enabled domain verification.";
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IMailService _mailService; private readonly IMailService _mailService;
@ -27,6 +31,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
public SingleOrgPolicyValidator( public SingleOrgPolicyValidator(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@ -36,7 +41,8 @@ public class SingleOrgPolicyValidator : IPolicyValidator
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService, IFeatureService featureService,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_mailService = mailService; _mailService = mailService;
@ -46,6 +52,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
_featureService = featureService; _featureService = featureService;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
} }
public IEnumerable<PolicyType> RequiredPolicies => []; public IEnumerable<PolicyType> RequiredPolicies => [];
@ -54,10 +61,54 @@ public class SingleOrgPolicyValidator : IPolicyValidator
{ {
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })
{ {
await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
var currentUser = _currentContext.UserId ?? Guid.Empty;
var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId);
await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider));
}
else
{
await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId);
}
} }
} }
private async Task RevokeNonCompliantUsersAsync(Guid organizationId, IActingUser performedBy)
{
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (organization is null)
{
throw new NotFoundException(OrganizationNotFoundErrorMessage);
}
var currentActiveRevocableOrganizationUsers =
(await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
ou.Status != OrganizationUserStatusType.Revoked &&
ou.Type != OrganizationUserType.Owner &&
ou.Type != OrganizationUserType.Admin &&
!(performedBy is StandardUser stdUser && stdUser.UserId == ou.UserId))
.ToList();
if (currentActiveRevocableOrganizationUsers.Count == 0)
{
return;
}
var commandResult = await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync(
new RevokeOrganizationUsersRequest(organizationId, currentActiveRevocableOrganizationUsers, performedBy));
if (commandResult.HasErrors)
{
throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages));
}
await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x =>
_mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email)));
}
private async Task RemoveNonCompliantUsersAsync(Guid organizationId) private async Task RemoveNonCompliantUsersAsync(Guid organizationId)
{ {
// Remove non-compliant users // Remove non-compliant users
@ -67,7 +118,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
var org = await _organizationRepository.GetByIdAsync(organizationId); var org = await _organizationRepository.GetByIdAsync(organizationId);
if (org == null) if (org == null)
{ {
throw new NotFoundException("Organization not found."); throw new NotFoundException(OrganizationNotFoundErrorMessage);
} }
var removableOrgUsers = orgUsers.Where(ou => var removableOrgUsers = orgUsers.Where(ou =>
@ -76,18 +127,17 @@ public class SingleOrgPolicyValidator : IPolicyValidator
ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Owner &&
ou.Type != OrganizationUserType.Admin && ou.Type != OrganizationUserType.Admin &&
ou.UserId != savingUserId ou.UserId != savingUserId
).ToList(); ).ToList();
var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(
removableOrgUsers.Select(ou => ou.UserId!.Value)); removableOrgUsers.Select(ou => ou.UserId!.Value));
foreach (var orgUser in removableOrgUsers) foreach (var orgUser in removableOrgUsers)
{ {
if (userOrgs.Any(ou => ou.UserId == orgUser.UserId if (userOrgs.Any(ou => ou.UserId == orgUser.UserId
&& ou.OrganizationId != org.Id && ou.OrganizationId != org.Id
&& ou.Status != OrganizationUserStatusType.Invited)) && ou.Status != OrganizationUserStatusType.Invited))
{ {
await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, savingUserId);
savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync( await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(
org.DisplayName(), orgUser.Email); org.DisplayName(), orgUser.Email);
@ -111,7 +161,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) && await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
{ {
return "The Single organization policy is required for organizations that have enabled domain verification."; return ClaimedDomainSingleOrganizationRequiredErrorMessage;
} }
} }

View File

@ -2,12 +2,15 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -21,6 +24,10 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IFeatureService _featureService;
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
public const string NonCompliantMembersWillLoseAccessMessage = "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.";
public PolicyType Type => PolicyType.TwoFactorAuthentication; public PolicyType Type => PolicyType.TwoFactorAuthentication;
public IEnumerable<PolicyType> RequiredPolicies => []; public IEnumerable<PolicyType> RequiredPolicies => [];
@ -31,7 +38,9 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ICurrentContext currentContext, ICurrentContext currentContext,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand) IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IFeatureService featureService,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_mailService = mailService; _mailService = mailService;
@ -39,16 +48,65 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
_currentContext = currentContext; _currentContext = currentContext;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_featureService = featureService;
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
} }
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{ {
if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true })
{ {
await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
var currentUser = _currentContext.UserId ?? Guid.Empty;
var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId);
await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider));
}
else
{
await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId);
}
} }
} }
private async Task RevokeNonCompliantUsersAsync(Guid organizationId, IActingUser performedBy)
{
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var currentActiveRevocableOrganizationUsers =
(await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
ou.Status != OrganizationUserStatusType.Revoked &&
ou.Type != OrganizationUserType.Owner &&
ou.Type != OrganizationUserType.Admin &&
!(performedBy is StandardUser stdUser && stdUser.UserId == ou.UserId))
.ToList();
if (currentActiveRevocableOrganizationUsers.Count == 0)
{
return;
}
var organizationUsersTwoFactorEnabled =
await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(currentActiveRevocableOrganizationUsers);
if (NonCompliantMembersWillLoseAccess(currentActiveRevocableOrganizationUsers, organizationUsersTwoFactorEnabled))
{
throw new BadRequestException(NonCompliantMembersWillLoseAccessMessage);
}
var commandResult = await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync(
new RevokeOrganizationUsersRequest(organizationId, currentActiveRevocableOrganizationUsers, performedBy));
if (commandResult.HasErrors)
{
throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages));
}
await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x =>
_mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email)));
}
private async Task RemoveNonCompliantUsersAsync(Guid organizationId) private async Task RemoveNonCompliantUsersAsync(Guid organizationId)
{ {
var org = await _organizationRepository.GetByIdAsync(organizationId); var org = await _organizationRepository.GetByIdAsync(organizationId);
@ -83,5 +141,12 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
} }
} }
private static bool NonCompliantMembersWillLoseAccess(
IEnumerable<OrganizationUserUserDetails> orgUserDetails,
IEnumerable<(OrganizationUserUserDetails user, bool isTwoFactorEnabled)> organizationUsersTwoFactorEnabled) =>
orgUserDetails.Any(x =>
!x.HasMasterPassword && !organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == x.Id)
.isTwoFactorEnabled);
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
} }

View File

@ -58,4 +58,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
/// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains. /// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains.
/// </summary> /// </summary>
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId);
Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds);
} }

View File

@ -9,7 +9,7 @@ namespace Bit.Core.AdminConsole.Services;
public interface IPolicyService public interface IPolicyService
{ {
Task SaveAsync(Policy policy, Guid? savingUserId); Task SaveAsync(Policy policy, Guid? savingUserId, EventSystemUser? eventSystemUser = null);
/// <summary> /// <summary>
/// Get the combined master password policy options for the specified user. /// Get the combined master password policy options for the specified user.

View File

@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
@ -9,6 +10,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -34,6 +36,7 @@ public class PolicyService : IPolicyService
private readonly ISavePolicyCommand _savePolicyCommand; private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly ICurrentContext _currentContext;
public PolicyService( public PolicyService(
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
@ -48,7 +51,8 @@ public class PolicyService : IPolicyService
IFeatureService featureService, IFeatureService featureService,
ISavePolicyCommand savePolicyCommand, ISavePolicyCommand savePolicyCommand,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
ICurrentContext currentContext)
{ {
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_eventService = eventService; _eventService = eventService;
@ -63,19 +67,24 @@ public class PolicyService : IPolicyService
_savePolicyCommand = savePolicyCommand; _savePolicyCommand = savePolicyCommand;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_currentContext = currentContext;
} }
public async Task SaveAsync(Policy policy, Guid? savingUserId) public async Task SaveAsync(Policy policy, Guid? savingUserId, EventSystemUser? eventSystemUser = null)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.Pm13322AddPolicyDefinitions)) if (_featureService.IsEnabled(FeatureFlagKeys.Pm13322AddPolicyDefinitions))
{ {
// Transitional mapping - this will be moved to callers once the feature flag is removed // Transitional mapping - this will be moved to callers once the feature flag is removed
// TODO make sure to populate with SystemUser if not an actual user
var policyUpdate = new PolicyUpdate var policyUpdate = new PolicyUpdate
{ {
OrganizationId = policy.OrganizationId, OrganizationId = policy.OrganizationId,
Type = policy.Type, Type = policy.Type,
Enabled = policy.Enabled, Enabled = policy.Enabled,
Data = policy.Data Data = policy.Data,
PerformedBy = savingUserId.HasValue
? new StandardUser(savingUserId.Value, await _currentContext.OrganizationOwner(policy.OrganizationId))
: new SystemUser(eventSystemUser ?? EventSystemUser.Unknown)
}; };
await _savePolicyCommand.SaveAsync(policyUpdate); await _savePolicyCommand.SaveAsync(policyUpdate);

View File

@ -0,0 +1,14 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top" align="left">
Your user account has been revoked from the <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{OrganizationName}}</b> organization because your account is part of multiple organizations. Before you can re-join {{OrganizationName}}, you must first leave all other organizations.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top" align="left">
To leave an organization, first log into the <a href="https://vault.bitwarden.com/#/login">web app</a>, select the three dot menu next to the organization name, and select Leave.
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
Your user account has been revoked from the {{OrganizationName}} organization because your account is part of multiple organizations. Before you can rejoin {{OrganizationName}}, you must first leave all other organizations.
To leave an organization, first log in the web app (https://vault.bitwarden.com/#/login), select the three dot menu next to the organization name, and select Leave.
{{/BasicTextLayout}}

View File

@ -0,0 +1,15 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top" align="left">
Your user account has been revoked from the <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{OrganizationName}}</b> organization because you do not have two-step login configured. Before you can re-join {{OrganizationName}}, you need to set up two-step login on your user account.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top" align="left">
Learn how to enable two-step login on your user account at
<a target="_blank" href="https://help.bitwarden.com/article/setup-two-step-login/" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #175DDC; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; text-decoration: underline;">https://help.bitwarden.com/article/setup-two-step-login/</a>
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,7 @@
{{#>BasicTextLayout}}
Your user account has been removed from the {{OrganizationName}} organization because you do not have two-step login
configured. Before you can re-join this organization you need to set up two-step login on your user account.
Learn how to enable two-step login on your user account at
https://help.bitwarden.com/article/setup-two-step-login/
{{/BasicTextLayout}}

View File

@ -0,0 +1,12 @@
namespace Bit.Core.Models.Commands;
public class CommandResult(IEnumerable<string> errors)
{
public CommandResult(string error) : this([error]) { }
public bool Success => ErrorMessages.Count == 0;
public bool HasErrors => ErrorMessages.Count > 0;
public List<string> ErrorMessages { get; } = errors.ToList();
public CommandResult() : this([]) { }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail;
public class OrganizationUserRevokedForPolicySingleOrgViewModel : BaseMailModel
{
public string OrganizationName { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Models.Mail;
public class OrganizationUserRevokedForPolicyTwoFactorViewModel : BaseMailModel
{
public string OrganizationName { get; set; }
}

View File

@ -91,6 +91,7 @@ public static class OrganizationServiceCollectionExtensions
private static void AddOrganizationUserCommands(this IServiceCollection services) private static void AddOrganizationUserCommands(this IServiceCollection services)
{ {
services.AddScoped<IRemoveOrganizationUserCommand, RemoveOrganizationUserCommand>(); services.AddScoped<IRemoveOrganizationUserCommand, RemoveOrganizationUserCommand>();
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>(); services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>(); services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IDeleteManagedOrganizationUserAccountCommand, DeleteManagedOrganizationUserAccountCommand>(); services.AddScoped<IDeleteManagedOrganizationUserAccountCommand, DeleteManagedOrganizationUserAccountCommand>();

View File

@ -35,6 +35,8 @@ public interface IMailService
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails, bool hasAccessSecretsManager = false); Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails, bool hasAccessSecretsManager = false);
Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false); Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false);
Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email); Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email);
Task SendOrganizationUserRevokedForTwoFactoryPolicyEmailAsync(string organizationName, string email);
Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email);
Task SendPasswordlessSignInAsync(string returnUrl, string token, string email); Task SendPasswordlessSignInAsync(string returnUrl, string token, string email);
Task SendInvoiceUpcoming( Task SendInvoiceUpcoming(
string email, string email,

View File

@ -25,8 +25,7 @@ public class HandlebarsMailService : IMailService
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IMailDeliveryService _mailDeliveryService; private readonly IMailDeliveryService _mailDeliveryService;
private readonly IMailEnqueuingService _mailEnqueuingService; private readonly IMailEnqueuingService _mailEnqueuingService;
private readonly Dictionary<string, HandlebarsTemplate<object, object>> _templateCache = private readonly Dictionary<string, HandlebarsTemplate<object, object>> _templateCache = new();
new Dictionary<string, HandlebarsTemplate<object, object>>();
private bool _registeredHelpersAndPartials = false; private bool _registeredHelpersAndPartials = false;
@ -295,6 +294,20 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendOrganizationUserRevokedForTwoFactoryPolicyEmailAsync(string organizationName, string email)
{
var message = CreateDefaultMessage($"You have been revoked from {organizationName}", email);
var model = new OrganizationUserRevokedForPolicyTwoFactorViewModel
{
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "AdminConsole.OrganizationUserRevokedForTwoFactorPolicy", model);
message.Category = "OrganizationUserRevokedForTwoFactorPolicy";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendWelcomeEmailAsync(User user) public async Task SendWelcomeEmailAsync(User user)
{ {
var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email); var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email);
@ -496,6 +509,20 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email)
{
var message = CreateDefaultMessage($"You have been revoked from {organizationName}", email);
var model = new OrganizationUserRevokedForPolicySingleOrgViewModel
{
OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false),
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName
};
await AddMessageContentAsync(message, "AdminConsole.OrganizationUserRevokedForSingleOrgPolicy", model);
message.Category = "OrganizationUserRevokedForSingleOrgPolicy";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage) public async Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage)
{ {
var message = CreateDefaultMessage(queueMessage.Subject, queueMessage.ToEmails); var message = CreateDefaultMessage(queueMessage.Subject, queueMessage.ToEmails);

View File

@ -79,6 +79,12 @@ public class NoopMailService : IMailService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendOrganizationUserRevokedForTwoFactoryPolicyEmailAsync(string organizationName, string email) =>
Task.CompletedTask;
public Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email) =>
Task.CompletedTask;
public Task SendTwoFactorEmailAsync(string email, string token) public Task SendTwoFactorEmailAsync(string email, string token)
{ {
return Task.FromResult(0); return Task.FromResult(0);

View File

@ -557,4 +557,14 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
return results.ToList(); return results.ToList();
} }
} }
public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
{
await using var connection = new SqlConnection(ConnectionString);
await connection.ExecuteAsync(
"[dbo].[OrganizationUser_SetStatusForUsersById]",
new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked },
commandType: CommandType.StoredProcedure);
}
} }

View File

@ -721,4 +721,16 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
return data; return data;
} }
} }
public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
await dbContext.OrganizationUsers.Where(x => organizationUserIds.Contains(x.Id))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, OrganizationUserStatusType.Revoked));
await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(organizationUserIds);
}
} }

View File

@ -0,0 +1,29 @@
CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersById]
@OrganizationUserIds AS NVARCHAR(MAX),
@Status SMALLINT
AS
BEGIN
SET NOCOUNT ON
-- Declare a table variable to hold the parsed JSON data
DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER);
-- Parse the JSON input into the table variable
INSERT INTO @ParsedIds (Id)
SELECT value
FROM OPENJSON(@OrganizationUserIds);
-- Check if the input table is empty
IF (SELECT COUNT(1) FROM @ParsedIds) < 1
BEGIN
RETURN(-1);
END
UPDATE
[dbo].[OrganizationUser]
SET [Status] = @Status
WHERE [Id] IN (SELECT Id from @ParsedIds)
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds
END

View File

@ -2,6 +2,7 @@
using AutoFixture; using AutoFixture;
using AutoFixture.Xunit2; using AutoFixture.Xunit2;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.Test.AdminConsole.AutoFixture; namespace Bit.Core.Test.AdminConsole.AutoFixture;
@ -12,7 +13,8 @@ internal class PolicyUpdateCustomization(PolicyType type, bool enabled) : ICusto
{ {
fixture.Customize<PolicyUpdate>(composer => composer fixture.Customize<PolicyUpdate>(composer => composer
.With(o => o.Type, type) .With(o => o.Type, type)
.With(o => o.Enabled, enabled)); .With(o => o.Enabled, enabled)
.With(o => o.PerformedBy, new StandardUser(Guid.NewGuid(), false)));
} }
} }

View File

@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -28,7 +29,12 @@ public class VerifyOrganizationDomainCommandTests
DomainName = "Test Domain", DomainName = "Test Domain",
Txt = "btw+test18383838383" Txt = "btw+test18383838383"
}; };
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());
expected.SetVerifiedDate(); expected.SetVerifiedDate();
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetByIdAsync(id) .GetByIdAsync(id)
.Returns(expected); .Returns(expected);
@ -53,6 +59,10 @@ public class VerifyOrganizationDomainCommandTests
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetByIdAsync(id) .GetByIdAsync(id)
.Returns(expected); .Returns(expected);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetClaimedDomainsByDomainNameAsync(expected.DomainName) .GetClaimedDomainsByDomainNameAsync(expected.DomainName)
.Returns(new List<OrganizationDomain> { expected }); .Returns(new List<OrganizationDomain> { expected });
@ -77,9 +87,14 @@ public class VerifyOrganizationDomainCommandTests
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetByIdAsync(id) .GetByIdAsync(id)
.Returns(expected); .Returns(expected);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetClaimedDomainsByDomainNameAsync(expected.DomainName) .GetClaimedDomainsByDomainNameAsync(expected.DomainName)
.Returns(new List<OrganizationDomain>()); .Returns(new List<OrganizationDomain>());
sutProvider.GetDependency<IDnsResolverService>() sutProvider.GetDependency<IDnsResolverService>()
.ResolveAsync(expected.DomainName, Arg.Any<string>()) .ResolveAsync(expected.DomainName, Arg.Any<string>())
.Returns(true); .Returns(true);
@ -107,9 +122,14 @@ public class VerifyOrganizationDomainCommandTests
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetByIdAsync(id) .GetByIdAsync(id)
.Returns(expected); .Returns(expected);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetClaimedDomainsByDomainNameAsync(expected.DomainName) .GetClaimedDomainsByDomainNameAsync(expected.DomainName)
.Returns(new List<OrganizationDomain>()); .Returns(new List<OrganizationDomain>());
sutProvider.GetDependency<IDnsResolverService>() sutProvider.GetDependency<IDnsResolverService>()
.ResolveAsync(expected.DomainName, Arg.Any<string>()) .ResolveAsync(expected.DomainName, Arg.Any<string>())
.Returns(false); .Returns(false);
@ -143,7 +163,7 @@ public class VerifyOrganizationDomainCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsVerified_ThenSingleOrgPolicyShouldBeEnabled( public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsVerified_ThenSingleOrgPolicyShouldBeEnabled(
OrganizationDomain domain, SutProvider<VerifyOrganizationDomainCommand> sutProvider) OrganizationDomain domain, Guid userId, SutProvider<VerifyOrganizationDomainCommand> sutProvider)
{ {
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetClaimedDomainsByDomainNameAsync(domain.DomainName) .GetClaimedDomainsByDomainNameAsync(domain.DomainName)
@ -157,11 +177,14 @@ public class VerifyOrganizationDomainCommandTests
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(true); .Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(userId);
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
await sutProvider.GetDependency<IPolicyService>() await sutProvider.GetDependency<IPolicyService>()
.Received(1) .Received(1)
.SaveAsync(Arg.Is<Policy>(x => x.Type == PolicyType.SingleOrg && x.OrganizationId == domain.OrganizationId && x.Enabled), null); .SaveAsync(Arg.Is<Policy>(x => x.Type == PolicyType.SingleOrg && x.OrganizationId == domain.OrganizationId && x.Enabled), userId);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
@ -176,6 +199,9 @@ public class VerifyOrganizationDomainCommandTests
.ResolveAsync(domain.DomainName, domain.Txt) .ResolveAsync(domain.DomainName, domain.Txt)
.Returns(true); .Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(false); .Returns(false);
@ -189,7 +215,7 @@ public class VerifyOrganizationDomainCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled( public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled(
OrganizationDomain domain, SutProvider<VerifyOrganizationDomainCommand> sutProvider) OrganizationDomain domain, SutProvider<VerifyOrganizationDomainCommand> sutProvider)
{ {
sutProvider.GetDependency<IOrganizationDomainRepository>() sutProvider.GetDependency<IOrganizationDomainRepository>()
.GetClaimedDomainsByDomainNameAsync(domain.DomainName) .GetClaimedDomainsByDomainNameAsync(domain.DomainName)
@ -199,6 +225,9 @@ public class VerifyOrganizationDomainCommandTests
.ResolveAsync(domain.DomainName, domain.Txt) .ResolveAsync(domain.DomainName, domain.Txt)
.Returns(false); .Returns(false);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(true); .Returns(true);
@ -223,6 +252,9 @@ public class VerifyOrganizationDomainCommandTests
.ResolveAsync(domain.DomainName, domain.Txt) .ResolveAsync(domain.DomainName, domain.Txt)
.Returns(false); .Returns(false);
sutProvider.GetDependency<ICurrentContext>()
.UserId.Returns(Guid.NewGuid());
sutProvider.GetDependency<IFeatureService>() sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(true); .Returns(true);

View File

@ -0,0 +1,185 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
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;
[SutProviderCustomize]
public class RevokeNonCompliantOrganizationUserCommandTests
{
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenUnrecognizedUserType_WhenAttemptingToRevoke_ThenErrorShouldBeReturned(
Guid organizationId, SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
var command = new RevokeOrganizationUsersRequest(organizationId, [], new InvalidUser());
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
Assert.True(result.HasErrors);
Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorRequestedByWasNotValid, result.ErrorMessages);
}
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeThemselves_ThenErrorShouldBeReturned(
Guid organizationId, OrganizationUserUserDetails revokingUser,
SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
var command = new RevokeOrganizationUsersRequest(organizationId, revokingUser,
new StandardUser(revokingUser?.UserId ?? Guid.NewGuid(), true));
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
Assert.True(result.HasErrors);
Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorCannotRevokeSelf, result.ErrorMessages);
}
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeOrgUsersFromAnotherOrg_ThenErrorShouldBeReturned(
Guid organizationId, OrganizationUserUserDetails userFromAnotherOrg,
SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
userFromAnotherOrg.OrganizationId = Guid.NewGuid();
var command = new RevokeOrganizationUsersRequest(organizationId, userFromAnotherOrg,
new StandardUser(Guid.NewGuid(), true));
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
Assert.True(result.HasErrors);
Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorInvalidUsers, result.ErrorMessages);
}
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeAllOwnersFromOrg_ThenErrorShouldBeReturned(
Guid organizationId, OrganizationUserUserDetails userToRevoke,
SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
userToRevoke.OrganizationId = organizationId;
var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke,
new StandardUser(Guid.NewGuid(), true));
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(false);
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
Assert.True(result.HasErrors);
Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorOrgMustHaveAtLeastOneOwner, result.ErrorMessages);
}
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeOwnerWhenNotAnOwner_ThenErrorShouldBeReturned(
Guid organizationId, OrganizationUserUserDetails userToRevoke,
SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
userToRevoke.OrganizationId = organizationId;
userToRevoke.Type = OrganizationUserType.Owner;
var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke,
new StandardUser(Guid.NewGuid(), false));
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
Assert.True(result.HasErrors);
Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorOnlyOwnersCanRevokeOtherOwners, result.ErrorMessages);
}
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeUserWhoIsAlreadyRevoked_ThenErrorShouldBeReturned(
Guid organizationId, OrganizationUserUserDetails userToRevoke,
SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
userToRevoke.OrganizationId = organizationId;
userToRevoke.Status = OrganizationUserStatusType.Revoked;
var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke,
new StandardUser(Guid.NewGuid(), true));
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
Assert.True(result.HasErrors);
Assert.Contains($"{RevokeNonCompliantOrganizationUserCommand.ErrorUserAlreadyRevoked} Id: {userToRevoke.Id}", result.ErrorMessages);
}
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserHasMultipleInvalidUsers_ThenErrorShouldBeReturned(
Guid organizationId, IEnumerable<OrganizationUserUserDetails> usersToRevoke,
SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
var revocableUsers = usersToRevoke.ToList();
revocableUsers.ForEach(user => user.OrganizationId = organizationId);
revocableUsers[0].Type = OrganizationUserType.Owner;
revocableUsers[1].Status = OrganizationUserStatusType.Revoked;
var command = new RevokeOrganizationUsersRequest(organizationId, revocableUsers,
new StandardUser(Guid.NewGuid(), false));
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
Assert.True(result.HasErrors);
Assert.True(result.ErrorMessages.Count > 1);
}
[Theory, BitAutoData]
public async Task RevokeNonCompliantOrganizationUsersAsync_GivenValidPopulatedRequest_WhenUserAttemptsToRevokeAUser_ThenUserShouldBeRevoked(
Guid organizationId, OrganizationUserUserDetails userToRevoke,
SutProvider<RevokeNonCompliantOrganizationUserCommand> sutProvider)
{
userToRevoke.OrganizationId = organizationId;
userToRevoke.Type = OrganizationUserType.Admin;
var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke,
new StandardUser(Guid.NewGuid(), false));
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>())
.Returns(true);
var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RevokeManyByIdAsync(Arg.Any<IEnumerable<Guid>>());
Assert.True(result.Success);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventsAsync(
Arg.Is<IEnumerable<(OrganizationUserUserDetails organizationUser, EventType eventType, DateTime? time
)>>(
x => x.Any(y =>
y.organizationUser.Id == userToRevoke.Id && y.eventType == EventType.OrganizationUser_Revoked)
));
}
public class InvalidUser : IActingUser
{
public Guid? UserId => Guid.Empty;
public bool IsOrganizationOwnerOrProvider => false;
public EventSystemUser? SystemUserType => null;
}
}

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
@ -10,6 +11,7 @@ using Bit.Core.Auth.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Commands;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -61,6 +63,79 @@ public class SingleOrgPolicyValidatorTests
Assert.True(string.IsNullOrEmpty(result)); Assert.True(string.IsNullOrEmpty(result));
} }
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy policy,
Guid savingUserId,
Guid nonCompliantUserId,
Organization organization, SutProvider<SingleOrgPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
var compliantUser1 = new OrganizationUserUserDetails
{
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = new Guid(),
Email = "user1@example.com"
};
var compliantUser2 = new OrganizationUserUserDetails
{
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = new Guid(),
Email = "user2@example.com"
};
var nonCompliantUser = new OrganizationUserUserDetails
{
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantUserId,
Email = "user3@example.com"
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([compliantUser1, compliantUser2, nonCompliantUser]);
var otherOrganizationUser = new OrganizationUser
{
OrganizationId = new Guid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(nonCompliantUserId)))
.Returns([otherOrganizationUser]);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(true);
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
.Returns(new CommandResult());
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.Received(1)
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(),
"user3@example.com");
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
@ -116,6 +191,13 @@ public class SingleOrgPolicyValidatorTests
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId); sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(false);
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
.Returns(new CommandResult());
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>() await sutProvider.GetDependency<IRemoveOrganizationUserCommand>()
@ -126,4 +208,73 @@ public class SingleOrgPolicyValidatorTests
.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(),
"user3@example.com"); "user3@example.com");
} }
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_WhenAccountDeprovisioningIsEnabled_ThenUsersAreRevoked(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy policy,
Guid savingUserId,
Guid nonCompliantUserId,
Organization organization, SutProvider<SingleOrgPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
var compliantUser1 = new OrganizationUserUserDetails
{
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = new Guid(),
Email = "user1@example.com"
};
var compliantUser2 = new OrganizationUserUserDetails
{
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = new Guid(),
Email = "user2@example.com"
};
var nonCompliantUser = new OrganizationUserUserDetails
{
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantUserId,
Email = "user3@example.com"
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([compliantUser1, compliantUser2, nonCompliantUser]);
var otherOrganizationUser = new OrganizationUser
{
OrganizationId = new Guid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(nonCompliantUserId)))
.Returns([otherOrganizationUser]);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
.Returns(new CommandResult());
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.Received()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
}
} }

View File

@ -1,12 +1,14 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Commands;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -176,6 +178,10 @@ public class TwoFactorAuthenticationPolicyValidatorTests
HasMasterPassword = false HasMasterPassword = false
}; };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(false);
sutProvider.GetDependency<IOrganizationUserRepository>() sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policy.OrganizationId) .GetManyDetailsByOrganizationAsync(policy.OrganizationId)
.Returns(new List<OrganizationUserUserDetails> .Returns(new List<OrganizationUserUserDetails>
@ -201,9 +207,151 @@ public class TwoFactorAuthenticationPolicyValidatorTests
var badRequestException = await Assert.ThrowsAsync<BadRequestException>( var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy)); () => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy));
Assert.Contains("Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(TwoFactorAuthenticationPolicyValidator.NonCompliantMembersWillLoseAccessMessage, badRequestException.Message);
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().DidNotReceiveWithAnyArgs() await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().DidNotReceiveWithAnyArgs()
.RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default); .RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default);
} }
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_GivenUpdateTo2faPolicy_WhenAccountProvisioningIsDisabled_ThenRevokeUserCommandShouldNotBeCalled(
Organization organization,
[PolicyUpdate(PolicyType.TwoFactorAuthentication)]
PolicyUpdate policyUpdate,
[Policy(PolicyType.TwoFactorAuthentication, false)]
Policy policy,
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(false);
var orgUserDetailUserAcceptedWithout2Fa = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
Status = OrganizationUserStatusType.Accepted,
Type = OrganizationUserType.User,
Email = "user3@test.com",
Name = "TEST",
UserId = Guid.NewGuid(),
HasMasterPassword = true
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns(new List<OrganizationUserUserDetails>
{
orgUserDetailUserAcceptedWithout2Fa
});
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
{
(orgUserDetailUserAcceptedWithout2Fa, false),
});
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.DidNotReceive()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_GivenUpdateTo2faPolicy_WhenAccountProvisioningIsEnabledAndUserDoesNotHaveMasterPassword_ThenNonCompliantMembersErrorMessageWillReturn(
Organization organization,
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(true);
var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = "user3@test.com",
Name = "TEST",
UserId = Guid.NewGuid(),
HasMasterPassword = false
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUserDetailUserWithout2Fa]);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
{
(orgUserDetailUserWithout2Fa, false),
});
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy));
Assert.Equal(TwoFactorAuthenticationPolicyValidator.NonCompliantMembersWillLoseAccessMessage, exception.Message);
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_WhenAccountProvisioningIsEnabledAndUserHasMasterPassword_ThenUserWillBeRevoked(
Organization organization,
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
.Returns(true);
var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = "user3@test.com",
Name = "TEST",
UserId = Guid.NewGuid(),
HasMasterPassword = true
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUserDetailUserWithout2Fa]);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
{
(orgUserDetailUserWithout2Fa, true),
});
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
.Returns(new CommandResult());
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.Received(1)
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(),
"user3@test.com");
}
} }

View File

@ -100,7 +100,7 @@ public class SavePolicyCommandTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest(PolicyUpdate policyUpdate) public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
{ {
var sutProvider = SutProviderFactory(); var sutProvider = SutProviderFactory();
sutProvider.GetDependency<IApplicationCacheService>() sutProvider.GetDependency<IApplicationCacheService>()
@ -115,7 +115,7 @@ public class SavePolicyCommandTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest(PolicyUpdate policyUpdate) public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
{ {
var sutProvider = SutProviderFactory(); var sutProvider = SutProviderFactory();
sutProvider.GetDependency<IApplicationCacheService>() sutProvider.GetDependency<IApplicationCacheService>()

View File

@ -169,7 +169,6 @@ public class EventServiceTests
new EventMessage() new EventMessage()
{ {
IpAddress = ipAddress, IpAddress = ipAddress,
DeviceType = DeviceType.Server,
OrganizationId = orgUser.OrganizationId, OrganizationId = orgUser.OrganizationId,
UserId = orgUser.UserId, UserId = orgUser.UserId,
OrganizationUserId = orgUser.Id, OrganizationUserId = orgUser.Id,

View File

@ -0,0 +1,28 @@
CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersById]
@OrganizationUserIds AS NVARCHAR(MAX),
@Status SMALLINT
AS
BEGIN
SET NOCOUNT ON
-- Declare a table variable to hold the parsed JSON data
DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER);
-- Parse the JSON input into the table variable
INSERT INTO @ParsedIds (Id)
SELECT value
FROM OPENJSON(@OrganizationUserIds);
-- Check if the input table is empty
IF (SELECT COUNT(1) FROM @ParsedIds) < 1
BEGIN
RETURN(-1);
END
UPDATE
[dbo].[OrganizationUser]
SET [Status] = @Status
WHERE [Id] IN (SELECT Id from @ParsedIds)
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds
END