Skip to content

[PM-36158] - Fix org cipher push notifications to fan out per authorized user#7522

Open
jaasen-livefront wants to merge 10 commits intomainfrom
PM-35168
Open

[PM-36158] - Fix org cipher push notifications to fan out per authorized user#7522
jaasen-livefront wants to merge 10 commits intomainfrom
PM-35168

Conversation

@jaasen-livefront
Copy link
Copy Markdown
Collaborator

@jaasen-livefront jaasen-livefront commented Apr 22, 2026

🎟️ Tracking

PM-35168

📔 Objective

This PR refactors org cipher push notification delivery to be per-user and fixes several related regressions.

Previously, org cipher push notifications were either silently dropped (NotificationHub / Relay) or broadcast to an org-level SignalR group (Notifications API / AzureQueue) without respecting per-user collection access. This PR moves org cipher fan-out into MultiServicePushNotificationService, which resolves the exact set of users who can access the cipher and delivers a targeted User-scoped push to each one.

Changes

Org cipher push fan-out moved to MultiServicePushNotificationService

  • When PushCipherAsync is called for an org cipher, the service:
    1. Resolves the collection IDs the cipher belongs to (using the provided collectionIds or falling back to GetCollectionIdsByCipherIdAsync if none were supplied).
    2. Calls GetUserIdsByCollectionIdsAsync to find all confirmed org members with access to those collections (via direct assignment or group membership).
    3. Fans out a User-targeted PushAsync call per user, concurrently via Task.WhenAll.
  • If no collection IDs can be resolved, a warning is logged and the push is skipped rather than sending an incorrect broadcast.

Org cipher handling removed from individual push engines

  • AzureQueuePushEngine, NotificationsApiPushEngine, RelayPushEngine, and NotificationHubPushEngine no longer handle org ciphers in PushCipherAsync; that responsibility now lives exclusively in MultiServicePushNotificationService.
  • HubHelpers no longer broadcasts cipher notifications to an org-level SignalR group; per-user fan-out upstream means the engine only sees UserId-targeted messages.

CipherService fixes

  • SaveAsync and SaveDetailsAsync now correctly forward collectionIds to PushSyncCipherCreateAsync (previously always passed null).
  • DeleteAsync now calls PushCipherAsync(cipher, PushType.SyncLoginDelete, collectionIds) directly, replacing the old PushSyncCipherDeleteAsync(cipher) call that did not carry collection context.
  • New private helper GetCollectionIdsForPushAsync retrieves collection IDs for org ciphers before push, returning null for personal ciphers.

New repository surface

  • ICollectionCipherRepository.GetCollectionIdsByCipherIdAsync(Guid cipherId) — returns the collection IDs a specific cipher belongs to (targeted single-cipher lookup, avoids the O(org) query).
  • ICollectionCipherRepository.GetUserIdsByCollectionIdsAsync(IEnumerable<Guid> collectionIds) — returns distinct confirmed user IDs with direct or group-based access to the given collections.
  • Implemented across Dapper, EF Core, and a new Noop repository.
  • ICollectionCipherRepository is now registered as a noop singleton in AddPush to satisfy DI in push-only service compositions.

Database

  • New SSDT stored procedures:
    • CollectionCipher_ReadCollectionIdsByCipherId
    • CollectionCipher_ReadUserIdsByCollectionIds
  • Migration scripts added under util/Migrator/DbScripts/.

Tests

  • AzureQueuePushEngineTests: Replaced parameterized personal/org test cases with separate _PersonalCipher_SendsExpectedResponse and _OrgCipher_DoesNotSendMessage variants to clearly document the new engine contract.
  • MultiServicePushNotificationServiceTests: Added tests for personal cipher delegation, org cipher per-user fan-out, and the null-collection-ID fallback resolution path.
  • CipherServiceTests: Updated five DeleteAsync_* assertions from PushSyncCipherDeleteAsync to PushCipherAsync to match the production call shape.

Behavior With Feature Flag OFF (OrgCipherPushFanout = false)

  • Org cipher sync pushes are sent as a single org-level broadcast (NotificationTarget.Organization) only to non-mobile engines (SignalR/Azure Queue/NotificationsApi).
  • Mobile engines (NotificationHub, Relay) do not receive org cipher sync pushes.
  • This preserves legacy behavior: web/desktop clients receive org cipher sync notifications, but mobile clients do not (avoiding unnecessary wakeups and full vault syncs).
  • Personal cipher sync pushes are unaffected and delivered to all clients as before.

Behavior With Feature Flag ON (OrgCipherPushFanout = true)

  • Org cipher sync pushes are fanned out per-user (resolved via collection access) and sent as individual user-targeted notifications (NotificationTarget.User) to all engines (web, desktop, and mobile).
  • Mobile clients will now receive per-cipher org sync notifications for ciphers they have access to.
  • This enables granular, access-aware push delivery for all platforms.

📸 Screenshots

N/A — no UI changes.

@jaasen-livefront jaasen-livefront added the ai-review Request a Claude code review label Apr 22, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the move of org-cipher push fan-out into MultiServicePushNotificationService along with the new GetUserIdsByCollectionIdsAsync and GetCollectionIdsByCipherIdAsync repository methods (Dapper + EF), the new SQL stored procedures/migrations, the removal of the org-wide broadcast paths across all four push engines and HubHelpers, and the updated CipherService.DeleteAsync flow. The implementation correctly restricts real-time cipher notifications to users with actual collection access, handles null/empty collection IDs via a lookup fallback, and parallelizes the per-user fan-out with Task.WhenAll. Dapper and EF paths match (including DISTINCT/UNION dedup and Confirmed-status filter), and unit tests cover both the new MultiServicePushNotificationService behavior and the updated CipherService.DeleteAsync contract.

Code Review Details

No new findings. The four prior review threads (silent drop on null collectionIds, sequential fan-out latency, duplicated org-wide query in the fallback, and outdated CipherServiceTests.DeleteAsync* assertions) all appear addressed by commits 5f503532d, 90b345375, and 5e4382830.

Minor observations (not blocking):

  • PushServiceCollectionExtensions registers a Noop ICollectionCipherRepository via TryAddSingleton as a standalone-test fallback — the real Dapper/EF registration from AddDatabaseRepositories runs first in production Startup paths, so the real implementation resolves correctly, but the fallback is a potential foot-gun if AddPush is ever wired without the repositories layer.
  • Only AzureQueuePushEngineTests received new OrgCipher_DoesNotSendMessage assertions; equivalent coverage for NotificationsApiPushEngine, NotificationHubPushEngine, and RelayPushEngine would harden the guarantee that engines never emit org-wide cipher payloads.

Comment thread src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs Outdated
Comment thread src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

❌ Patch coverage is 17.67442% with 177 lines in your changes missing coverage. Please review.
✅ Project coverage is 14.39%. Comparing base (0b1c22e) to head (ff7ebcd).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
.../Services/Implementations/CipherSyncPushService.cs 0.00% 84 Missing ⚠️
...re/Vault/Services/Implementations/CipherService.cs 0.00% 58 Missing ⚠️
...re/Repositories/Noop/CollectionCipherRepository.cs 0.00% 11 Missing ⚠️
....Dapper/Repositories/CollectionCipherRepository.cs 50.00% 9 Missing ⚠️
...amework/Repositories/CollectionCipherRepository.cs 76.31% 9 Missing ⚠️
.../Push/NotificationHub/NotificationHubPushEngine.cs 0.00% 3 Missing ⚠️
src/Core/Platform/Push/Engines/RelayPushEngine.cs 0.00% 2 Missing ⚠️
src/Core/Platform/Push/PushNotification.cs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #7522       +/-   ##
===========================================
- Coverage   59.13%   14.39%   -44.75%     
===========================================
  Files        2077     1280      -797     
  Lines       91848    55613    -36235     
  Branches     8175     4314     -3861     
===========================================
- Hits        54315     8004    -46311     
- Misses      35601    47469    +11868     
+ Partials     1932      140     -1792     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown
Contributor

Logo
Checkmarx One – Scan Summary & Details80320a4f-3a28-459a-9edb-ebb7ecff537b

Great job! No new security vulnerabilities introduced in this pull request

Comment thread src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs Outdated
Comment thread src/Core/Vault/Services/Implementations/CipherService.cs Outdated
@sonarqubecloud
Copy link
Copy Markdown

@jaasen-livefront jaasen-livefront marked this pull request as ready for review April 22, 2026 20:41
@jaasen-livefront jaasen-livefront requested review from a team as code owners April 22, 2026 20:41
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

return storageBytesRemaining;
}

private async Task<ICollection<Guid>> GetCollectionIdsForPushAsync(Cipher cipher)
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 should be nullable, given it can return null

Suggested change
private async Task<ICollection<Guid>> GetCollectionIdsForPushAsync(Cipher cipher)
private async Task<ICollection<Guid?>> GetCollectionIdsForPushAsync(Cipher cipher)

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 Good suggestion. The file is #nullable disable but this is a good opportunity to clean that up. I've done so along with ICipherService.

bb3d8b4

nick-livefront
nick-livefront previously approved these changes Apr 24, 2026
Comment on lines +36 to +38
// 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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants