1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -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
36 changed files with 1074 additions and 73 deletions

View File

@ -2,6 +2,7 @@
public enum EventSystemUser : byte
{
Unknown = 0,
SCIM = 1,
DomainVerification = 2,
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.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -12,124 +14,121 @@ using Microsoft.Extensions.Logging;
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)
{
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
? EventType.OrganizationDomain_Verified
: EventType.OrganizationDomain_NotVerified);
await _organizationDomainRepository.ReplaceAsync(domainVerificationResult);
await organizationDomainRepository.ReplaceAsync(domainVerificationResult);
return domainVerificationResult;
}
public async Task<OrganizationDomain> SystemVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain)
{
var actingUser = new SystemUser(EventSystemUser.DomainVerification);
organizationDomain.SetJobRunCount();
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain);
var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain, actingUser);
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,
EventSystemUser.DomainVerification);
}
else
{
domainVerificationResult.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval);
domainVerificationResult.SetNextRunDate(globalSettings.DomainVerification.VerificationInterval);
await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
await eventService.LogOrganizationDomainEventAsync(domainVerificationResult,
EventType.OrganizationDomain_NotVerified,
EventSystemUser.DomainVerification);
_logger.LogInformation(Constants.BypassFiltersEventId,
logger.LogInformation(Constants.BypassFiltersEventId,
"Verification for organization {OrgId} with domain {Domain} failed",
domainVerificationResult.OrganizationId, domainVerificationResult.DomainName);
}
await _organizationDomainRepository.ReplaceAsync(domainVerificationResult);
await organizationDomainRepository.ReplaceAsync(domainVerificationResult);
return domainVerificationResult;
}
private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(OrganizationDomain domain)
private async Task<OrganizationDomain> VerifyOrganizationDomainAsync(OrganizationDomain domain, IActingUser actingUser)
{
domain.SetLastCheckedDate();
if (domain.VerifiedDate is not null)
{
await _organizationDomainRepository.ReplaceAsync(domain);
await organizationDomainRepository.ReplaceAsync(domain);
throw new ConflictException("Domain has already been verified.");
}
var claimedDomain =
await _organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName);
await organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName);
if (claimedDomain.Count > 0)
{
await _organizationDomainRepository.ReplaceAsync(domain);
await organizationDomainRepository.ReplaceAsync(domain);
throw new ConflictException("The domain is not available to be claimed.");
}
try
{
if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
if (await dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
{
domain.SetVerifiedDate();
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId);
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser);
}
}
catch (Exception e)
{
_logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}",
logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}",
domain.DomainName, e.Message);
}
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(
new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, null);
await policyService.SaveAsync(
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
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Utilities;
@ -15,6 +16,7 @@ public record PolicyUpdate
public PolicyType Type { get; set; }
public string? Data { get; set; }
public bool Enabled { get; set; }
public IActingUser? PerformedBy { get; set; }
public T GetDataModel<T>() where T : IPolicyDataModel, new()
{

View File

@ -2,8 +2,10 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.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.Auth.Enums;
using Bit.Core.Auth.Repositories;
@ -18,6 +20,8 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class SingleOrgPolicyValidator : IPolicyValidator
{
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 IMailService _mailService;
@ -27,6 +31,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
private readonly IFeatureService _featureService;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
public SingleOrgPolicyValidator(
IOrganizationUserRepository organizationUserRepository,
@ -36,7 +41,8 @@ public class SingleOrgPolicyValidator : IPolicyValidator
ICurrentContext currentContext,
IFeatureService featureService,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_mailService = mailService;
@ -46,6 +52,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
_featureService = featureService;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
}
public IEnumerable<PolicyType> RequiredPolicies => [];
@ -54,10 +61,54 @@ public class SingleOrgPolicyValidator : IPolicyValidator
{
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)
{
// Remove non-compliant users
@ -67,7 +118,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
var org = await _organizationRepository.GetByIdAsync(organizationId);
if (org == null)
{
throw new NotFoundException("Organization not found.");
throw new NotFoundException(OrganizationNotFoundErrorMessage);
}
var removableOrgUsers = orgUsers.Where(ou =>
@ -76,18 +127,17 @@ public class SingleOrgPolicyValidator : IPolicyValidator
ou.Type != OrganizationUserType.Owner &&
ou.Type != OrganizationUserType.Admin &&
ou.UserId != savingUserId
).ToList();
).ToList();
var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(
removableOrgUsers.Select(ou => ou.UserId!.Value));
removableOrgUsers.Select(ou => ou.UserId!.Value));
foreach (var orgUser in removableOrgUsers)
{
if (userOrgs.Any(ou => ou.UserId == orgUser.UserId
&& ou.OrganizationId != org.Id
&& ou.Status != OrganizationUserStatusType.Invited))
&& ou.OrganizationId != org.Id
&& ou.Status != OrganizationUserStatusType.Invited))
{
await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id,
savingUserId);
await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, savingUserId);
await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(
org.DisplayName(), orgUser.Email);
@ -111,7 +161,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& 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.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
@ -21,6 +24,10 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
private readonly ICurrentContext _currentContext;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
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 IEnumerable<PolicyType> RequiredPolicies => [];
@ -31,7 +38,9 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
IOrganizationRepository organizationRepository,
ICurrentContext currentContext,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IFeatureService featureService,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_mailService = mailService;
@ -39,16 +48,65 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
_currentContext = currentContext;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_featureService = featureService;
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
}
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
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)
{
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("");
}

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.
/// </summary>
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
{
Task SaveAsync(Policy policy, Guid? savingUserId);
Task SaveAsync(Policy policy, Guid? savingUserId, EventSystemUser? eventSystemUser = null);
/// <summary>
/// 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.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.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.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -34,6 +36,7 @@ public class PolicyService : IPolicyService
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly ICurrentContext _currentContext;
public PolicyService(
IApplicationCacheService applicationCacheService,
@ -48,7 +51,8 @@ public class PolicyService : IPolicyService
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
ICurrentContext currentContext)
{
_applicationCacheService = applicationCacheService;
_eventService = eventService;
@ -63,19 +67,24 @@ public class PolicyService : IPolicyService
_savePolicyCommand = savePolicyCommand;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_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))
{
// 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
{
OrganizationId = policy.OrganizationId,
Type = policy.Type,
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);

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)
{
services.AddScoped<IRemoveOrganizationUserCommand, RemoveOrganizationUserCommand>();
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
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 SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false);
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 SendInvoiceUpcoming(
string email,

View File

@ -25,8 +25,7 @@ public class HandlebarsMailService : IMailService
private readonly GlobalSettings _globalSettings;
private readonly IMailDeliveryService _mailDeliveryService;
private readonly IMailEnqueuingService _mailEnqueuingService;
private readonly Dictionary<string, HandlebarsTemplate<object, object>> _templateCache =
new Dictionary<string, HandlebarsTemplate<object, object>>();
private readonly Dictionary<string, HandlebarsTemplate<object, object>> _templateCache = new();
private bool _registeredHelpersAndPartials = false;
@ -295,6 +294,20 @@ public class HandlebarsMailService : IMailService
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)
{
var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email);
@ -496,6 +509,20 @@ public class HandlebarsMailService : IMailService
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)
{
var message = CreateDefaultMessage(queueMessage.Subject, queueMessage.ToEmails);

View File

@ -79,6 +79,12 @@ public class NoopMailService : IMailService
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)
{
return Task.FromResult(0);

View File

@ -557,4 +557,14 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
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;
}
}
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