[PM-24011] Create new policy sync push notification (#6594)

* create new policy sync push notification

* CR feedback

* add tests, fix typo
This commit is contained in:
Brandon Treston 2025-12-01 10:21:44 -05:00 committed by GitHub
parent 62cbe36ce1
commit a5ea603817
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 210 additions and 6 deletions

View File

@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
using Bit.Core.Services; using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand
private readonly IReadOnlyDictionary<PolicyType, IPolicyValidator> _policyValidators; private readonly IReadOnlyDictionary<PolicyType, IPolicyValidator> _policyValidators;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
private readonly IPostSavePolicySideEffect _postSavePolicySideEffect; private readonly IPostSavePolicySideEffect _postSavePolicySideEffect;
private readonly IPushNotificationService _pushNotificationService;
public SavePolicyCommand(IApplicationCacheService applicationCacheService, public SavePolicyCommand(IApplicationCacheService applicationCacheService,
IEventService eventService, IEventService eventService,
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IEnumerable<IPolicyValidator> policyValidators, IEnumerable<IPolicyValidator> policyValidators,
TimeProvider timeProvider, TimeProvider timeProvider,
IPostSavePolicySideEffect postSavePolicySideEffect) IPostSavePolicySideEffect postSavePolicySideEffect,
IPushNotificationService pushNotificationService)
{ {
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_eventService = eventService; _eventService = eventService;
_policyRepository = policyRepository; _policyRepository = policyRepository;
_timeProvider = timeProvider; _timeProvider = timeProvider;
_postSavePolicySideEffect = postSavePolicySideEffect; _postSavePolicySideEffect = postSavePolicySideEffect;
_pushNotificationService = pushNotificationService;
var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>(); var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>();
foreach (var policyValidator in policyValidators) foreach (var policyValidator in policyValidators)
@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand
await _policyRepository.UpsertAsync(policy); await _policyRepository.UpsertAsync(policy);
await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated);
await PushPolicyUpdateToClients(policy.OrganizationId, policy);
return policy; return policy;
} }
@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type);
return (savedPoliciesDict, currentPolicy); return (savedPoliciesDict, currentPolicy);
} }
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
{
Type = PushType.PolicyChanged,
Target = NotificationTarget.Organization,
TargetId = organizationId,
ExcludeCurrentContext = false,
Payload = new SyncPolicyPushNotification
{
Policy = policy,
OrganizationId = organizationId
}
});
} }

View File

@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
using Bit.Core.Services; using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
@ -15,7 +17,8 @@ public class VNextSavePolicyCommand(
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers, IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers,
TimeProvider timeProvider, TimeProvider timeProvider,
IPolicyEventHandlerFactory policyEventHandlerFactory) IPolicyEventHandlerFactory policyEventHandlerFactory,
IPushNotificationService pushNotificationService)
: IVNextSavePolicyCommand : IVNextSavePolicyCommand
{ {
@ -74,7 +77,7 @@ public class VNextSavePolicyCommand(
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await policyRepository.UpsertAsync(policy); await policyRepository.UpsertAsync(policy);
await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy);
return policy; return policy;
} }
@ -192,4 +195,17 @@ public class VNextSavePolicyCommand(
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
return savedPoliciesDict; return savedPoliciesDict;
} }
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
{
Type = PushType.PolicyChanged,
Target = NotificationTarget.Organization,
TargetId = organizationId,
ExcludeCurrentContext = false,
Payload = new SyncPolicyPushNotification
{
Policy = policy,
OrganizationId = organizationId
}
});
} }

View File

@ -1,4 +1,5 @@
using Bit.Core.Enums; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Enums; using Bit.Core.NotificationCenter.Enums;
namespace Bit.Core.Models; namespace Bit.Core.Models;
@ -103,3 +104,9 @@ public class LogOutPushNotification
public Guid UserId { get; set; } public Guid UserId { get; set; }
public PushNotificationLogOutReason? Reason { get; set; } public PushNotificationLogOutReason? Reason { get; set; }
} }
public class SyncPolicyPushNotification
{
public Guid OrganizationId { get; set; }
public required Policy Policy { get; set; }
}

View File

@ -95,5 +95,8 @@ public enum PushType : byte
OrganizationBankAccountVerified = 23, OrganizationBankAccountVerified = 23,
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))]
ProviderBankAccountVerified = 24 ProviderBankAccountVerified = 24,
[NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.SyncPolicyPushNotification))]
PolicyChanged = 25,
} }

View File

@ -231,9 +231,26 @@ public class HubHelpers
await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString())
.SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken);
break; break;
case PushType.PolicyChanged:
await policyChangedNotificationHandler(notificationJson, cancellationToken);
break;
default: default:
_logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); _logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type);
break; break;
} }
} }
private async Task policyChangedNotificationHandler(string notificationJson, CancellationToken cancellationToken)
{
var policyData = JsonSerializer.Deserialize<PushNotificationData<SyncPolicyPushNotification>>(notificationJson, _deserializerOptions);
if (policyData is null)
{
return;
}
await _hubContext.Clients
.Group(NotificationsHub.GetOrganizationGroup(policyData.Payload.OrganizationId))
.SendAsync(_receiveMessageMethod, policyData, cancellationToken);
}
} }

View File

@ -6,8 +6,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models;
using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Data.Organizations;
using Bit.Core.Platform.Push;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
@ -95,7 +98,8 @@ public class SavePolicyCommandTests
Substitute.For<IPolicyRepository>(), Substitute.For<IPolicyRepository>(),
[new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()], [new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()],
Substitute.For<TimeProvider>(), Substitute.For<TimeProvider>(),
Substitute.For<IPostSavePolicySideEffect>())); Substitute.For<IPostSavePolicySideEffect>(),
Substitute.For<IPushNotificationService>()));
Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message); Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message);
} }
@ -360,6 +364,103 @@ public class SavePolicyCommandTests
.ExecuteSideEffectsAsync(default!, default!, default!); .ExecuteSideEffectsAsync(default!, default!, default!);
} }
[Theory, BitAutoData]
public async Task VNextSaveAsync_SendsPushNotification(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy currentPolicy)
{
// Arrange
var fakePolicyValidator = new FakeSingleOrgPolicyValidator();
fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("");
var sutProvider = SutProviderFactory([fakePolicyValidator]);
var savePolicyModel = new SavePolicyModel(policyUpdate);
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
.Returns(currentPolicy);
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy]);
// Act
var result = await sutProvider.Sut.VNextSaveAsync(savePolicyModel);
// Assert
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushAsync(Arg.Is<PushNotification<SyncPolicyPushNotification>>(p =>
p.Type == PushType.PolicyChanged &&
p.Target == NotificationTarget.Organization &&
p.TargetId == policyUpdate.OrganizationId &&
p.ExcludeCurrentContext == false &&
p.Payload.OrganizationId == policyUpdate.OrganizationId &&
p.Payload.Policy.Id == result.Id &&
p.Payload.Policy.Type == policyUpdate.Type &&
p.Payload.Policy.Enabled == policyUpdate.Enabled &&
p.Payload.Policy.Data == policyUpdate.Data));
}
[Theory, BitAutoData]
public async Task SaveAsync_SendsPushNotification([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
{
var fakePolicyValidator = new FakeSingleOrgPolicyValidator();
fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("");
var sutProvider = SutProviderFactory([fakePolicyValidator]);
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]);
var result = await sutProvider.Sut.SaveAsync(policyUpdate);
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushAsync(Arg.Is<PushNotification<SyncPolicyPushNotification>>(p =>
p.Type == PushType.PolicyChanged &&
p.Target == NotificationTarget.Organization &&
p.TargetId == policyUpdate.OrganizationId &&
p.ExcludeCurrentContext == false &&
p.Payload.OrganizationId == policyUpdate.OrganizationId &&
p.Payload.Policy.Id == result.Id &&
p.Payload.Policy.Type == policyUpdate.Type &&
p.Payload.Policy.Enabled == policyUpdate.Enabled &&
p.Payload.Policy.Data == policyUpdate.Data));
}
[Theory, BitAutoData]
public async Task SaveAsync_ExistingPolicy_SendsPushNotificationWithUpdatedPolicy(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy currentPolicy)
{
var fakePolicyValidator = new FakeSingleOrgPolicyValidator();
fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns("");
var sutProvider = SutProviderFactory([fakePolicyValidator]);
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
.Returns(currentPolicy);
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy]);
var result = await sutProvider.Sut.SaveAsync(policyUpdate);
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushAsync(Arg.Is<PushNotification<SyncPolicyPushNotification>>(p =>
p.Type == PushType.PolicyChanged &&
p.Target == NotificationTarget.Organization &&
p.TargetId == policyUpdate.OrganizationId &&
p.ExcludeCurrentContext == false &&
p.Payload.OrganizationId == policyUpdate.OrganizationId &&
p.Payload.Policy.Id == result.Id &&
p.Payload.Policy.Type == policyUpdate.Type &&
p.Payload.Policy.Enabled == policyUpdate.Enabled &&
p.Payload.Policy.Data == policyUpdate.Data));
}
/// <summary> /// <summary>
/// Returns a new SutProvider with the PolicyValidators registered in the Sut. /// Returns a new SutProvider with the PolicyValidators registered in the Sut.
/// </summary> /// </summary>

View File

@ -225,6 +225,30 @@ public class HubHelpersTest
.Group(Arg.Any<string>()); .Group(Arg.Any<string>());
} }
[Theory]
[BitAutoData]
public async Task SendNotificationToHubAsync_PolicyChanged_SentToOrganizationGroup(
SutProvider<HubHelpers> sutProvider,
SyncPolicyPushNotification notification,
string contextId,
CancellationToken cancellationToken)
{
var json = ToNotificationJson(notification, PushType.PolicyChanged, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
.Group($"Organization_{notification.OrganizationId}")
.Received(1)
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
objects.Length == 1 && AssertSyncPolicyPushNotification(notification, objects[0],
PushType.PolicyChanged, contextId)),
cancellationToken);
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
private static string ToNotificationJson(object payload, PushType type, string contextId) private static string ToNotificationJson(object payload, PushType type, string contextId)
{ {
var notification = new PushNotificationData<object>(type, payload, contextId); var notification = new PushNotificationData<object>(type, payload, contextId);
@ -247,4 +271,20 @@ public class HubHelpersTest
expected.ClientType == pushNotificationData.Payload.ClientType && expected.ClientType == pushNotificationData.Payload.ClientType &&
expected.RevisionDate == pushNotificationData.Payload.RevisionDate; expected.RevisionDate == pushNotificationData.Payload.RevisionDate;
} }
private static bool AssertSyncPolicyPushNotification(SyncPolicyPushNotification expected, object? actual,
PushType type, string contextId)
{
if (actual is not PushNotificationData<SyncPolicyPushNotification> pushNotificationData)
{
return false;
}
return pushNotificationData.Type == type &&
pushNotificationData.ContextId == contextId &&
expected.OrganizationId == pushNotificationData.Payload.OrganizationId &&
expected.Policy.Id == pushNotificationData.Payload.Policy.Id &&
expected.Policy.Type == pushNotificationData.Payload.Policy.Type &&
expected.Policy.Enabled == pushNotificationData.Payload.Policy.Enabled;
}
} }