1
0
mirror of https://github.com/bitwarden/server synced 2026-02-14 07:23:26 +00:00

[PM-26379] Implement auto confirm push notification (#6980)

* implement auto confirm push notification

* fix test

* fix test

* simplify LINQ
This commit is contained in:
Brandon Treston
2026-02-13 11:50:12 -05:00
committed by GitHub
parent 3cf8c98e40
commit bf9cc01459
9 changed files with 446 additions and 1 deletions

View File

@@ -2,6 +2,7 @@
#nullable disable
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
@@ -36,6 +37,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
private readonly IFeatureService _featureService;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator;
private readonly IPushAutoConfirmNotificationCommand _pushAutoConfirmNotificationCommand;
public AcceptOrgUserCommand(
IDataProtectionProvider dataProtectionProvider,
@@ -49,7 +51,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator)
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
IPushAutoConfirmNotificationCommand pushAutoConfirmNotificationCommand)
{
// TODO: remove data protector when old token validation removed
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
@@ -64,6 +67,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
_featureService = featureService;
_policyRequirementQuery = policyRequirementQuery;
_automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator;
_pushAutoConfirmNotificationCommand = pushAutoConfirmNotificationCommand;
}
public async Task<OrganizationUser> AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken,
@@ -233,6 +237,11 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
await _mailService.SendOrganizationAcceptedEmailAsync(organization, user.Email, adminEmails);
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
await _pushAutoConfirmNotificationCommand.PushAsync(user.Id, orgUser.OrganizationId);
}
return orgUser;
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
public interface IPushAutoConfirmNotificationCommand
{
Task PushAsync(Guid userId, Guid organizationId);
}

View File

@@ -0,0 +1,65 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
public class PushAutoConfirmNotificationCommand : IPushAutoConfirmNotificationCommand
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPushNotificationService _pushNotificationService;
public PushAutoConfirmNotificationCommand(
IOrganizationUserRepository organizationUserRepository,
IPushNotificationService pushNotificationService)
{
_organizationUserRepository = organizationUserRepository;
_pushNotificationService = pushNotificationService;
}
public async Task PushAsync(Guid userId, Guid organizationId)
{
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId);
if (organizationUser == null)
{
throw new Exception("Organization user not found");
}
var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(
organizationId,
OrganizationUserType.Admin);
var customUsersWithManagePermission = (await _organizationUserRepository.GetManyDetailsByRoleAsync(
organizationId,
OrganizationUserType.Custom))
.Where(c => c.GetPermissions()?.ManageUsers == true)
.Select(c => c.UserId);
var userIds = admins
.Select(a => a.UserId)
.Concat(customUsersWithManagePermission)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct();
foreach (var adminUserId in userIds)
{
await _pushNotificationService.PushAsync(
new PushNotification<AutoConfirmPushNotification>
{
Target = NotificationTarget.User,
TargetId = adminUserId,
Type = PushType.AutoConfirm,
Payload = new AutoConfirmPushNotification
{
UserId = adminUserId,
OrganizationId = organizationId,
TargetUserId = organizationUser.Id
},
ExcludeCurrentContext = false,
});
}
}
}

View File

@@ -110,3 +110,21 @@ public class SyncPolicyPushNotification
public Guid OrganizationId { get; set; }
public required Policy Policy { get; set; }
}
public class AutoConfirmPushNotification
{
/// <summary>
/// The admin/owner receiving this notification
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The organization the user accepted an invite to
/// </summary>
public Guid OrganizationId { get; set; }
/// <summary>
/// The user who accepted the organization invite (will be auto-confirmed)
/// </summary>
public Guid TargetUserId { get; set; }
}

View File

@@ -197,6 +197,7 @@ public static class OrganizationServiceCollectionExtensions
{
services.AddScoped<ICountNewSmSeatsRequiredQuery, CountNewSmSeatsRequiredQuery>();
services.AddScoped<IAcceptOrgUserCommand, AcceptOrgUserCommand>();
services.AddScoped<IPushAutoConfirmNotificationCommand, PushAutoConfirmNotificationCommand>();
services.AddScoped<IOrganizationUserUserDetailsQuery, OrganizationUserUserDetailsQuery>();
services.AddScoped<IGetOrganizationUsersClaimedStatusQuery, GetOrganizationUsersClaimedStatusQuery>();

View File

@@ -99,4 +99,7 @@ public enum PushType : byte
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.SyncPolicyPushNotification))]
PolicyChanged = 25,
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.AutoConfirmPushNotification))]
AutoConfirm = 26,
}

View File

@@ -234,6 +234,18 @@ public class HubHelpers
case PushType.PolicyChanged:
await policyChangedNotificationHandler(notificationJson, cancellationToken);
break;
case PushType.AutoConfirm:
var autoConfirmNotification =
JsonSerializer.Deserialize<PushNotificationData<AutoConfirmPushNotification>>(
notificationJson, _deserializerOptions);
if (autoConfirmNotification is null)
{
break;
}
await _hubContext.Clients.User(autoConfirmNotification.Payload.UserId.ToString())
.SendAsync(_receiveMessageMethod, autoConfirmNotification, cancellationToken);
break;
default:
_logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type);
break;

View File

@@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
@@ -767,6 +768,50 @@ public class AcceptOrgUserCommandTests
}
[Theory]
[BitAutoData]
public async Task AcceptOrgUser_WithAutoConfirmFeatureFlagEnabled_SendsPushNotification(
SutProvider<AcceptOrgUserCommand> sutProvider,
User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
{
SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
sutProvider.GetDependency<IAutomaticUserConfirmationPolicyEnforcementValidator>()
.IsCompliantAsync(Arg.Any<AutomaticUserConfirmationPolicyEnforcementRequest>())
.Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user)));
await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService);
await sutProvider.GetDependency<IPushAutoConfirmNotificationCommand>()
.Received(1)
.PushAsync(user.Id, orgUser.OrganizationId);
}
[Theory]
[BitAutoData]
public async Task AcceptOrgUser_WithAutoConfirmFeatureFlagDisabled_DoesNotSendPushNotification(
SutProvider<AcceptOrgUserCommand> sutProvider,
User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails)
{
SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(false);
await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService);
await sutProvider.GetDependency<IPushAutoConfirmNotificationCommand>()
.DidNotReceiveWithAnyArgs()
.PushAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
}
private void SetupCommonAcceptOrgUserByTokenMocks(SutProvider<AcceptOrgUserCommand> sutProvider, User user, OrganizationUser orgUser)
{
sutProvider.GetDependency<IGlobalSettings>().OrganizationInviteExpirationHours.Returns(24);

View File

@@ -0,0 +1,286 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
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 PushAutoConfirmNotificationCommandTests
{
[Theory]
[BitAutoData]
public async Task PushAsync_SendsNotificationToAdminsAndOwners(
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
Guid userId,
Guid organizationId,
OrganizationUser orgUser,
List<OrganizationUserUserDetails> admins)
{
foreach (var admin in admins)
{
admin.UserId = Guid.NewGuid();
}
orgUser.Id = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organizationId, userId)
.Returns(orgUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
.Returns(admins);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
.Returns(new List<OrganizationUserUserDetails>());
await sutProvider.Sut.PushAsync(userId, organizationId);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(admins.Count)
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(pn =>
pn.Type == PushType.AutoConfirm &&
pn.Target == NotificationTarget.User &&
pn.Payload.OrganizationId == organizationId &&
pn.Payload.TargetUserId == orgUser.Id &&
pn.ExcludeCurrentContext == false));
}
[Theory]
[BitAutoData]
public async Task PushAsync_SendsNotificationToCustomUsersWithManageUsersPermission(
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
Guid userId,
Guid organizationId,
OrganizationUser orgUser,
List<OrganizationUserUserDetails> customUsers)
{
foreach (var customUser in customUsers)
{
customUser.UserId = Guid.NewGuid();
customUser.Permissions = "{\"manageUsers\":true}";
}
orgUser.Id = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organizationId, userId)
.Returns(orgUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
.Returns(new List<OrganizationUserUserDetails>());
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
.Returns(customUsers);
await sutProvider.Sut.PushAsync(userId, organizationId);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(customUsers.Count)
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(pn =>
pn.Type == PushType.AutoConfirm &&
pn.Target == NotificationTarget.User &&
pn.Payload.OrganizationId == organizationId &&
pn.Payload.TargetUserId == orgUser.Id &&
pn.ExcludeCurrentContext == false));
}
[Theory]
[BitAutoData]
public async Task PushAsync_DoesNotSendToCustomUsersWithoutManageUsersPermission(
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
Guid userId,
Guid organizationId,
OrganizationUser orgUser,
List<OrganizationUserUserDetails> customUsers)
{
foreach (var customUser in customUsers)
{
customUser.UserId = Guid.NewGuid();
customUser.Permissions = "{\"manageUsers\":false}";
}
orgUser.Id = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organizationId, userId)
.Returns(orgUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
.Returns(new List<OrganizationUserUserDetails>());
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
.Returns(customUsers);
await sutProvider.Sut.PushAsync(userId, organizationId);
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushAsync(Arg.Any<PushNotification<AutoConfirmPushNotification>>());
}
[Theory]
[BitAutoData]
public async Task PushAsync_SendsToAdminsAndCustomUsersWithManageUsers(
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
Guid userId,
Guid organizationId,
OrganizationUser orgUser,
List<OrganizationUserUserDetails> admins,
List<OrganizationUserUserDetails> customUsersWithPermission,
List<OrganizationUserUserDetails> customUsersWithoutPermission)
{
foreach (var admin in admins)
{
admin.UserId = Guid.NewGuid();
}
foreach (var customUser in customUsersWithPermission)
{
customUser.UserId = Guid.NewGuid();
customUser.Permissions = "{\"manageUsers\":true}";
}
foreach (var customUser in customUsersWithoutPermission)
{
customUser.UserId = Guid.NewGuid();
customUser.Permissions = "{\"manageUsers\":false}";
}
orgUser.Id = Guid.NewGuid();
var allCustomUsers = customUsersWithPermission.Concat(customUsersWithoutPermission).ToList();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organizationId, userId)
.Returns(orgUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
.Returns(admins);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
.Returns(allCustomUsers);
await sutProvider.Sut.PushAsync(userId, organizationId);
var expectedNotificationCount = admins.Count + customUsersWithPermission.Count;
await sutProvider.GetDependency<IPushNotificationService>()
.Received(expectedNotificationCount)
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(pn =>
pn.Type == PushType.AutoConfirm &&
pn.Target == NotificationTarget.User &&
pn.Payload.OrganizationId == organizationId &&
pn.Payload.TargetUserId == orgUser.Id &&
pn.ExcludeCurrentContext == false));
}
[Theory]
[BitAutoData]
public async Task PushAsync_SkipsUsersWithoutUserId(
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
Guid userId,
Guid organizationId,
OrganizationUser orgUser,
List<OrganizationUserUserDetails> admins)
{
admins[0].UserId = Guid.NewGuid();
admins[1].UserId = null;
admins[2].UserId = Guid.NewGuid();
orgUser.Id = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organizationId, userId)
.Returns(orgUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
.Returns(admins);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
.Returns(new List<OrganizationUserUserDetails>());
await sutProvider.Sut.PushAsync(userId, organizationId);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(2)
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(pn =>
pn.Type == PushType.AutoConfirm));
}
[Theory]
[BitAutoData]
public async Task PushAsync_DeduplicatesUserIds(
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
Guid userId,
Guid organizationId,
OrganizationUser orgUser,
Guid duplicateUserId)
{
var admin1 = new OrganizationUserUserDetails { UserId = duplicateUserId };
var admin2 = new OrganizationUserUserDetails { UserId = duplicateUserId };
var customUser = new OrganizationUserUserDetails
{
UserId = duplicateUserId,
Permissions = "{\"manageUsers\":true}"
};
orgUser.Id = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organizationId, userId)
.Returns(orgUser);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationId, OrganizationUserType.Admin)
.Returns(new List<OrganizationUserUserDetails> { admin1, admin2 });
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationId, OrganizationUserType.Custom)
.Returns(new List<OrganizationUserUserDetails> { customUser });
await sutProvider.Sut.PushAsync(userId, organizationId);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushAsync(Arg.Is<PushNotification<AutoConfirmPushNotification>>(pn =>
pn.TargetId == duplicateUserId));
}
[Theory]
[BitAutoData]
public async Task PushAsync_OrganizationUserNotFound_ThrowsException(
SutProvider<PushAutoConfirmNotificationCommand> sutProvider,
Guid userId,
Guid organizationId)
{
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organizationId, userId)
.Returns((OrganizationUser)null);
var exception = await Assert.ThrowsAsync<Exception>(() =>
sutProvider.Sut.PushAsync(userId, organizationId));
Assert.Equal("Organization user not found", exception.Message);
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushAsync(Arg.Any<PushNotification<AutoConfirmPushNotification>>());
}
}