Skip to content
16 changes: 3 additions & 13 deletions src/Core/Platform/Push/Engines/AzureQueuePushEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,9 @@ public AzureQueuePushEngine(

public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
var message = new SyncCipherPushNotification
{
Id = cipher.Id,
OrganizationId = cipher.OrganizationId,
RevisionDate = cipher.RevisionDate,
CollectionIds = collectionIds,
};

await SendMessageAsync(type, message, true);
}
else if (cipher.UserId.HasValue)
// Org cipher fan-out is handled upstream in MultiServicePushNotificationService,
// which resolves per-user access and calls PushAsync directly.
if (cipher.UserId.HasValue)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If this is true, then PushCipherAsync can be deleted from IPushEngine. It only exists because of the different behavior of the engines but since this PR is making it so there isn't a difference it shouldn't need to exist anymore.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@justindbaur Indeed. Removed.2f0aab4

{
var message = new SyncCipherPushNotification
{
Expand Down
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not the place to add this kind of logic. You should likely create your own service that has methods like PushSyncCipherCreateAsync, PushSyncCipherUpdateAsync, PushSyncCipherDeleteAsync and those do all the logic you have here then it can call IPushNotificationService.PushAsync then PushCipherAsync and associated tests can be deleted.

This new service is also the place where this needs to be feature flagged. This has the possibility of new load to the database when notifications are being sent, new amount of notifications being sent and new load to the server because clients are now reacting to notifications they did not get before by doing API calls.

Copy link
Copy Markdown
Collaborator Author

@jaasen-livefront jaasen-livefront Apr 25, 2026

Choose a reason for hiding this comment

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

Great idea. Added a service as well as feature flag on/off handling.
2f0aab4

Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώusing Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Vault.Entities;
using Microsoft.Extensions.Logging;
Expand All @@ -8,6 +10,7 @@ namespace Bit.Core.Platform.Push.Internal;
public class MultiServicePushNotificationService : IPushNotificationService
{
private readonly IPushEngine[] _services;
private readonly ICollectionCipherRepository _collectionCipherRepository;

public Guid InstallationId { get; }

Expand All @@ -17,12 +20,14 @@ public class MultiServicePushNotificationService : IPushNotificationService

public MultiServicePushNotificationService(
IEnumerable<IPushEngine> services,
ICollectionCipherRepository collectionCipherRepository,
ILogger<MultiServicePushNotificationService> logger,
GlobalSettings globalSettings,
TimeProvider timeProvider)
{
// Filter out any NoopPushEngine's
_services = [.. services.Where(engine => engine is not NoopPushEngine)];
_collectionCipherRepository = collectionCipherRepository;

Logger = logger;
Logger.LogInformation("Hub services: {Services}", _services.Count());
Expand Down Expand Up @@ -66,10 +71,45 @@ private Task PushToServices(Func<IPushEngine, Task> pushFunc)
#endif
}

public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds)
public async Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds)
{
return PushToServices((s) => s.PushCipherAsync(cipher, pushType, collectionIds));
if (cipher.OrganizationId.HasValue)
{
var collectionIdList = collectionIds?.ToList() ?? [];
if (collectionIdList.Count == 0)
{
// Cannot fan out without collection IDs to determine which users have access.
return;
}
Comment thread
jaasen-livefront marked this conversation as resolved.
Outdated

var userIds = await _collectionCipherRepository.GetUserIdsByCollectionIdsAsync(collectionIdList);
foreach (var userId in userIds)
{
var message = new SyncCipherPushNotification
{
Id = cipher.Id,
UserId = userId,
OrganizationId = cipher.OrganizationId,
RevisionDate = cipher.RevisionDate,
CollectionIds = collectionIdList,
};

await PushToServices(s => s.PushAsync(new PushNotification<SyncCipherPushNotification>
{
Type = pushType,
Target = NotificationTarget.User,
TargetId = userId,
Payload = message,
ExcludeCurrentContext = true,
}));
}
Comment thread
jaasen-livefront marked this conversation as resolved.
Outdated

return;
}

await PushToServices(s => s.PushCipherAsync(cipher, pushType, collectionIds));
}

public Task PushAsync<T>(PushNotification<T> pushNotification) where T : class
{
return PushToServices((s) => s.PushAsync(pushNotification));
Expand Down
16 changes: 3 additions & 13 deletions src/Core/Platform/Push/Engines/NotificationsApiPushEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,9 @@ public NotificationsApiPushEngine(

public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
var message = new SyncCipherPushNotification
{
Id = cipher.Id,
OrganizationId = cipher.OrganizationId,
RevisionDate = cipher.RevisionDate,
CollectionIds = collectionIds,
};

await SendMessageAsync(type, message, true);
}
else if (cipher.UserId.HasValue)
// Org cipher fan-out is handled upstream in MultiServicePushNotificationService,
// which resolves per-user access and calls PushAsync directly.
if (cipher.UserId.HasValue)
{
var message = new SyncCipherPushNotification
{
Expand Down
13 changes: 3 additions & 10 deletions src/Core/Platform/Push/Engines/RelayPushEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,9 @@ public RelayPushEngine(

public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
// We cannot send org pushes since access logic is much more complicated than just the fact that they belong
// to the organization. Potentially we could blindly send to just users that have the access all permission
// device registration needs to be more granular to handle that appropriately. A more brute force approach could
// me to send "full sync" push to all org users, but that has the potential to DDOS the API in bursts.

// await SendPayloadToOrganizationAsync(cipher.OrganizationId.Value, type, message, true);
}
else if (cipher.UserId.HasValue)
// Org cipher fan-out is handled upstream in MultiServicePushNotificationService,
// which resolves per-user access and calls PushAsync directly.
if (cipher.UserId.HasValue)
{
var message = new SyncCipherPushNotification
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,9 @@ public NotificationHubPushEngine(

public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
// We cannot send org pushes since access logic is much more complicated than just the fact that they belong
// to the organization. Potentially we could blindly send to just users that have the access all permission
// device registration needs to be more granular to handle that appropriately. A more brute force approach could
// me to send "full sync" push to all org users, but that has the potential to DDOS the API in bursts.

// await SendPayloadToOrganizationAsync(cipher.OrganizationId.Value, type, message, true);
}
else if (cipher.UserId.HasValue)
// Org cipher fan-out is handled upstream in MultiServicePushNotificationService,
// which resolves per-user access and calls PushAsync directly.
if (cipher.UserId.HasValue)
{
var message = new SyncCipherPushNotification
{
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Repositories/ICollectionCipherRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public interface ICollectionCipherRepository
Task UpdateCollectionsForCiphersAsync(IEnumerable<Guid> cipherIds, Guid userId, Guid organizationId,
IEnumerable<Guid> collectionIds);

/// <summary>
/// Returns the distinct user IDs of all confirmed organization members who have access to any of the specified
/// collections, either directly via <c>CollectionUser</c> or via group membership.
/// </summary>
Task<ICollection<Guid>> GetUserIdsByCollectionIdsAsync(IEnumerable<Guid> collectionIds);

/// <summary>
/// Add the specified collections to the specified ciphers. If a cipher already belongs to a requested collection,
/// no action is taken.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ public async Task<ICollection<CollectionCipher>> GetManyByUserIdCipherIdAsync(Gu
}
}

public async Task<ICollection<Guid>> GetUserIdsByCollectionIdsAsync(IEnumerable<Guid> collectionIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<Guid>(
"[dbo].[CollectionCipher_ReadUserIdsByCollectionIds]",
new { CollectionIds = collectionIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);

return results.ToList();
}
}

public async Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable<Guid> collectionIds)
{
using (var connection = new SqlConnection(ConnectionString))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ public async Task<ICollection<CollectionCipher>> GetManyByUserIdCipherIdAsync(Gu
}
}

public async Task<ICollection<Guid>> GetUserIdsByCollectionIdsAsync(IEnumerable<Guid> collectionIds)
{
var collectionIdList = collectionIds.ToList();
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);

var directUserIds = from cu in dbContext.CollectionUsers
where collectionIdList.Contains(cu.CollectionId)
join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id
where ou.Status == Core.Enums.OrganizationUserStatusType.Confirmed && ou.UserId != null
select ou.UserId!.Value;

var groupUserIds = from cg in dbContext.CollectionGroups
where collectionIdList.Contains(cg.CollectionId)
join gu in dbContext.GroupUsers on cg.GroupId equals gu.GroupId
join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id
where ou.Status == Core.Enums.OrganizationUserStatusType.Confirmed && ou.UserId != null
select ou.UserId!.Value;

return await directUserIds.Union(groupUserIds).ToListAsync();
}
}

public async Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable<Guid> collectionIds)
{
using (var scope = ServiceScopeFactory.CreateScope())
Expand Down
8 changes: 2 additions & 6 deletions src/Notifications/HubHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,8 @@ public async Task SendNotificationToHubAsync(string notificationJson, Cancellati
await _hubContext.Clients.User(cipherNotification.Payload.UserId.Value.ToString())
.SendAsync(_receiveMessageMethod, cipherNotification, cancellationToken);
}
else if (cipherNotification.Payload.OrganizationId.HasValue)
{
await _hubContext.Clients
.Group(NotificationsHub.GetOrganizationGroup(cipherNotification.Payload.OrganizationId.Value))
.SendAsync(_receiveMessageMethod, cipherNotification, cancellationToken);
}
// OrganizationId-only cipher notifications are no longer sent via push engines;
// MultiServicePushNotificationService fans out per-user before the message is queued.

break;
case PushType.SyncFolderUpdate:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
CREATE PROCEDURE [dbo].[CollectionCipher_ReadUserIdsByCollectionIds]
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON

-- Users with direct collection access
SELECT DISTINCT OU.[UserId]
FROM [dbo].[CollectionUser] CU
INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId]
WHERE CU.[CollectionId] IN (SELECT [Id] FROM @CollectionIds)
AND OU.[Status] = 2 -- Confirmed

UNION

-- Users with group-based collection access
SELECT DISTINCT OU.[UserId]
FROM [dbo].[CollectionGroup] CG
INNER JOIN [dbo].[GroupUser] GU ON GU.[GroupId] = CG.[GroupId]
INNER JOIN [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId]
WHERE CG.[CollectionId] IN (SELECT [Id] FROM @CollectionIds)
AND OU.[Status] = 2 -- Confirmed
END
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This query covers direct CollectionUser and group-based access, but it misses users who have access to all org ciphers implicitly, (OrganizationUser.Type of Owner or Admin) and users with AccessAll = true

 -- Users with org-level access (admins, owners, AccessAll)
UNION
SELECT DISTINCT OU.[UserId]
FROM [dbo].[OrganizationUser] OU
INNER JOIN [dbo].[CollectionCipher] CC ON CC.[CollectionId] IN (SELECT [Id] FROM @CollectionIds)
INNER JOIN [dbo].[Collection] COL ON COL.[Id] = CC.[CollectionId]
WHERE OU.[OrganizationId] = COL.[OrganizationId]
  AND OU.[Status] = 2
  AND (OU.[AccessAll] = 1 OR OU.[Type] IN (0, 1))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The same change would be needed on the EF side of things in src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs

Copy link
Copy Markdown
Collaborator Author

@jaasen-livefront jaasen-livefront Apr 23, 2026

Choose a reason for hiding this comment

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

@nick-livefront Good point. AccessAll was removed but we should definitely automatically send this to admins and owners.

bb3d8b4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice catch on AccessAll. Your current fix will allow any admin or owner to receive notifications, it still should be restricted to if they have access. AllowAdminAccessToAllCollectionItems should be checked to see if they have that setting enabled for any owner to admin to access all items.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@nick-livefront Ah yes ofc. Fixed. e7d235d

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The EF implementation needs the fix as well πŸ˜„

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🀦
7b343e6

Loading
Loading