diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml
index 178e29212a..a59bbcfa6c 100644
--- a/.github/workflows/repository-management.yml
+++ b/.github/workflows/repository-management.yml
@@ -22,6 +22,8 @@ on:
required: false
type: string
+permissions: {}
+
jobs:
setup:
name: Setup
@@ -44,49 +46,11 @@ jobs:
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
-
- cut_branch:
- name: Cut branch
- if: ${{ needs.setup.outputs.branch != 'none' }}
- needs: setup
- runs-on: ubuntu-24.04
- steps:
- - name: Generate GH App token
- uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
- id: app-token
- with:
- app-id: ${{ secrets.BW_GHAPP_ID }}
- private-key: ${{ secrets.BW_GHAPP_KEY }}
-
- - name: Check out target ref
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- with:
- ref: ${{ inputs.target_ref }}
- token: ${{ steps.app-token.outputs.token }}
-
- - name: Check if ${{ needs.setup.outputs.branch }} branch exists
- env:
- BRANCH_NAME: ${{ needs.setup.outputs.branch }}
- run: |
- if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
- echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
- exit 1
- fi
-
- - name: Cut branch
- env:
- BRANCH_NAME: ${{ needs.setup.outputs.branch }}
- run: |
- git switch --quiet --create $BRANCH_NAME
- git push --quiet --set-upstream origin $BRANCH_NAME
-
-
bump_version:
name: Bump Version
if: ${{ always() }}
runs-on: ubuntu-24.04
needs:
- - cut_branch
- setup
outputs:
version: ${{ steps.set-final-version-output.outputs.version }}
@@ -187,14 +151,13 @@ jobs:
- name: Push changes
run: git push
-
- cherry_pick:
- name: Cherry-Pick Commit(s)
+ cut_branch:
+ name: Cut branch
if: ${{ needs.setup.outputs.branch != 'none' }}
- runs-on: ubuntu-24.04
needs:
- - bump_version
- setup
+ - bump_version
+ runs-on: ubuntu-24.04
steps:
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
@@ -203,78 +166,30 @@ jobs:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- - name: Check out main branch
+ - name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
- fetch-depth: 0
- ref: main
+ ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}
- - name: Configure Git
- run: |
- git config --local user.email "actions@github.com"
- git config --local user.name "Github Actions"
-
- - name: Install xmllint
- run: |
- sudo apt-get update
- sudo apt-get install -y libxml2-utils
-
- - name: Perform cherry-pick(s)
+ - name: Check if ${{ needs.setup.outputs.branch }} branch exists
env:
- CUT_BRANCH: ${{ needs.setup.outputs.branch }}
+ BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
- # Function for cherry-picking
- cherry_pick () {
- local source_branch=$1
- local destination_branch=$2
-
- # Get project commit/version from source branch
- git switch $source_branch
- SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
- SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
-
- # Get project commit/version from destination branch
- git switch $destination_branch
- DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
-
- if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then
- git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT
- git push -u origin $destination_branch
- fi
- }
-
- # If we are cutting 'hotfix-rc':
- if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
-
- # If the 'rc' branch exists:
- if [[ $(git ls-remote --heads origin rc) ]]; then
-
- # Chery-pick from 'rc' into 'hotfix-rc'
- cherry_pick rc hotfix-rc
-
- # Cherry-pick from 'main' into 'rc'
- cherry_pick main rc
-
- # If the 'rc' branch does not exist:
- else
-
- # Cherry-pick from 'main' into 'hotfix-rc'
- cherry_pick main hotfix-rc
-
- fi
-
- # If we are cutting 'rc':
- elif [[ "$CUT_BRANCH" == "rc" ]]; then
-
- # Cherry-pick from 'main' into 'rc'
- cherry_pick main rc
-
+ if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
+ echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
+ exit 1
fi
+ - name: Cut branch
+ env:
+ BRANCH_NAME: ${{ needs.setup.outputs.branch }}
+ run: |
+ git switch --quiet --create $BRANCH_NAME
+ git push --quiet --set-upstream origin $BRANCH_NAME
move_future_db_scripts:
name: Move finalization database scripts
- needs: cherry_pick
+ needs: cut_branch
uses: ./.github/workflows/_move_finalization_db_scripts.yml
secrets: inherit
diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
index 1b58fcfebe..7765eb2665 100644
--- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
+++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
@@ -403,16 +403,15 @@ public class OrganizationUsersController : Controller
}
[HttpPost("{id}/confirm")]
- public async Task Confirm(string orgId, string id, [FromBody] OrganizationUserConfirmRequestModel model)
+ public async Task Confirm(Guid orgId, Guid id, [FromBody] OrganizationUserConfirmRequestModel model)
{
- var orgGuidId = new Guid(orgId);
- if (!await _currentContext.ManageUsers(orgGuidId))
+ if (!await _currentContext.ManageUsers(orgId))
{
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User);
- var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value);
+ var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, id, model.Key, userId.Value, model.DefaultUserCollectionName);
}
[HttpPost("confirm")]
diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs
index c0ab5c059b..3d749d25d7 100644
--- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs
+++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs
@@ -2,7 +2,7 @@
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs
index ccab2b36ae..85d46c791b 100644
--- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs
+++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs
@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
#nullable enable
diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs
index bbbb571f42..e6d4f85d3b 100644
--- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs
+++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs
@@ -60,6 +60,10 @@ public class OrganizationUserConfirmRequestModel
{
[Required]
public string Key { get; set; }
+
+ [EncryptedString]
+ [EncryptedStringLength(1000)]
+ public string DefaultUserCollectionName { get; set; }
}
public class OrganizationUserBulkConfirmRequestModelEntry
diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs
index 2f7a5a4328..8281bdaa98 100644
--- a/src/Api/Dirt/Controllers/ReportsController.cs
+++ b/src/Api/Dirt/Controllers/ReportsController.cs
@@ -1,5 +1,6 @@
using Bit.Api.Dirt.Models;
using Bit.Api.Dirt.Models.Response;
+using Bit.Api.Tools.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.Reports.Entities;
using Bit.Core.Dirt.Reports.Models.Data;
@@ -17,21 +18,24 @@ namespace Bit.Api.Dirt.Controllers;
public class ReportsController : Controller
{
private readonly ICurrentContext _currentContext;
- private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery;
+ private readonly IMemberAccessReportQuery _memberAccessReportQuery;
+ private readonly IRiskInsightsReportQuery _riskInsightsReportQuery;
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand;
public ReportsController(
ICurrentContext currentContext,
- IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery,
+ IMemberAccessReportQuery memberAccessReportQuery,
+ IRiskInsightsReportQuery riskInsightsReportQuery,
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery,
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand
)
{
_currentContext = currentContext;
- _memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery;
+ _memberAccessReportQuery = memberAccessReportQuery;
+ _riskInsightsReportQuery = riskInsightsReportQuery;
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
@@ -54,9 +58,9 @@ public class ReportsController : Controller
throw new NotFoundException();
}
- var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
+ var riskDetails = await GetRiskInsightsReportDetails(new RiskInsightsReportRequest { OrganizationId = orgId });
- var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x));
+ var responses = riskDetails.Select(x => new MemberCipherDetailsResponseModel(x));
return responses;
}
@@ -69,16 +73,16 @@ public class ReportsController : Controller
/// IEnumerable of MemberAccessReportResponseModel
/// If Access reports permission is not assigned
[HttpGet("member-access/{orgId}")]
- public async Task> GetMemberAccessReport(Guid orgId)
+ public async Task> GetMemberAccessReport(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
- var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId });
+ var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId });
- var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x));
+ var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x));
return responses;
}
@@ -87,13 +91,28 @@ public class ReportsController : Controller
/// Contains the organization member info, the cipher ids associated with the member,
/// and details on their collections, groups, and permissions
///
- /// Request to the MemberAccessCipherDetailsQuery
- /// IEnumerable of MemberAccessCipherDetails
- private async Task> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request)
+ /// Request parameters
+ ///
+ /// List of a user's permissions at a group and collection level as well as the number of ciphers
+ /// associated with that group/collection
+ ///
+ private async Task> GetMemberAccessDetails(
+ MemberAccessReportRequest request)
{
- var memberCipherDetails =
- await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request);
- return memberCipherDetails;
+ var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request);
+ return accessDetails;
+ }
+
+ ///
+ /// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids
+ ///
+ /// Request parameters
+ /// A list of risk insights data associating the user to cipher ids
+ private async Task> GetRiskInsightsReportDetails(
+ RiskInsightsReportRequest request)
+ {
+ var riskDetails = await _riskInsightsReportQuery.GetRiskInsightsReportDetails(request);
+ return riskDetails;
}
///
diff --git a/src/Api/Dirt/Models/Response/MemberAccessDetailReportResponseModel.cs b/src/Api/Dirt/Models/Response/MemberAccessDetailReportResponseModel.cs
new file mode 100644
index 0000000000..2d5a7b1556
--- /dev/null
+++ b/src/Api/Dirt/Models/Response/MemberAccessDetailReportResponseModel.cs
@@ -0,0 +1,39 @@
+using Bit.Core.Dirt.Reports.Models.Data;
+
+namespace Bit.Api.Tools.Models.Response;
+
+public class MemberAccessDetailReportResponseModel
+{
+ public Guid? UserGuid { get; set; }
+ public string UserName { get; set; }
+ public string Email { get; set; }
+ public bool TwoFactorEnabled { get; set; }
+ public bool AccountRecoveryEnabled { get; set; }
+ public bool UsesKeyConnector { get; set; }
+ public Guid? CollectionId { get; set; }
+ public Guid? GroupId { get; set; }
+ public string GroupName { get; set; }
+ public string CollectionName { get; set; }
+ public bool? ReadOnly { get; set; }
+ public bool? HidePasswords { get; set; }
+ public bool? Manage { get; set; }
+ public IEnumerable CipherIds { get; set; }
+
+ public MemberAccessDetailReportResponseModel(MemberAccessReportDetail reportDetail)
+ {
+ UserGuid = reportDetail.UserGuid;
+ UserName = reportDetail.UserName;
+ Email = reportDetail.Email;
+ TwoFactorEnabled = reportDetail.TwoFactorEnabled;
+ AccountRecoveryEnabled = reportDetail.AccountRecoveryEnabled;
+ UsesKeyConnector = reportDetail.UsesKeyConnector;
+ CollectionId = reportDetail.CollectionId;
+ GroupId = reportDetail.GroupId;
+ GroupName = reportDetail.GroupName;
+ CollectionName = reportDetail.CollectionName;
+ ReadOnly = reportDetail.ReadOnly;
+ HidePasswords = reportDetail.HidePasswords;
+ Manage = reportDetail.Manage;
+ CipherIds = reportDetail.CipherIds;
+ }
+}
diff --git a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs
index 30065ad05a..e5c6235de3 100644
--- a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs
+++ b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs
@@ -15,12 +15,12 @@ public class MemberCipherDetailsResponseModel
///
public IEnumerable CipherIds { get; set; }
- public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails)
+ public MemberCipherDetailsResponseModel(RiskInsightsReportDetail reportDetail)
{
- this.UserGuid = memberAccessCipherDetails.UserGuid;
- this.UserName = memberAccessCipherDetails.UserName;
- this.Email = memberAccessCipherDetails.Email;
- this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector;
- this.CipherIds = memberAccessCipherDetails.CipherIds;
+ this.UserGuid = reportDetail.UserGuid;
+ this.UserName = reportDetail.UserName;
+ this.Email = reportDetail.Email;
+ this.UsesKeyConnector = reportDetail.UsesKeyConnector;
+ this.CipherIds = reportDetail.CipherIds;
}
}
diff --git a/src/Api/Platform/Push/Controllers/PushController.cs b/src/Api/Platform/Push/Controllers/PushController.cs
index 2a1f2b987d..af24a7b2ca 100644
--- a/src/Api/Platform/Push/Controllers/PushController.cs
+++ b/src/Api/Platform/Push/Controllers/PushController.cs
@@ -1,8 +1,11 @@
-using Bit.Core.Context;
+using System.Diagnostics;
+using System.Text.Json;
+using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push;
+using Bit.Core.Platform.Push.Internal;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@@ -20,14 +23,14 @@ namespace Bit.Api.Platform.Push;
public class PushController : Controller
{
private readonly IPushRegistrationService _pushRegistrationService;
- private readonly IPushNotificationService _pushNotificationService;
+ private readonly IPushRelayer _pushRelayer;
private readonly IWebHostEnvironment _environment;
private readonly ICurrentContext _currentContext;
private readonly IGlobalSettings _globalSettings;
public PushController(
IPushRegistrationService pushRegistrationService,
- IPushNotificationService pushNotificationService,
+ IPushRelayer pushRelayer,
IWebHostEnvironment environment,
ICurrentContext currentContext,
IGlobalSettings globalSettings)
@@ -35,7 +38,7 @@ public class PushController : Controller
_currentContext = currentContext;
_environment = environment;
_pushRegistrationService = pushRegistrationService;
- _pushNotificationService = pushNotificationService;
+ _pushRelayer = pushRelayer;
_globalSettings = globalSettings;
}
@@ -74,31 +77,50 @@ public class PushController : Controller
}
[HttpPost("send")]
- public async Task SendAsync([FromBody] PushSendRequestModel model)
+ public async Task SendAsync([FromBody] PushSendRequestModel model)
{
CheckUsage();
- if (!string.IsNullOrWhiteSpace(model.InstallationId))
+ NotificationTarget target;
+ Guid targetId;
+
+ if (model.InstallationId.HasValue)
{
- if (_currentContext.InstallationId!.Value.ToString() != model.InstallationId!)
+ if (_currentContext.InstallationId!.Value != model.InstallationId.Value)
{
throw new BadRequestException("InstallationId does not match current context.");
}
- await _pushNotificationService.SendPayloadToInstallationAsync(
- _currentContext.InstallationId.Value.ToString(), model.Type, model.Payload, Prefix(model.Identifier),
- Prefix(model.DeviceId), model.ClientType);
+ target = NotificationTarget.Installation;
+ targetId = _currentContext.InstallationId.Value;
}
- else if (!string.IsNullOrWhiteSpace(model.UserId))
+ else if (model.UserId.HasValue)
{
- await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId),
- model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
+ target = NotificationTarget.User;
+ targetId = model.UserId.Value;
}
- else if (!string.IsNullOrWhiteSpace(model.OrganizationId))
+ else if (model.OrganizationId.HasValue)
{
- await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId),
- model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType);
+ target = NotificationTarget.Organization;
+ targetId = model.OrganizationId.Value;
}
+ else
+ {
+ throw new UnreachableException("Model validation should have prevented getting here.");
+ }
+
+ var notification = new RelayedNotification
+ {
+ Type = model.Type,
+ Target = target,
+ TargetId = targetId,
+ Payload = model.Payload,
+ Identifier = model.Identifier,
+ DeviceId = model.DeviceId,
+ ClientType = model.ClientType,
+ };
+
+ await _pushRelayer.RelayAsync(_currentContext.InstallationId.Value, notification);
}
private string Prefix(string value)
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs
similarity index 82%
rename from src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs
index c94794765b..f979b8af0e 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs
@@ -2,7 +2,7 @@
using Bit.Core.Enums;
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public interface IIntegrationMessage
{
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs
similarity index 88%
rename from src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs
index ecf5d25c51..d3b0c0d5ac 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs
@@ -1,6 +1,6 @@
#nullable enable
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationHandlerResult
{
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs
similarity index 94%
rename from src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs
index 018d453cb9..1861ec4522 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs
@@ -3,7 +3,7 @@
using System.Text.Json;
using Bit.Core.Enums;
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationMessage : IIntegrationMessage
{
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs
similarity index 95%
rename from src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs
index 338c2b963d..82c236865f 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs
@@ -5,7 +5,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationTemplateContext(EventMessage eventMessage)
{
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs
similarity index 51%
rename from src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs
index 4f2c434ff6..1e6fbadb34 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs
@@ -1,5 +1,5 @@
#nullable enable
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegration(string token);
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs
similarity index 57%
rename from src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs
index 18b13248ec..13d1ad168f 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs
@@ -1,5 +1,5 @@
#nullable enable
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfiguration(string channelId);
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs
similarity index 62%
rename from src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs
index a9b4150419..219149e4f9 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs
@@ -1,5 +1,5 @@
#nullable enable
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfigurationDetails(string channelId, string token);
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs
similarity index 56%
rename from src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs
index 47e014ee2a..5d00778e4b 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs
@@ -1,5 +1,5 @@
#nullable enable
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfiguration(string url);
diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs
similarity index 58%
rename from src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs
rename to src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs
index c4c41db24f..4790588c32 100644
--- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs
+++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs
@@ -1,5 +1,5 @@
#nullable enable
-namespace Bit.Core.AdminConsole.Models.Data.Integrations;
+namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfigurationDetails(string url);
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
index 806cf5a533..dd118d7ea3 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
@@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
+using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -28,6 +29,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
private readonly IDeviceRepository _deviceRepository;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
+ private readonly ICollectionRepository _collectionRepository;
public ConfirmOrganizationUserCommand(
IOrganizationRepository organizationRepository,
@@ -41,7 +43,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
IPolicyService policyService,
IDeviceRepository deviceRepository,
IPolicyRequirementQuery policyRequirementQuery,
- IFeatureService featureService)
+ IFeatureService featureService,
+ ICollectionRepository collectionRepository)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -55,10 +58,11 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
_deviceRepository = deviceRepository;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
+ _collectionRepository = collectionRepository;
}
public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
- Guid confirmingUserId)
+ Guid confirmingUserId, string defaultUserCollectionName = null)
{
var result = await ConfirmUsersAsync(
organizationId,
@@ -75,6 +79,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
{
throw new BadRequestException(error);
}
+
+ await HandleConfirmationSideEffectsAsync(organizationId, orgUser, defaultUserCollectionName);
+
return orgUser;
}
@@ -213,4 +220,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString());
}
+
+ private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, OrganizationUser organizationUser, string defaultUserCollectionName)
+ {
+ // Create DefaultUserCollection type collection for the user if the PersonalOwnership policy is enabled for the organization
+ var requiresDefaultCollection = await OrganizationRequiresDefaultCollectionAsync(organizationId, organizationUser.UserId.Value, defaultUserCollectionName);
+ if (requiresDefaultCollection)
+ {
+ await CreateDefaultCollectionAsync(organizationId, organizationUser.Id, defaultUserCollectionName);
+ }
+ }
+
+ private async Task OrganizationRequiresDefaultCollectionAsync(Guid organizationId, Guid userId, string defaultUserCollectionName)
+ {
+ if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
+ {
+ return false;
+ }
+
+ // Skip if no collection name provided (backwards compatibility)
+ if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
+ {
+ return false;
+ }
+
+ var personalOwnershipRequirement = await _policyRequirementQuery.GetAsync(userId);
+ return personalOwnershipRequirement.RequiresDefaultCollection(organizationId);
+ }
+
+ private async Task CreateDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName)
+ {
+ var collection = new Collection
+ {
+ OrganizationId = organizationId,
+ Name = defaultCollectionName,
+ Type = CollectionType.DefaultUserCollection
+ };
+
+ var userAccess = new List
+ {
+ new CollectionAccessSelection
+ {
+ Id = organizationUserId,
+ ReadOnly = false,
+ HidePasswords = false,
+ Manage = true
+ }
+ };
+
+ await _collectionRepository.CreateAsync(collection, groups: null, users: userAccess);
+ }
}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs
index e574d29e48..734b8d2b0c 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs
@@ -15,9 +15,10 @@ public interface IConfirmOrganizationUserCommand
/// The ID of the organization user to confirm.
/// The encrypted organization key for the user.
/// The ID of the user performing the confirmation.
+ /// Optional encrypted collection name for creating a default collection.
/// The confirmed organization user.
/// Thrown when the user is not valid or cannot be confirmed.
- Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId);
+ Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null);
///
/// Confirms multiple organization users who have accepted their invitations.
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs
index 6f3f017bb9..219d3f1bf8 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs
@@ -3,15 +3,55 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
+///
+/// Represents the personal ownership policy state.
+///
+public enum PersonalOwnershipState
+{
+ ///
+ /// Personal ownership is allowed - users can save items to their personal vault.
+ ///
+ Allowed,
+
+ ///
+ /// Personal ownership is restricted - members are required to save items to an organization.
+ ///
+ Restricted
+}
+
///
/// Policy requirements for the Disable Personal Ownership policy.
///
public class PersonalOwnershipPolicyRequirement : IPolicyRequirement
{
+ private readonly IEnumerable _organizationIdsWithPolicyEnabled;
+
+ ///
+ /// The personal ownership state for the user.
+ ///
+ ///
+ /// The collection of Organization IDs that have the Disable Personal Ownership policy enabled.
+ ///
+ public PersonalOwnershipPolicyRequirement(
+ PersonalOwnershipState personalOwnershipState,
+ IEnumerable organizationIdsWithPolicyEnabled)
+ {
+ _organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? [];
+ State = personalOwnershipState;
+ }
+
///
- /// Indicates whether Personal Ownership is disabled for the user. If true, members are required to save items to an organization.
+ /// The personal ownership policy state for the user.
///
- public bool DisablePersonalOwnership { get; init; }
+ public PersonalOwnershipState State { get; }
+
+ ///
+ /// Returns true if the Disable Personal Ownership policy is enforced in that organization.
+ ///
+ public bool RequiresDefaultCollection(Guid organizationId)
+ {
+ return _organizationIdsWithPolicyEnabled.Contains(organizationId);
+ }
}
public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory
@@ -20,7 +60,13 @@ public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFa
public override PersonalOwnershipPolicyRequirement Create(IEnumerable policyDetails)
{
- var result = new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = policyDetails.Any() };
- return result;
+ var personalOwnershipState = policyDetails.Any()
+ ? PersonalOwnershipState.Restricted
+ : PersonalOwnershipState.Allowed;
+ var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet();
+
+ return new PersonalOwnershipPolicyRequirement(
+ personalOwnershipState,
+ organizationIdsWithPolicyEnabled);
}
}
diff --git a/src/Core/AdminConsole/Services/IAzureServiceBusService.cs b/src/Core/AdminConsole/Services/IAzureServiceBusService.cs
index d254e763d5..75864255c2 100644
--- a/src/Core/AdminConsole/Services/IAzureServiceBusService.cs
+++ b/src/Core/AdminConsole/Services/IAzureServiceBusService.cs
@@ -1,5 +1,5 @@
using Azure.Messaging.ServiceBus;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services;
diff --git a/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs
index 560da576b7..b80b518223 100644
--- a/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs
+++ b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs
@@ -1,4 +1,4 @@
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services;
diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs
index bf6e6791cf..e02f26a873 100644
--- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs
+++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs
@@ -1,4 +1,4 @@
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services;
diff --git a/src/Core/AdminConsole/Services/IRabbitMqService.cs b/src/Core/AdminConsole/Services/IRabbitMqService.cs
index b0b9a72eac..12c40c3b98 100644
--- a/src/Core/AdminConsole/Services/IRabbitMqService.cs
+++ b/src/Core/AdminConsole/Services/IRabbitMqService.cs
@@ -1,4 +1,4 @@
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs
similarity index 93%
rename from src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs
index 8b00204775..ffa148fc08 100644
--- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs
@@ -33,6 +33,13 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
await _processor.StartProcessingAsync(cancellationToken);
}
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _processor.StopProcessingAsync(cancellationToken);
+ await _processor.DisposeAsync();
+ await base.StopAsync(cancellationToken);
+ }
+
internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
{
_logger.LogError(
@@ -49,16 +56,4 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId);
await args.CompleteMessageAsync(args.Message);
}
-
- public override async Task StopAsync(CancellationToken cancellationToken)
- {
- await _processor.StopProcessingAsync(cancellationToken);
- await base.StopAsync(cancellationToken);
- }
-
- public override void Dispose()
- {
- _processor.DisposeAsync().GetAwaiter().GetResult();
- base.Dispose();
- }
}
diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs
similarity index 100%
rename from src/Core/AdminConsole/Services/Implementations/AzureServiceBusIntegrationListenerService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs
diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs
similarity index 97%
rename from src/Core/AdminConsole/Services/Implementations/AzureServiceBusService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs
index 7d24095819..4887aa3a7f 100644
--- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs
@@ -1,5 +1,5 @@
using Azure.Messaging.ServiceBus;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Settings;
diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrationEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs
similarity index 100%
rename from src/Core/AdminConsole/Services/Implementations/EventIntegrationEventWriteService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs
diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs
similarity index 98%
rename from src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs
index aa76fdf8bc..e728d7c5c0 100644
--- a/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs
@@ -1,7 +1,7 @@
#nullable enable
using System.Text.Json;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
diff --git a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs
similarity index 100%
rename from src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs
diff --git a/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs
similarity index 100%
rename from src/Core/AdminConsole/Services/Implementations/EventRouteService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs
diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md
new file mode 100644
index 0000000000..54b36d2595
--- /dev/null
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md
@@ -0,0 +1,375 @@
+# Design goals
+
+The main goal of event integrations is to easily enable adding new integrations over time without the need
+for a lot of custom work to expose events to a new integration. The ability of fan-out offered by AMQP
+(either in RabbitMQ or in Azure Service Bus) gives us a way to attach any number of new integrations to the
+existing event system without needing to add special handling. By adding a new listener to the existing
+pipeline, it gains an independent stream of events without the need for additional broadcast code.
+
+We want to enable robust handling of failures and retries. By utilizing the two-tier approach
+([described below](#two-tier-exchange)), we build in support at the service level for retries. When we add
+new integrations, they can focus solely on the integration-specific logic and reporting status, with all the
+process of retries and delays managed by the messaging system.
+
+Another goal is to not only support this functionality in the cloud version, but offer it as well to
+self-hosted instances. RabbitMQ provides a lightweight way for self-hosted instances to tie into the event system
+using the same robust architecture for integrations without the need for Azure Service Bus.
+
+Finally, we want to offer organization admins flexibility and control over what events are significant, where
+to send events, and the data to be included in the message. The configuration architecture allows Organizations
+to customize details of a specific integration; see [Integrations and integration
+configurations](#integrations-and-integration-configurations) below for more details on the configuration piece.
+
+# Architecture
+
+The entry point for the event integrations is the `IEventWriteService`. By configuring the
+`EventIntegrationEventWriteService` as the `EventWriteService`, all events sent to the
+service are broadcast on the RabbitMQ or Azure Service Bus message exchange. To abstract away
+the specifics of publishing to a specific AMQP provider, an `IEventIntegrationPublisher`
+is injected into `EventIntegrationEventWriteService` to handle the publishing of events to the
+RabbitMQ or Azure Service Bus service.
+
+## Two-tier exchange
+
+When `EventIntegrationEventWriteService` publishes, it posts to the first tier of our two-tier
+approach to handling messages. Each tier is represented in the AMQP stack by a separate exchange
+(in RabbitMQ terminology) or topic (in Azure Service Bus).
+
+``` mermaid
+flowchart TD
+ B1[EventService]
+ B2[EventIntegrationEventWriteService]
+ B3[Event Exchange / Topic]
+ B4[EventRepositoryHandler]
+ B5[WebhookIntegrationHandler]
+ B6[Events in Database / Azure Tables]
+ B7[HTTP Server]
+ B8[SlackIntegrationHandler]
+ B9[Slack]
+ B10[EventIntegrationHandler]
+ B12[Integration Exchange / Topic]
+
+ B1 -->|IEventWriteService| B2 --> B3
+ B3-->|EventListenerService| B4 --> B6
+ B3-->|EventListenerService| B10
+ B3-->|EventListenerService| B10
+ B10 --> B12
+ B12 -->|IntegrationListenerService| B5
+ B12 -->|IntegrationListenerService| B8
+ B5 -->|HTTP POST| B7
+ B8 -->|HTTP POST| B9
+```
+
+### Event tier
+
+In the first tier, events are broadcast in a fan-out to a series of listeners. The message body
+is a JSON representation of an individual `EventMessage` or an array of `EventMessage`. Handlers at
+this level are responsible for handling each event or array of events. There are currently two handlers
+at this level:
+ - `EventRepositoryHandler`
+ - The `EventRepositoryHandler` is responsible for long term storage of events. It receives all events
+ and stores them via an injected `IEventRepository` into the database.
+ - This mirrors the behavior of when event integrations are turned off - cloud stores to Azure Tables
+ and self-hosted is stored to the database.
+ - `EventIntegrationHandler`
+ - The `EventIntegrationHandler` is a generic class that is customized to each integration (via the
+ configuration details of the integration) and is responsible for determining if there's a configuration
+ for this event / organization / integration, fetching that configuration, and parsing the details of the
+ event into a template string.
+ - The `EventIntegrationHandler` uses the injected `IOrganizationIntegrationConfigurationRepository` to pull
+ the specific set of configuration and template based on the event type, organization, and integration type.
+ This configuration is what determines if an integration should be sent, what details are necessary for sending
+ it, and the actual message to send.
+ - The output of `EventIntegrationHandler` is a new `IntegrationMessage`, with the details of this
+ the configuration necessary to interact with the integration and the message to send (with all the event
+ details incorporated), published to the integration level of the message bus.
+
+### Integration tier
+
+At the integration level, messages are JSON representations of `IIntegrationMessage` - specifically they
+will be concrete types of the generic `IntegrationMessage` where `` is the configuration details of the
+specific integration for which they've been sent. These messages represent the details required for
+sending a specific event to a specific integration, including handling retries and delays.
+
+Handlers at the integration level are tied directly to the integration (e.g. `SlackIntegrationHandler`,
+`WebhookIntegrationHandler`). These handlers take in `IntegrationMessage` and output
+`IntegrationHandlerResult`, which tells the listener the outcome of the integration (e.g. success / fail,
+if it can be retried and any minimum delay that should occur). This makes them easy to unit test in isolation
+without any of the concerns of AMQP or messaging.
+
+The listeners at this level are responsible for firing off the handler when a new message comes in and then
+taking the correct action based on the result. Successful results simply acknowledge the message and resolve.
+Failures will either be sent to the dead letter queue (DLQ) or re-published for retry after the correct amount of delay.
+
+### Retries
+
+One of the goals of introducing the integration level is to simplify and enable the process of multiple retries
+for a specific event integration. For instance, if a service is temporarily down, we don't want one of our handlers
+blocking the rest of the queue while it waits to retry. In addition, we don't want to retry _all_ integrations for a
+specific event if only one integration fails nor do we want to re-lookup the configuration details. By splitting
+out the `IntegrationMessage` with the configuration, message, and details around retries, we can process each
+event / integration individually and retry easily.
+
+When the `IntegrationHandlerResult.Success` is set to `false` (indicating that the integration attempt failed) the
+`Retryable` flag tells the listener whether this failure is temporary or final. If the `Retryable` is `false`, then
+the message is immediately sent to the DLQ. If it is `true`, the listener uses the `ApplyRetry(DateTime)` method
+in `IntegrationMessage` which handles both incrementing the `RetryCount` and updating the `DelayUntilDate` using
+the provided DateTime, but also adding exponential backoff (based on `RetryCount`) and jitter. The listener compares
+the `RetryCount` in the `IntegrationMessage` to see if it's over the `MaxRetries` defined in Global Settings. If it
+is over the `MaxRetries`, the message is sent to the DLQ. Otherwise, it is scheduled for retry.
+
+``` mermaid
+flowchart TD
+A[Success == false] --> B{Retryable?}
+ B -- No --> C[Send to Dead Letter Queue DLQ]
+ B -- Yes --> D[Check RetryCount vs MaxRetries]
+ D -->|RetryCount >= MaxRetries| E[Send to Dead Letter Queue DLQ]
+ D -->|RetryCount < MaxRetries| F[Schedule for Retry]
+```
+
+Azure Service Bus supports scheduling messages as part of its core functionality. Retries are scheduled to a specific
+time and then ASB holds the message and publishes it at the correct time.
+
+#### RabbitMQ retry options
+
+For RabbitMQ (which will be used by self-host only), we have two different options. The `useDelayPlugin` flag in
+`GlobalSettings.RabbitMqSettings` determines which one is used. If it is set to `true`, we use the delay plugin. It
+defaults to `false` which indicates we should use retry queues with a timing check.
+
+1. Delay plugin
+ - [Delay plugin GitHub repo](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange)
+ - This plugin enables a delayed message exchange in RabbitMQ that supports delaying a message for an amount
+ of time specified in a special header.
+ - This allows us to forego using any retry queues and rely instead on the delay exchange. When a message is
+ marked with the header it gets published to the exchange and the exchange handles all the functionality of
+ holding it until the appropriate time (similar to ASB's built-in support).
+ - The plugin must be setup and enabled before turning this option on (which is why it defaults to off).
+
+2. Retry queues + timing check
+ - If the delay plugin setting is off, we push the message to a retry queue which has a fixed amount of time before
+ it gets re-published back to the main queue.
+ - When a message comes off the queue, we check to see if the `DelayUntilDate` has already passed.
+ - If it has passed, we then handle the integration normally and retry the request.
+ - If it is still in the future, we put the message back on the retry queue for an additional wait.
+ - While this does use extra processing, it gives us better support for honoring the delays even if the delay plugin
+ isn't enabled. Since this solution is only intended for self-host, it should be a pretty minimal impact with short
+ delays and a small number of retries.
+
+## Listener / Handler pattern
+
+To make it easy to support multiple AMQP services (RabbitMQ and Azure Service Bus), the act
+of listening to the stream of messages is decoupled from the act of responding to a message.
+
+### Listeners
+
+- Listeners handle the details of the communication platform (i.e. RabbitMQ and Azure Service Bus).
+- There is one listener for each platform (RabbitMQ / ASB) for each of the two levels - i.e. one event listener
+ and one integration listener.
+- Perform all the aspects of setup / teardown, subscription, message acknowledgement, etc. for the messaging platform,
+ but do not directly process any events themselves. Instead, they delegate to the handler with which they
+ are configured.
+- Multiple instances can be configured to run independently, each with its own handler and
+ subscription / queue.
+
+### Handlers
+
+- One handler per queue / subscription (e.g. per integration at the integration level).
+- Completely isolated from and know nothing of the messaging platform in use. This allows them to be
+ freely reused across different communication platforms.
+- Perform all aspects of handling an event.
+- Allows them to be highly testable as they are isolated and decoupled from the more complicated
+ aspects of messaging.
+
+This combination allows for a configuration inside of `ServiceCollectionExtensions.cs` that pairs
+instances of the listener service for the currently running messaging platform with any number of
+handlers. It also allows for quick development of new handlers as they are focused only on the
+task of handling a specific event.
+
+## Publishers and Services
+
+Listeners (and `EventIntegrationHandler`) interact with the messaging system via the `IEventPublisher` interface,
+which is backed by a RabbitMQ and ASB specific service. By placing most of the messaging platform details in the
+service layer, we are able to handle common things like configuring the connection, binding or creating a specific
+queue, etc. in one place. The `IRabbitMqService` and `IAzureServiceBusService` implement the `IEventPublisher`
+interface and therefore can also handle directly all the message publishing functionality.
+
+## Integrations and integration configurations
+
+Organizations can configure integration configurations to send events to different endpoints -- each
+handler maps to a specific integration and checks for the configuration when it receives an event.
+Currently, there are integrations / handlers for Slack and webhooks (as mentioned above).
+
+### `OrganizationIntegration`
+
+- The top-level object that enables a specific integration for the organization.
+- Includes any properties that apply to the entire integration across all events.
+ - For Slack, it consists of the token: `{ "token": "xoxb-token-from-slack" }`
+ - For webhooks, it is `null`. However, even though there is no configuration, an organization must
+ have a webhook `OrganizationIntegration` to enable configuration via `OrganizationIntegrationConfiguration`.
+
+### `OrganizationIntegrationConfiguration`
+
+- This contains the configurations specific to each `EventType` for the integration.
+- `Configuration` contains the event-specific configuration.
+ - For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }`
+ - For Webhook, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }`
+- `Template` contains a template string that is expected to be filled in with the contents of the actual event.
+ - The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`.
+ - The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from
+ the provided `EventMessage`.
+ - The template does not enforce any structure — it could be a freeform text message to send via Slack, or a
+ JSON body to send via webhook; it is simply stored and used as a string for the most flexibility.
+
+### `OrganizationIntegrationConfigurationDetails`
+
+- This is the combination of both the `OrganizationIntegration` and `OrganizationIntegrationConfiguration` into
+ a single object. The combined contents tell the integration's handler all the details needed to send to an
+ external service.
+- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from
+ the database to determine what to publish at the integration level.
+
+# Building a new integration
+
+These are all the pieces required in the process of building out a new integration. For
+clarity in naming, these assume a new integration called "Example".
+
+## IntegrationType
+
+Add a new type to `IntegrationType` for the new integration.
+
+## Configuration Models
+
+The configuration models are the classes that will determine what is stored in the database for
+`OrganizationIntegration` and `OrganizationIntegrationConfiguration`. The `Configuration` columns are the
+serialized version of the corresponding objects and represent the coonfiguration details for this integration
+and event type.
+
+1. `ExampleIntegration`
+ - Configuration details for the whole integration (e.g. a token in Slack).
+ - Applies to every event type configuration defined for this integration.
+ - Maps to the JSON structure stored in `Configuration` in ``OrganizationIntegration`.
+2. `ExampleIntegrationConfiguration`
+ - Configuration details that could change from event to event (e.g. channelId in Slack).
+ - Maps to the JSON structure stored in `Configuration` in `OrganizationIntegrationConfiguration`.
+3. `ExampleIntegrationConfigurationDetails`
+ - Combined configuration of both Integration _and_ IntegrationConfiguration.
+ - This will be the deserialized version of the `MergedConfiguration` in
+ `OrganizationIntegrationConfigurationDetails`.
+
+## Request Models
+
+1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`.
+2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`.
+
+## Integration Handler
+
+e.g. `ExampleIntegrationHandler`
+- This is where the actual code will go to perform the integration (i.e. send an HTTP request, etc.).
+- Handlers receive an `IntegrationMessage` where `` is the `ExampleIntegrationConfigurationDetails`
+ defined above. This has the Configuration as well as the rendered template message to be sent.
+- Handlers return an `IntegrationHandlerResult` with details about if the request - success / failure,
+ if it can be retried, when it should be delayed until, etc.
+- The scope of the handler is simply to do the integration and report the result.
+ Everything else (such as how many times to retry, when to retry, what to do with failures)
+ is done in the Listener.
+
+## GlobalSettings
+
+### RabbitMQ
+Add the queue names for the integration. These are typically set with a default value so
+that they will be created when first accessed in code by RabbitMQ.
+
+1. `ExampleEventQueueName`
+2. `ExampleIntegrationQueueName`
+3. `ExampleIntegrationRetryQueueName`
+
+### Azure Service Bus
+Add the subscription names to use for ASB for this integration. Similar to RabbitMQ a
+default value is provided so that we don't require configuring it in secrets but allow
+it to be overridden. **However**, unlike RabbitMQ these subscriptions must exist prior
+to the code accessing them. They will not be created on the fly. See [Deploying a new
+integration](#deploying-a-new-integration) below
+
+1. `ExmpleEventSubscriptionName`
+2. `ExmpleIntegrationSubscriptionName`
+
+#### Service Bus Emulator, local config
+In order to create ASB resources locally, we need to also update the `servicebusemulator_config.json` file
+to include any new subscriptions.
+- Under the existing event topic (`event-logging`) add a subscription for the event level for this
+ new integration (`events-example-subscription`).
+- Under the existing integration topic (`event-integrations`) add a new subscription for the integration
+ level messages (`integration-example-subscription`).
+ - Copy the correlation filter from the other integration level subscriptions. It should filter based on
+ the `IntegrationType.ToRoutingKey`, or in this example `example`.
+
+These names added here are what must match the values provided in the secrets or the defaults provided
+in Global Settings. This must be in place (and the local ASB emulator restarted) before you can use any
+code locally that accesses ASB resources.
+
+## ServiceCollectionExtensions
+In our `ServiceCollectionExtensions`, we pull all the above pieces together to start listeners on each message
+tier with handlers to process the integration. There are a number of helper methods in here to make this simple
+to add a new integration - one call per platform.
+
+Also note that if an integration needs a custom singleton / service defined, the add listeners method is a
+good place to set that up. For instance, `SlackIntegrationHandler` needs a `SlackService`, so the singleton
+declaration is right above the add integration method for slack. Same thing for webhooks when it comes to
+defining a custom HttpClient by name.
+
+1. In `AddRabbitMqListeners` add the integration:
+``` csharp
+ services.AddRabbitMqIntegration(
+ globalSettings.EventLogging.RabbitMq.ExampleEventsQueueName,
+ globalSettings.EventLogging.RabbitMq.ExampleIntegrationQueueName,
+ globalSettings.EventLogging.RabbitMq.ExampleIntegrationRetryQueueName,
+ globalSettings.EventLogging.RabbitMq.MaxRetries,
+ IntegrationType.Example);
+```
+
+2. In `AddAzureServiceBusListeners` add the integration:
+``` csharp
+services.AddAzureServiceBusIntegration(
+ eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.ExampleEventSubscriptionName,
+ integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.ExampleIntegrationSubscriptionName,
+ integrationType: IntegrationType.Example,
+ globalSettings: globalSettings);
+```
+
+# Deploying a new integration
+
+## RabbitMQ
+
+RabbitMQ dynamically creates queues and exchanges when they are first accessed in code.
+Therefore, there is no need to manually create queues when deploying a new integration.
+They can be created and configured ahead of time, but it's not required. Note that once
+they are created, if any configurations need to be changed, the queue or exchange must be
+deleted and recreated.
+
+## Azure Service Bus
+
+Unlike RabbitMQ, ASB resources **must** be allocated before the code accesses them and
+will not be created on the fly. This means that any subscriptions needed for a new
+integration must be created in ASB before that code is deployed.
+
+The two subscriptions created above in Global Settings and `servicebusemulator_config.json`
+need to be created in the Azure portal or CLI for the environment before deploying the
+code.
+
+1. `ExmpleEventSubscriptionName`
+ - This subscription is a fan-out subscription from the main event topic.
+ - As such, it will start receiving all the events as soon as it is declared.
+ - This can create a backlog before the integration-specific handler is declared and deployed.
+ - One strategy to avoid this is to create the subscription with a false filter (e.g. `1 = 0`).
+ - This will create the subscription, but the filter will ensure that no messages
+ actually land in the subscription.
+ - Code can be deployed that references the subscription, because the subscription
+ legitimately exists (it is simply empty).
+ - When the code is in place, and we're ready to start receiving messages on the new
+ integration, we simply remove the filter to return the subscription to receiving
+ all messages via fan-out.
+2. `ExmpleIntegrationSubscriptionName`
+ - This subscription must be created before the new integration code can be deployed.
+ - However, it is not fan-out, but rather a filter based on the `IntegrationType.ToRoutingKey`.
+ - Therefore, it won't start receiving messages until organizations have active configurations.
+ This means there's no risk of building up a backlog by declaring it ahead of time.
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs
similarity index 100%
rename from src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs
similarity index 94%
rename from src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs
index 5b18d8817c..db6a7f9510 100644
--- a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs
@@ -2,7 +2,7 @@
using System.Text;
using System.Text.Json;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
@@ -20,6 +20,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
private readonly Lazy> _lazyChannel;
private readonly IRabbitMqService _rabbitMqService;
private readonly ILogger _logger;
+ private readonly TimeProvider _timeProvider;
public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
string routingKey,
@@ -27,7 +28,8 @@ public class RabbitMqIntegrationListenerService : BackgroundService
string retryQueueName,
int maxRetries,
IRabbitMqService rabbitMqService,
- ILogger logger)
+ ILogger logger,
+ TimeProvider timeProvider)
{
_handler = handler;
_routingKey = routingKey;
@@ -35,6 +37,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
_queueName = queueName;
_rabbitMqService = rabbitMqService;
_logger = logger;
+ _timeProvider = timeProvider;
_maxRetries = maxRetries;
_lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync());
}
@@ -74,7 +77,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
var integrationMessage = JsonSerializer.Deserialize(json);
if (integrationMessage is not null &&
integrationMessage.DelayUntilDate.HasValue &&
- integrationMessage.DelayUntilDate.Value > DateTime.UtcNow)
+ integrationMessage.DelayUntilDate.Value > _timeProvider.GetUtcNow().UtcDateTime)
{
await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs
similarity index 99%
rename from src/Core/AdminConsole/Services/Implementations/RabbitMqService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs
index 617d1b41fb..20ae31a113 100644
--- a/src/Core/AdminConsole/Services/Implementations/RabbitMqService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs
@@ -1,7 +1,7 @@
#nullable enable
using System.Text;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Settings;
using RabbitMQ.Client;
diff --git a/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs
similarity index 91%
rename from src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs
index fe0f6eabe1..f32d1166fa 100644
--- a/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs
@@ -1,6 +1,6 @@
#nullable enable
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services;
diff --git a/src/Core/AdminConsole/Services/Implementations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs
similarity index 100%
rename from src/Core/AdminConsole/Services/Implementations/SlackService.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs
diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs
similarity index 90%
rename from src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs
rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs
index df364b2a48..6dc348310d 100644
--- a/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs
+++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs
@@ -3,13 +3,15 @@
using System.Globalization;
using System.Net;
using System.Text;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
#nullable enable
namespace Bit.Core.Services;
-public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
+public class WebhookIntegrationHandler(
+ IHttpClientFactory httpClientFactory,
+ TimeProvider timeProvider)
: IntegrationHandlerBase
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
@@ -39,7 +41,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
if (int.TryParse(value, out var seconds))
{
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
- result.DelayUntilDate = DateTime.UtcNow.AddSeconds(seconds);
+ result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
}
else if (DateTimeOffset.TryParseExact(value,
"r", // "r" is the round-trip format: RFC1123
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 316c6e3277..240a9dd0b8 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -107,12 +107,12 @@ public static class FeatureFlagKeys
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string PolicyRequirements = "pm-14439-policy-requirements";
- public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript";
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string ImportAsyncRefactor = "pm-22583-refactor-import-async";
+ public const string CreateDefaultLocation = "pm-19467-create-default-location";
/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
@@ -182,6 +182,7 @@ public static class FeatureFlagKeys
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
public const string MobileErrorReporting = "mobile-error-reporting";
public const string AndroidChromeAutofill = "android-chrome-autofill";
+ public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps";
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
public const string AppIntents = "app-intents";
@@ -206,6 +207,7 @@ public static class FeatureFlagKeys
public const string EndUserNotifications = "pm-10609-end-user-notifications";
public const string PhishingDetection = "phishing-detection";
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
+ public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public static List GetAllKeys()
{
diff --git a/src/Core/Dirt/Reports/Models/Data/MemberAccessReportDetail.cs b/src/Core/Dirt/Reports/Models/Data/MemberAccessReportDetail.cs
new file mode 100644
index 0000000000..a99b6e2088
--- /dev/null
+++ b/src/Core/Dirt/Reports/Models/Data/MemberAccessReportDetail.cs
@@ -0,0 +1,19 @@
+namespace Bit.Core.Dirt.Reports.Models.Data;
+
+public class MemberAccessReportDetail
+{
+ public Guid? UserGuid { get; set; }
+ public string UserName { get; set; }
+ public string Email { get; set; }
+ public bool TwoFactorEnabled { get; set; }
+ public bool AccountRecoveryEnabled { get; set; }
+ public bool UsesKeyConnector { get; set; }
+ public Guid? CollectionId { get; set; }
+ public Guid? GroupId { get; set; }
+ public string GroupName { get; set; }
+ public string CollectionName { get; set; }
+ public bool? ReadOnly { get; set; }
+ public bool? HidePasswords { get; set; }
+ public bool? Manage { get; set; }
+ public IEnumerable CipherIds { get; set; }
+}
diff --git a/src/Core/Dirt/Reports/Models/Data/OrganizationMemberBaseDetail.cs b/src/Core/Dirt/Reports/Models/Data/OrganizationMemberBaseDetail.cs
new file mode 100644
index 0000000000..a1f0bd81fd
--- /dev/null
+++ b/src/Core/Dirt/Reports/Models/Data/OrganizationMemberBaseDetail.cs
@@ -0,0 +1,19 @@
+namespace Bit.Core.Dirt.Reports.Models.Data;
+
+public class OrganizationMemberBaseDetail
+{
+ public Guid? UserGuid { get; set; }
+ public string UserName { get; set; }
+ public string Email { get; set; }
+ public string TwoFactorProviders { get; set; }
+ public bool UsesKeyConnector { get; set; }
+ public string ResetPasswordKey { get; set; }
+ public Guid? CollectionId { get; set; }
+ public Guid? GroupId { get; set; }
+ public string GroupName { get; set; }
+ public string CollectionName { get; set; }
+ public bool? ReadOnly { get; set; }
+ public bool? HidePasswords { get; set; }
+ public bool? Manage { get; set; }
+ public Guid CipherId { get; set; }
+}
diff --git a/src/Core/Dirt/Reports/Models/Data/RiskInsightsReportDetail.cs b/src/Core/Dirt/Reports/Models/Data/RiskInsightsReportDetail.cs
new file mode 100644
index 0000000000..1ea805edf1
--- /dev/null
+++ b/src/Core/Dirt/Reports/Models/Data/RiskInsightsReportDetail.cs
@@ -0,0 +1,10 @@
+namespace Bit.Core.Dirt.Reports.Models.Data;
+
+public class RiskInsightsReportDetail
+{
+ public Guid? UserGuid { get; set; }
+ public string UserName { get; set; }
+ public string Email { get; set; }
+ public bool UsesKeyConnector { get; set; }
+ public IEnumerable CipherIds { get; set; }
+}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs
deleted file mode 100644
index 4a8039e6bc..0000000000
--- a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs
+++ /dev/null
@@ -1,206 +0,0 @@
-using System.Collections.Concurrent;
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.AdminConsole.Repositories;
-using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
-using Bit.Core.Dirt.Reports.Models.Data;
-using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
-using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
-using Bit.Core.Entities;
-using Bit.Core.Models.Data;
-using Bit.Core.Models.Data.Organizations;
-using Bit.Core.Models.Data.Organizations.OrganizationUsers;
-using Bit.Core.Repositories;
-using Bit.Core.Services;
-using Bit.Core.Vault.Models.Data;
-using Bit.Core.Vault.Queries;
-using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
-using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
-
-namespace Bit.Core.Dirt.Reports.ReportFeatures;
-
-public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
-{
- private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery;
- private readonly IGroupRepository _groupRepository;
- private readonly ICollectionRepository _collectionRepository;
- private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
- private readonly IApplicationCacheService _applicationCacheService;
- private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
-
- public MemberAccessCipherDetailsQuery(
- IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
- IGroupRepository groupRepository,
- ICollectionRepository collectionRepository,
- IOrganizationCiphersQuery organizationCiphersQuery,
- IApplicationCacheService applicationCacheService,
- ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery
- )
- {
- _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery;
- _groupRepository = groupRepository;
- _collectionRepository = collectionRepository;
- _organizationCiphersQuery = organizationCiphersQuery;
- _applicationCacheService = applicationCacheService;
- _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
- }
-
- public async Task> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request)
- {
- var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails(
- new OrganizationUserUserDetailsQueryRequest
- {
- OrganizationId = request.OrganizationId,
- IncludeCollections = true,
- IncludeGroups = true
- });
-
- var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(request.OrganizationId);
- var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
- var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(request.OrganizationId);
- var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId);
- var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
-
- var memberAccessCipherDetails = GenerateAccessDataParallel(
- orgGroups,
- orgCollectionsWithAccess,
- orgItems,
- organizationUsersTwoFactorEnabled,
- orgAbility);
-
- return memberAccessCipherDetails;
- }
-
- ///
- /// Generates a report for all members of an organization. Containing summary information
- /// such as item, collection, and group counts. Including the cipherIds a member is assigned.
- /// Child collection includes detailed information on the user and group collections along
- /// with their permissions.
- ///
- /// Organization groups collection
- /// Collections for the organization and the groups/users and permissions
- /// Cipher items for the organization with the collections associated with them
- /// Organization users and two factor status
- /// Organization ability for account recovery status
- /// List of the MemberAccessCipherDetailsModel;
- private IEnumerable GenerateAccessDataParallel(
- ICollection orgGroups,
- ICollection> orgCollectionsWithAccess,
- IEnumerable orgItems,
- IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled,
- OrganizationAbility orgAbility)
- {
- var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList();
- var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name);
- var collectionItems = orgItems
- .SelectMany(x => x.CollectionIds,
- (cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId })
- .GroupBy(y => y.CollectionId,
- (key, ciphers) => new { CollectionId = key, Ciphers = ciphers });
- var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()).ToList());
-
- var memberAccessCipherDetails = new ConcurrentBag();
-
- Parallel.ForEach(orgUsers, user =>
- {
- var groupAccessDetails = new List();
- var userCollectionAccessDetails = new List();
-
- foreach (var tCollect in orgCollectionsWithAccess)
- {
- if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items))
- {
- var itemCounts = items.Count;
-
- if (tCollect.Item2.Groups.Any())
- {
- var groupDetails = tCollect.Item2.Groups
- .Where(tCollectGroups => user.Groups.Contains(tCollectGroups.Id))
- .Select(x => new MemberAccessDetails
- {
- CollectionId = tCollect.Item1.Id,
- CollectionName = tCollect.Item1.Name,
- GroupId = x.Id,
- GroupName = groupNameDictionary[x.Id],
- ReadOnly = x.ReadOnly,
- HidePasswords = x.HidePasswords,
- Manage = x.Manage,
- ItemCount = itemCounts,
- CollectionCipherIds = items
- });
-
- groupAccessDetails.AddRange(groupDetails);
- }
-
- if (tCollect.Item2.Users.Any())
- {
- var userCollectionDetails = tCollect.Item2.Users
- .Where(tCollectUser => tCollectUser.Id == user.Id)
- .Select(x => new MemberAccessDetails
- {
- CollectionId = tCollect.Item1.Id,
- CollectionName = tCollect.Item1.Name,
- ReadOnly = x.ReadOnly,
- HidePasswords = x.HidePasswords,
- Manage = x.Manage,
- ItemCount = itemCounts,
- CollectionCipherIds = items
- });
-
- userCollectionAccessDetails.AddRange(userCollectionDetails);
- }
- }
- }
-
- var report = new MemberAccessCipherDetails
- {
- UserName = user.Name,
- Email = user.Email,
- TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled,
- AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword,
- UserGuid = user.Id,
- UsesKeyConnector = user.UsesKeyConnector
- };
-
- var userAccessDetails = new List();
- if (user.Groups.Any())
- {
- var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault()));
- userAccessDetails.AddRange(userGroups);
- }
-
- var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId));
- if (groupsWithoutCollections.Any())
- {
- var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails
- {
- GroupId = x,
- GroupName = groupNameDictionary[x],
- ItemCount = 0
- });
- userAccessDetails.AddRange(emptyGroups);
- }
-
- if (user.Collections.Any())
- {
- var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id));
- userAccessDetails.AddRange(userCollections);
- }
- report.AccessDetails = userAccessDetails;
-
- var userCiphers = report.AccessDetails
- .Where(x => x.ItemCount > 0)
- .SelectMany(y => y.CollectionCipherIds)
- .Distinct();
- report.CipherIds = userCiphers;
- report.TotalItemCount = userCiphers.Count();
-
- var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct();
- report.CollectionsCount = distinctItems.Count();
- report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count();
-
- memberAccessCipherDetails.Add(report);
- });
-
- return memberAccessCipherDetails;
- }
-}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs
new file mode 100644
index 0000000000..7ab8acb8dc
--- /dev/null
+++ b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs
@@ -0,0 +1,65 @@
+using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
+using Bit.Core.Dirt.Reports.Models.Data;
+using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
+using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
+using Bit.Core.Dirt.Reports.Repositories;
+using Bit.Core.Services;
+
+namespace Bit.Core.Dirt.Reports.ReportFeatures;
+
+public class MemberAccessReportQuery(
+ IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository,
+ ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
+ IApplicationCacheService applicationCacheService)
+ : IMemberAccessReportQuery
+{
+ public async Task> GetMemberAccessReportsAsync(
+ MemberAccessReportRequest request)
+ {
+ var baseDetails =
+ await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId(
+ request.OrganizationId);
+
+ var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct();
+ var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers);
+
+ var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId);
+
+ var accessDetails = baseDetails
+ .GroupBy(b => new
+ {
+ b.UserGuid,
+ b.UserName,
+ b.Email,
+ b.TwoFactorProviders,
+ b.ResetPasswordKey,
+ b.UsesKeyConnector,
+ b.GroupId,
+ b.GroupName,
+ b.CollectionId,
+ b.CollectionName,
+ b.ReadOnly,
+ b.HidePasswords,
+ b.Manage
+ })
+ .Select(g => new MemberAccessReportDetail
+ {
+ UserGuid = g.Key.UserGuid,
+ UserName = g.Key.UserName,
+ Email = g.Key.Email,
+ TwoFactorEnabled = orgUsersTwoFactorEnabled.FirstOrDefault(x => x.userId == g.Key.UserGuid).twoFactorIsEnabled,
+ AccountRecoveryEnabled = !string.IsNullOrWhiteSpace(g.Key.ResetPasswordKey) && orgAbility.UseResetPassword,
+ UsesKeyConnector = g.Key.UsesKeyConnector,
+ GroupId = g.Key.GroupId,
+ GroupName = g.Key.GroupName,
+ CollectionId = g.Key.CollectionId,
+ CollectionName = g.Key.CollectionName,
+ ReadOnly = g.Key.ReadOnly,
+ HidePasswords = g.Key.HidePasswords,
+ Manage = g.Key.Manage,
+ CipherIds = g.Select(c => c.CipherId)
+ });
+
+ return accessDetails;
+ }
+}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessReportQuery.cs
similarity index 52%
rename from src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs
rename to src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessReportQuery.cs
index 98ed780db3..44bb4f33c5 100644
--- a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs
+++ b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessReportQuery.cs
@@ -3,7 +3,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
-public interface IMemberAccessCipherDetailsQuery
+public interface IMemberAccessReportQuery
{
- Task> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request);
+ Task> GetMemberAccessReportsAsync(MemberAccessReportRequest request);
}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IRiskInsightsReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IRiskInsightsReportQuery.cs
new file mode 100644
index 0000000000..c6ba69dfff
--- /dev/null
+++ b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IRiskInsightsReportQuery.cs
@@ -0,0 +1,9 @@
+using Bit.Core.Dirt.Reports.Models.Data;
+using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
+
+namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
+
+public interface IRiskInsightsReportQuery
+{
+ Task> GetRiskInsightsReportDetails(RiskInsightsReportRequest request);
+}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs
index d847c8051e..4339d0f2f4 100644
--- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs
+++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs
@@ -8,7 +8,8 @@ public static class ReportingServiceCollectionExtensions
{
public static void AddReportingServices(this IServiceCollection services)
{
- services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessReportRequest.cs
similarity index 70%
rename from src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs
rename to src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessReportRequest.cs
index b40dfc6dec..5fe28810a6 100644
--- a/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs
+++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessReportRequest.cs
@@ -1,6 +1,6 @@
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
-public class MemberAccessCipherDetailsRequest
+public class MemberAccessReportRequest
{
public Guid OrganizationId { get; set; }
}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/RiskInsightsReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/RiskInsightsReportRequest.cs
new file mode 100644
index 0000000000..1b843ea002
--- /dev/null
+++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/RiskInsightsReportRequest.cs
@@ -0,0 +1,6 @@
+namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
+
+public class RiskInsightsReportRequest
+{
+ public Guid OrganizationId { get; set; }
+}
diff --git a/src/Core/Dirt/Reports/ReportFeatures/RiskInsightsReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/RiskInsightsReportQuery.cs
new file mode 100644
index 0000000000..e686698c51
--- /dev/null
+++ b/src/Core/Dirt/Reports/ReportFeatures/RiskInsightsReportQuery.cs
@@ -0,0 +1,39 @@
+using Bit.Core.Dirt.Reports.Models.Data;
+using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
+using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
+using Bit.Core.Dirt.Reports.Repositories;
+
+namespace Bit.Core.Dirt.Reports.ReportFeatures;
+
+public class RiskInsightsReportQuery : IRiskInsightsReportQuery
+{
+ private readonly IOrganizationMemberBaseDetailRepository _organizationMemberBaseDetailRepository;
+
+ public RiskInsightsReportQuery(IOrganizationMemberBaseDetailRepository repository)
+ {
+ _organizationMemberBaseDetailRepository = repository;
+ }
+
+ public async Task> GetRiskInsightsReportDetails(
+ RiskInsightsReportRequest request)
+ {
+ var baseDetails =
+ await _organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId(
+ request.OrganizationId);
+
+ var insightsDetails = baseDetails
+ .GroupBy(b => new { b.UserGuid, b.UserName, b.Email, b.UsesKeyConnector })
+ .Select(g => new RiskInsightsReportDetail
+ {
+ UserGuid = g.Key.UserGuid,
+ UserName = g.Key.UserName,
+ Email = g.Key.Email,
+ UsesKeyConnector = g.Key.UsesKeyConnector,
+ CipherIds = g
+ .Select(x => x.CipherId.ToString())
+ .Distinct()
+ });
+
+ return insightsDetails;
+ }
+}
diff --git a/src/Core/Dirt/Reports/Repositories/IOrganizationMemberBaseDetailRepository.cs b/src/Core/Dirt/Reports/Repositories/IOrganizationMemberBaseDetailRepository.cs
new file mode 100644
index 0000000000..e2a161aa9c
--- /dev/null
+++ b/src/Core/Dirt/Reports/Repositories/IOrganizationMemberBaseDetailRepository.cs
@@ -0,0 +1,8 @@
+using Bit.Core.Dirt.Reports.Models.Data;
+
+namespace Bit.Core.Dirt.Reports.Repositories;
+
+public interface IOrganizationMemberBaseDetailRepository
+{
+ Task> GetOrganizationMemberBaseDetailsByOrganizationId(Guid organizationId);
+}
diff --git a/src/Core/Models/Api/Request/PushSendRequestModel.cs b/src/Core/Models/Api/Request/PushSendRequestModel.cs
index 0ef7e999e3..19f89d931f 100644
--- a/src/Core/Models/Api/Request/PushSendRequestModel.cs
+++ b/src/Core/Models/Api/Request/PushSendRequestModel.cs
@@ -4,22 +4,22 @@ using Bit.Core.Enums;
namespace Bit.Core.Models.Api;
-public class PushSendRequestModel : IValidatableObject
+public class PushSendRequestModel : IValidatableObject
{
- public string? UserId { get; set; }
- public string? OrganizationId { get; set; }
- public string? DeviceId { get; set; }
+ public Guid? UserId { get; set; }
+ public Guid? OrganizationId { get; set; }
+ public Guid? DeviceId { get; set; }
public string? Identifier { get; set; }
public required PushType Type { get; set; }
- public required object Payload { get; set; }
+ public required T Payload { get; set; }
public ClientType? ClientType { get; set; }
- public string? InstallationId { get; set; }
+ public Guid? InstallationId { get; set; }
public IEnumerable Validate(ValidationContext validationContext)
{
- if (string.IsNullOrWhiteSpace(UserId) &&
- string.IsNullOrWhiteSpace(OrganizationId) &&
- string.IsNullOrWhiteSpace(InstallationId))
+ if (!UserId.HasValue &&
+ !OrganizationId.HasValue &&
+ !InstallationId.HasValue)
{
yield return new ValidationResult(
$"{nameof(UserId)} or {nameof(OrganizationId)} or {nameof(InstallationId)} is required.");
diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs
index 368c0f731b..81ec82a25d 100644
--- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs
+++ b/src/Core/NotificationHub/NotificationHubPushNotificationService.cs
@@ -1,21 +1,17 @@
#nullable enable
using System.Text.Json;
using System.Text.RegularExpressions;
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.Auth.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Models.Data;
-using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Platform.Push;
+using Bit.Core.Platform.Push.Internal;
using Bit.Core.Repositories;
using Bit.Core.Settings;
-using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
-using Notification = Bit.Core.NotificationCenter.Entities.Notification;
namespace Bit.Core.NotificationHub;
@@ -26,52 +22,32 @@ namespace Bit.Core.NotificationHub;
/// Used by Cloud-Hosted environments.
/// Received by Firebase for Android or APNS for iOS.
///
-public class NotificationHubPushNotificationService : IPushNotificationService
+public class NotificationHubPushNotificationService : IPushEngine, IPushRelayer
{
private readonly IInstallationDeviceRepository _installationDeviceRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly bool _enableTracing = false;
private readonly INotificationHubPool _notificationHubPool;
private readonly ILogger _logger;
- private readonly IGlobalSettings _globalSettings;
- private readonly TimeProvider _timeProvider;
public NotificationHubPushNotificationService(
IInstallationDeviceRepository installationDeviceRepository,
INotificationHubPool notificationHubPool,
IHttpContextAccessor httpContextAccessor,
ILogger logger,
- IGlobalSettings globalSettings,
- TimeProvider timeProvider)
+ IGlobalSettings globalSettings)
{
_installationDeviceRepository = installationDeviceRepository;
_httpContextAccessor = httpContextAccessor;
_notificationHubPool = notificationHubPool;
_logger = logger;
- _globalSettings = globalSettings;
- _timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty)
{
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
}
}
- public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
- }
-
- public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherUpdate, collectionIds);
- }
-
- public async Task PushSyncCipherDeleteAsync(Cipher cipher)
- {
- await PushCipherAsync(cipher, PushType.SyncLoginDelete, null);
- }
-
- private async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
+ public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
@@ -93,311 +69,17 @@ public class NotificationHubPushNotificationService : IPushNotificationService
CollectionIds = collectionIds,
};
- await SendPayloadToUserAsync(cipher.UserId.Value, type, message, true);
- }
- }
-
- public async Task PushSyncFolderCreateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderCreate);
- }
-
- public async Task PushSyncFolderUpdateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderUpdate);
- }
-
- public async Task PushSyncFolderDeleteAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderDelete);
- }
-
- private async Task PushFolderAsync(Folder folder, PushType type)
- {
- var message = new SyncFolderPushNotification
- {
- Id = folder.Id,
- UserId = folder.UserId,
- RevisionDate = folder.RevisionDate
- };
-
- await SendPayloadToUserAsync(folder.UserId, type, message, true);
- }
-
- public async Task PushSyncCiphersAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncCiphers);
- }
-
- public async Task PushSyncVaultAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncVault);
- }
-
- public async Task PushSyncOrganizationsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrganizations);
- }
-
- public async Task PushSyncOrgKeysAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrgKeys);
- }
-
- public async Task PushSyncSettingsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncSettings);
- }
-
- public async Task PushLogOutAsync(Guid userId, bool excludeCurrentContext = false)
- {
- await PushUserAsync(userId, PushType.LogOut, excludeCurrentContext);
- }
-
- private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
- {
- var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
-
- await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext);
- }
-
- public async Task PushSyncSendCreateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendCreate);
- }
-
- public async Task PushSyncSendUpdateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendUpdate);
- }
-
- public async Task PushSyncSendDeleteAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendDelete);
- }
-
- private async Task PushSendAsync(Send send, PushType type)
- {
- if (send.UserId.HasValue)
- {
- var message = new SyncSendPushNotification
+ await PushAsync(new PushNotification
{
- Id = send.Id,
- UserId = send.UserId.Value,
- RevisionDate = send.RevisionDate
- };
-
- await SendPayloadToUserAsync(message.UserId, type, message, true);
+ Type = type,
+ Target = NotificationTarget.User,
+ TargetId = cipher.UserId.Value,
+ Payload = message,
+ ExcludeCurrentContext = true,
+ });
}
}
- public async Task PushAuthRequestAsync(AuthRequest authRequest)
- {
- await PushAuthRequestAsync(authRequest, PushType.AuthRequest);
- }
-
- public async Task PushAuthRequestResponseAsync(AuthRequest authRequest)
- {
- await PushAuthRequestAsync(authRequest, PushType.AuthRequestResponse);
- }
-
- public async Task PushNotificationAsync(Notification notification)
- {
- Guid? installationId = notification.Global && _globalSettings.Installation.Id != Guid.Empty
- ? _globalSettings.Installation.Id
- : null;
-
- var message = new NotificationPushNotification
- {
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = installationId,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate
- };
-
- if (notification.Global)
- {
- if (installationId.HasValue)
- {
- await SendPayloadToInstallationAsync(installationId.Value, PushType.Notification, message, true,
- notification.ClientType);
- }
- else
- {
- _logger.LogWarning(
- "Invalid global notification id {NotificationId} push notification. No installation id provided.",
- notification.Id);
- }
- }
- else if (notification.UserId.HasValue)
- {
- await SendPayloadToUserAsync(notification.UserId.Value, PushType.Notification, message, true,
- notification.ClientType);
- }
- else if (notification.OrganizationId.HasValue)
- {
- await SendPayloadToOrganizationAsync(notification.OrganizationId.Value, PushType.Notification, message,
- true, notification.ClientType);
- }
- else
- {
- _logger.LogWarning("Invalid notification id {NotificationId} push notification", notification.Id);
- }
- }
-
- public async Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus)
- {
- Guid? installationId = notification.Global && _globalSettings.Installation.Id != Guid.Empty
- ? _globalSettings.Installation.Id
- : null;
-
- var message = new NotificationPushNotification
- {
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = installationId,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate,
- ReadDate = notificationStatus.ReadDate,
- DeletedDate = notificationStatus.DeletedDate
- };
-
- if (notification.Global)
- {
- if (installationId.HasValue)
- {
- await SendPayloadToInstallationAsync(installationId.Value, PushType.NotificationStatus, message, true,
- notification.ClientType);
- }
- else
- {
- _logger.LogWarning(
- "Invalid global notification status id {NotificationId} push notification. No installation id provided.",
- notification.Id);
- }
- }
- else if (notification.UserId.HasValue)
- {
- await SendPayloadToUserAsync(notification.UserId.Value, PushType.NotificationStatus, message, true,
- notification.ClientType);
- }
- else if (notification.OrganizationId.HasValue)
- {
- await SendPayloadToOrganizationAsync(notification.OrganizationId.Value, PushType.NotificationStatus,
- message, true, notification.ClientType);
- }
- else
- {
- _logger.LogWarning("Invalid notification status id {NotificationId} push notification", notification.Id);
- }
- }
-
- private async Task PushAuthRequestAsync(AuthRequest authRequest, PushType type)
- {
- var message = new AuthRequestPushNotification { Id = authRequest.Id, UserId = authRequest.UserId };
-
- await SendPayloadToUserAsync(authRequest.UserId, type, message, true);
- }
-
- private async Task SendPayloadToInstallationAsync(Guid installationId, PushType type, object payload,
- bool excludeCurrentContext, ClientType? clientType = null)
- {
- await SendPayloadToInstallationAsync(installationId.ToString(), type, payload,
- GetContextIdentifier(excludeCurrentContext), clientType: clientType);
- }
-
- private async Task SendPayloadToUserAsync(Guid userId, PushType type, object payload, bool excludeCurrentContext,
- ClientType? clientType = null)
- {
- await SendPayloadToUserAsync(userId.ToString(), type, payload, GetContextIdentifier(excludeCurrentContext),
- clientType: clientType);
- }
-
- private async Task SendPayloadToOrganizationAsync(Guid orgId, PushType type, object payload,
- bool excludeCurrentContext, ClientType? clientType = null)
- {
- await SendPayloadToOrganizationAsync(orgId.ToString(), type, payload,
- GetContextIdentifier(excludeCurrentContext), clientType: clientType);
- }
-
- public async Task PushPendingSecurityTasksAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.PendingSecurityTasks);
- }
-
- public async Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload,
- string? identifier, string? deviceId = null, ClientType? clientType = null)
- {
- var tag = BuildTag($"template:payload && installationId:{installationId}", identifier, clientType);
- await SendPayloadAsync(tag, type, payload);
- if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId))
- {
- await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId));
- }
- }
-
- public async Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- var tag = BuildTag($"template:payload_userId:{SanitizeTagInput(userId)}", identifier, clientType);
- await SendPayloadAsync(tag, type, payload);
- if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId))
- {
- await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId));
- }
- }
-
- public async Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- var tag = BuildTag($"template:payload && organizationId:{SanitizeTagInput(orgId)}", identifier, clientType);
- await SendPayloadAsync(tag, type, payload);
- if (InstallationDeviceEntity.IsInstallationDeviceId(deviceId))
- {
- await _installationDeviceRepository.UpsertAsync(new InstallationDeviceEntity(deviceId));
- }
- }
-
- public async Task PushSyncOrganizationStatusAsync(Organization organization)
- {
- var message = new OrganizationStatusPushNotification
- {
- OrganizationId = organization.Id,
- Enabled = organization.Enabled
- };
-
- await SendPayloadToOrganizationAsync(organization.Id, PushType.SyncOrganizationStatusChanged, message, false);
- }
-
- public async Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) =>
- await SendPayloadToOrganizationAsync(
- organization.Id,
- PushType.SyncOrganizationCollectionSettingChanged,
- new OrganizationCollectionManagementPushNotification
- {
- OrganizationId = organization.Id,
- LimitCollectionCreation = organization.LimitCollectionCreation,
- LimitCollectionDeletion = organization.LimitCollectionDeletion,
- LimitItemDeletion = organization.LimitItemDeletion
- },
- false
- );
-
private string? GetContextIdentifier(bool excludeCurrentContext)
{
if (!excludeCurrentContext)
@@ -425,13 +107,73 @@ public class NotificationHubPushNotificationService : IPushNotificationService
return $"({tag})";
}
- private async Task SendPayloadAsync(string tag, PushType type, object payload)
+ public async Task PushAsync(PushNotification pushNotification)
+ where T : class
{
+ var initialTag = pushNotification.Target switch
+ {
+ NotificationTarget.User => $"template:payload_userId:{pushNotification.TargetId}",
+ NotificationTarget.Organization => $"template:payload && organizationId:{pushNotification.TargetId}",
+ NotificationTarget.Installation => $"template:payload && installationId:{pushNotification.TargetId}",
+ _ => throw new InvalidOperationException($"Push notification target '{pushNotification.Target}' is not valid."),
+ };
+
+ await PushCoreAsync(
+ initialTag,
+ GetContextIdentifier(pushNotification.ExcludeCurrentContext),
+ pushNotification.Type,
+ pushNotification.ClientType,
+ pushNotification.Payload
+ );
+ }
+
+ public async Task RelayAsync(Guid fromInstallation, RelayedNotification relayedNotification)
+ {
+ // Relayed notifications need identifiers prefixed with the installation they are from and a underscore
+ var initialTag = relayedNotification.Target switch
+ {
+ NotificationTarget.User => $"template:payload_userId:{fromInstallation}_{relayedNotification.TargetId}",
+ NotificationTarget.Organization => $"template:payload && organizationId:{fromInstallation}_{relayedNotification.TargetId}",
+ NotificationTarget.Installation => $"template:payload && installationId:{fromInstallation}",
+ _ => throw new InvalidOperationException($"Invalid Notification target {relayedNotification.Target}"),
+ };
+
+ await PushCoreAsync(
+ initialTag,
+ relayedNotification.Identifier,
+ relayedNotification.Type,
+ relayedNotification.ClientType,
+ relayedNotification.Payload
+ );
+
+ if (relayedNotification.DeviceId.HasValue)
+ {
+ await _installationDeviceRepository.UpsertAsync(
+ new InstallationDeviceEntity(fromInstallation, relayedNotification.DeviceId.Value)
+ );
+ }
+ else
+ {
+ _logger.LogWarning(
+ "A related notification of type '{Type}' came through without a device id from installation {Installation}",
+ relayedNotification.Type,
+ fromInstallation
+ );
+ }
+ }
+
+ private async Task PushCoreAsync(string initialTag, string? contextId, PushType pushType, ClientType? clientType, T payload)
+ {
+ var finalTag = BuildTag(initialTag, contextId, clientType);
+
var results = await _notificationHubPool.AllClients.SendTemplateNotificationAsync(
new Dictionary
{
- { "type", ((byte)type).ToString() }, { "payload", JsonSerializer.Serialize(payload) }
- }, tag);
+ { "type", ((byte)pushType).ToString() },
+ { "payload", JsonSerializer.Serialize(payload) },
+ },
+ finalTag
+ );
if (_enableTracing)
{
@@ -444,7 +186,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService
_logger.LogInformation(
"Azure Notification Hub Tracking ID: {Id} | {Type} push notification with {Success} successes and {Failure} failures with a payload of {@Payload} and result of {@Results}",
- outcome.TrackingId, type, outcome.Success, outcome.Failure, payload, outcome.Results);
+ outcome.TrackingId, pushType, outcome.Success, outcome.Failure, payload, outcome.Results);
}
}
}
diff --git a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs
index 05d1dd2d1d..94a20f1971 100644
--- a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs
+++ b/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs
@@ -1,14 +1,10 @@
#nullable enable
using System.Text.Json;
using Azure.Storage.Queues;
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.Auth.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models;
-using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Settings;
-using Bit.Core.Tools.Entities;
using Bit.Core.Utilities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http;
@@ -17,12 +13,10 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal;
-public class AzureQueuePushNotificationService : IPushNotificationService
+public class AzureQueuePushNotificationService : IPushEngine
{
private readonly QueueClient _queueClient;
private readonly IHttpContextAccessor _httpContextAccessor;
- private readonly IGlobalSettings _globalSettings;
- private readonly TimeProvider _timeProvider;
public AzureQueuePushNotificationService(
[FromKeyedServices("notifications")] QueueClient queueClient,
@@ -33,30 +27,13 @@ public class AzureQueuePushNotificationService : IPushNotificationService
{
_queueClient = queueClient;
_httpContextAccessor = httpContextAccessor;
- _globalSettings = globalSettings;
- _timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty)
{
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
}
}
- public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
- }
-
- public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherUpdate, collectionIds);
- }
-
- public async Task PushSyncCipherDeleteAsync(Cipher cipher)
- {
- await PushCipherAsync(cipher, PushType.SyncLoginDelete, null);
- }
-
- private async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
+ public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
@@ -83,166 +60,6 @@ public class AzureQueuePushNotificationService : IPushNotificationService
}
}
- public async Task PushSyncFolderCreateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderCreate);
- }
-
- public async Task PushSyncFolderUpdateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderUpdate);
- }
-
- public async Task PushSyncFolderDeleteAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderDelete);
- }
-
- private async Task PushFolderAsync(Folder folder, PushType type)
- {
- var message = new SyncFolderPushNotification
- {
- Id = folder.Id,
- UserId = folder.UserId,
- RevisionDate = folder.RevisionDate
- };
-
- await SendMessageAsync(type, message, true);
- }
-
- public async Task PushSyncCiphersAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncCiphers);
- }
-
- public async Task PushSyncVaultAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncVault);
- }
-
- public async Task PushSyncOrganizationsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrganizations);
- }
-
- public async Task PushSyncOrgKeysAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrgKeys);
- }
-
- public async Task PushSyncSettingsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncSettings);
- }
-
- public async Task PushLogOutAsync(Guid userId, bool excludeCurrentContext = false)
- {
- await PushUserAsync(userId, PushType.LogOut, excludeCurrentContext);
- }
-
- private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
- {
- var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
-
- await SendMessageAsync(type, message, excludeCurrentContext);
- }
-
- public async Task PushAuthRequestAsync(AuthRequest authRequest)
- {
- await PushAuthRequestAsync(authRequest, PushType.AuthRequest);
- }
-
- public async Task PushAuthRequestResponseAsync(AuthRequest authRequest)
- {
- await PushAuthRequestAsync(authRequest, PushType.AuthRequestResponse);
- }
-
- private async Task PushAuthRequestAsync(AuthRequest authRequest, PushType type)
- {
- var message = new AuthRequestPushNotification { Id = authRequest.Id, UserId = authRequest.UserId };
-
- await SendMessageAsync(type, message, true);
- }
-
- public async Task PushSyncSendCreateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendCreate);
- }
-
- public async Task PushSyncSendUpdateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendUpdate);
- }
-
- public async Task PushSyncSendDeleteAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendDelete);
- }
-
- public async Task PushNotificationAsync(Notification notification)
- {
- var message = new NotificationPushNotification
- {
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = notification.Global ? _globalSettings.Installation.Id : null,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate
- };
-
- await SendMessageAsync(PushType.Notification, message, true);
- }
-
- public async Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus)
- {
- var message = new NotificationPushNotification
- {
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = notification.Global ? _globalSettings.Installation.Id : null,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate,
- ReadDate = notificationStatus.ReadDate,
- DeletedDate = notificationStatus.DeletedDate
- };
-
- await SendMessageAsync(PushType.NotificationStatus, message, true);
- }
-
- public async Task PushPendingSecurityTasksAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.PendingSecurityTasks);
- }
-
- private async Task PushSendAsync(Send send, PushType type)
- {
- if (send.UserId.HasValue)
- {
- var message = new SyncSendPushNotification
- {
- Id = send.Id,
- UserId = send.UserId.Value,
- RevisionDate = send.RevisionDate
- };
-
- await SendMessageAsync(type, message, true);
- }
- }
-
private async Task SendMessageAsync(PushType type, T payload, bool excludeCurrentContext)
{
var contextId = GetContextIdentifier(excludeCurrentContext);
@@ -263,42 +80,9 @@ public class AzureQueuePushNotificationService : IPushNotificationService
return currentContext?.DeviceIdentifier;
}
- public Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null) =>
- // Noop
- Task.CompletedTask;
-
- public Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
+ public async Task PushAsync(PushNotification pushNotification)
+ where T : class
{
- // Noop
- return Task.FromResult(0);
+ await SendMessageAsync(pushNotification.Type, pushNotification.Payload, pushNotification.ExcludeCurrentContext);
}
-
- public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- // Noop
- return Task.FromResult(0);
- }
-
- public async Task PushSyncOrganizationStatusAsync(Organization organization)
- {
- var message = new OrganizationStatusPushNotification
- {
- OrganizationId = organization.Id,
- Enabled = organization.Enabled
- };
- await SendMessageAsync(PushType.SyncOrganizationStatusChanged, message, false);
- }
-
- public async Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) =>
- await SendMessageAsync(PushType.SyncOrganizationCollectionSettingChanged,
- new OrganizationCollectionManagementPushNotification
- {
- OrganizationId = organization.Id,
- LimitCollectionCreation = organization.LimitCollectionCreation,
- LimitCollectionDeletion = organization.LimitCollectionDeletion,
- LimitItemDeletion = organization.LimitItemDeletion
- }, false);
}
diff --git a/src/Core/Platform/Push/Services/IPushEngine.cs b/src/Core/Platform/Push/Services/IPushEngine.cs
new file mode 100644
index 0000000000..bde4ddaf4b
--- /dev/null
+++ b/src/Core/Platform/Push/Services/IPushEngine.cs
@@ -0,0 +1,13 @@
+#nullable enable
+using Bit.Core.Enums;
+using Bit.Core.Vault.Entities;
+
+namespace Bit.Core.Platform.Push;
+
+public interface IPushEngine
+{
+ Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds);
+
+ Task PushAsync(PushNotification pushNotification)
+ where T : class;
+}
diff --git a/src/Core/Platform/Push/Services/IPushNotificationService.cs b/src/Core/Platform/Push/Services/IPushNotificationService.cs
index 60f3c35089..58b8a4722d 100644
--- a/src/Core/Platform/Push/Services/IPushNotificationService.cs
+++ b/src/Core/Platform/Push/Services/IPushNotificationService.cs
@@ -2,41 +2,410 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Enums;
+using Bit.Core.Models;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
+using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push;
public interface IPushNotificationService
{
- Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds);
- Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds);
- Task PushSyncCipherDeleteAsync(Cipher cipher);
- Task PushSyncFolderCreateAsync(Folder folder);
- Task PushSyncFolderUpdateAsync(Folder folder);
- Task PushSyncFolderDeleteAsync(Folder folder);
- Task PushSyncCiphersAsync(Guid userId);
- Task PushSyncVaultAsync(Guid userId);
- Task PushSyncOrganizationsAsync(Guid userId);
- Task PushSyncOrgKeysAsync(Guid userId);
- Task PushSyncSettingsAsync(Guid userId);
- Task PushLogOutAsync(Guid userId, bool excludeCurrentContextFromPush = false);
- Task PushSyncSendCreateAsync(Send send);
- Task PushSyncSendUpdateAsync(Send send);
- Task PushSyncSendDeleteAsync(Send send);
- Task PushNotificationAsync(Notification notification);
- Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus);
- Task PushAuthRequestAsync(AuthRequest authRequest);
- Task PushAuthRequestResponseAsync(AuthRequest authRequest);
- Task PushSyncOrganizationStatusAsync(Organization organization);
- Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization);
+ Guid InstallationId { get; }
+ TimeProvider TimeProvider { get; }
+ ILogger Logger { get; }
- Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null);
- Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null);
- Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null);
- Task PushPendingSecurityTasksAsync(Guid userId);
+ #region Legacy method, to be removed soon.
+ Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds)
+ => PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
+
+ Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds)
+ => PushCipherAsync(cipher, PushType.SyncCipherUpdate, collectionIds);
+
+ Task PushSyncCipherDeleteAsync(Cipher cipher)
+ => PushCipherAsync(cipher, PushType.SyncLoginDelete, null);
+
+ Task PushSyncFolderCreateAsync(Folder folder)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncFolderCreate,
+ Target = NotificationTarget.User,
+ TargetId = folder.UserId,
+ Payload = new SyncFolderPushNotification
+ {
+ Id = folder.Id,
+ UserId = folder.UserId,
+ RevisionDate = folder.RevisionDate,
+ },
+ ExcludeCurrentContext = true,
+ });
+
+ Task PushSyncFolderUpdateAsync(Folder folder)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncFolderUpdate,
+ Target = NotificationTarget.User,
+ TargetId = folder.UserId,
+ Payload = new SyncFolderPushNotification
+ {
+ Id = folder.Id,
+ UserId = folder.UserId,
+ RevisionDate = folder.RevisionDate,
+ },
+ ExcludeCurrentContext = true,
+ });
+
+ Task PushSyncFolderDeleteAsync(Folder folder)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncFolderDelete,
+ Target = NotificationTarget.User,
+ TargetId = folder.UserId,
+ Payload = new SyncFolderPushNotification
+ {
+ Id = folder.Id,
+ UserId = folder.UserId,
+ RevisionDate = folder.RevisionDate,
+ },
+ ExcludeCurrentContext = true,
+ });
+
+ Task PushSyncCiphersAsync(Guid userId)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncCiphers,
+ Target = NotificationTarget.User,
+ TargetId = userId,
+ Payload = new UserPushNotification
+ {
+ UserId = userId,
+ Date = TimeProvider.GetUtcNow().UtcDateTime,
+ },
+ ExcludeCurrentContext = false,
+ });
+
+ Task PushSyncVaultAsync(Guid userId)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncVault,
+ Target = NotificationTarget.User,
+ TargetId = userId,
+ Payload = new UserPushNotification
+ {
+ UserId = userId,
+ Date = TimeProvider.GetUtcNow().UtcDateTime,
+ },
+ ExcludeCurrentContext = false,
+ });
+
+ Task PushSyncOrganizationsAsync(Guid userId)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncOrganizations,
+ Target = NotificationTarget.User,
+ TargetId = userId,
+ Payload = new UserPushNotification
+ {
+ UserId = userId,
+ Date = TimeProvider.GetUtcNow().UtcDateTime,
+ },
+ ExcludeCurrentContext = false,
+ });
+
+ Task PushSyncOrgKeysAsync(Guid userId)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncOrgKeys,
+ Target = NotificationTarget.User,
+ TargetId = userId,
+ Payload = new UserPushNotification
+ {
+ UserId = userId,
+ Date = TimeProvider.GetUtcNow().UtcDateTime,
+ },
+ ExcludeCurrentContext = false,
+ });
+
+ Task PushSyncSettingsAsync(Guid userId)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncSettings,
+ Target = NotificationTarget.User,
+ TargetId = userId,
+ Payload = new UserPushNotification
+ {
+ UserId = userId,
+ Date = TimeProvider.GetUtcNow().UtcDateTime,
+ },
+ ExcludeCurrentContext = false,
+ });
+
+ Task PushLogOutAsync(Guid userId, bool excludeCurrentContextFromPush = false)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.LogOut,
+ Target = NotificationTarget.User,
+ TargetId = userId,
+ Payload = new UserPushNotification
+ {
+ UserId = userId,
+ Date = TimeProvider.GetUtcNow().UtcDateTime,
+ },
+ ExcludeCurrentContext = excludeCurrentContextFromPush,
+ });
+
+ Task PushSyncSendCreateAsync(Send send)
+ {
+ if (send.UserId.HasValue)
+ {
+ return PushAsync(new PushNotification
+ {
+ Type = PushType.SyncSendCreate,
+ Target = NotificationTarget.User,
+ TargetId = send.UserId.Value,
+ Payload = new SyncSendPushNotification
+ {
+ Id = send.Id,
+ UserId = send.UserId.Value,
+ RevisionDate = send.RevisionDate,
+ },
+ ExcludeCurrentContext = true,
+ });
+ }
+
+ return Task.CompletedTask;
+ }
+
+ Task PushSyncSendUpdateAsync(Send send)
+ {
+ if (send.UserId.HasValue)
+ {
+ return PushAsync(new PushNotification
+ {
+ Type = PushType.SyncSendUpdate,
+ Target = NotificationTarget.User,
+ TargetId = send.UserId.Value,
+ Payload = new SyncSendPushNotification
+ {
+ Id = send.Id,
+ UserId = send.UserId.Value,
+ RevisionDate = send.RevisionDate,
+ },
+ ExcludeCurrentContext = true,
+ });
+ }
+
+ return Task.CompletedTask;
+ }
+
+ Task PushSyncSendDeleteAsync(Send send)
+ {
+ if (send.UserId.HasValue)
+ {
+ return PushAsync(new PushNotification
+ {
+ Type = PushType.SyncSendDelete,
+ Target = NotificationTarget.User,
+ TargetId = send.UserId.Value,
+ Payload = new SyncSendPushNotification
+ {
+ Id = send.Id,
+ UserId = send.UserId.Value,
+ RevisionDate = send.RevisionDate,
+ },
+ ExcludeCurrentContext = true,
+ });
+ }
+
+ return Task.CompletedTask;
+ }
+
+ Task PushNotificationAsync(Notification notification)
+ {
+ var message = new NotificationPushNotification
+ {
+ Id = notification.Id,
+ Priority = notification.Priority,
+ Global = notification.Global,
+ ClientType = notification.ClientType,
+ UserId = notification.UserId,
+ OrganizationId = notification.OrganizationId,
+ InstallationId = notification.Global ? InstallationId : null,
+ TaskId = notification.TaskId,
+ Title = notification.Title,
+ Body = notification.Body,
+ CreationDate = notification.CreationDate,
+ RevisionDate = notification.RevisionDate,
+ };
+
+ NotificationTarget target;
+ Guid targetId;
+
+ if (notification.Global)
+ {
+ // TODO: Think about this a bit more
+ target = NotificationTarget.Installation;
+ targetId = InstallationId;
+ }
+ else if (notification.UserId.HasValue)
+ {
+ target = NotificationTarget.User;
+ targetId = notification.UserId.Value;
+ }
+ else if (notification.OrganizationId.HasValue)
+ {
+ target = NotificationTarget.Organization;
+ targetId = notification.OrganizationId.Value;
+ }
+ else
+ {
+ Logger.LogWarning("Invalid notification id {NotificationId} push notification", notification.Id);
+ return Task.CompletedTask;
+ }
+
+ return PushAsync(new PushNotification
+ {
+ Type = PushType.Notification,
+ Target = target,
+ TargetId = targetId,
+ Payload = message,
+ ExcludeCurrentContext = true,
+ ClientType = notification.ClientType,
+ });
+ }
+
+ Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus)
+ {
+ var message = new NotificationPushNotification
+ {
+ Id = notification.Id,
+ Priority = notification.Priority,
+ Global = notification.Global,
+ ClientType = notification.ClientType,
+ UserId = notification.UserId,
+ OrganizationId = notification.OrganizationId,
+ InstallationId = notification.Global ? InstallationId : null,
+ TaskId = notification.TaskId,
+ Title = notification.Title,
+ Body = notification.Body,
+ CreationDate = notification.CreationDate,
+ RevisionDate = notification.RevisionDate,
+ ReadDate = notificationStatus.ReadDate,
+ DeletedDate = notificationStatus.DeletedDate,
+ };
+
+ NotificationTarget target;
+ Guid targetId;
+
+ if (notification.Global)
+ {
+ // TODO: Think about this a bit more
+ target = NotificationTarget.Installation;
+ targetId = InstallationId;
+ }
+ else if (notification.UserId.HasValue)
+ {
+ target = NotificationTarget.User;
+ targetId = notification.UserId.Value;
+ }
+ else if (notification.OrganizationId.HasValue)
+ {
+ target = NotificationTarget.Organization;
+ targetId = notification.OrganizationId.Value;
+ }
+ else
+ {
+ Logger.LogWarning("Invalid notification status id {NotificationId} push notification", notification.Id);
+ return Task.CompletedTask;
+ }
+
+ return PushAsync(new PushNotification
+ {
+ Type = PushType.NotificationStatus,
+ Target = target,
+ TargetId = targetId,
+ Payload = message,
+ ExcludeCurrentContext = true,
+ ClientType = notification.ClientType,
+ });
+ }
+
+ Task PushAuthRequestAsync(AuthRequest authRequest)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.AuthRequest,
+ Target = NotificationTarget.User,
+ TargetId = authRequest.UserId,
+ Payload = new AuthRequestPushNotification
+ {
+ Id = authRequest.Id,
+ UserId = authRequest.UserId,
+ },
+ ExcludeCurrentContext = true,
+ });
+
+ Task PushAuthRequestResponseAsync(AuthRequest authRequest)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.AuthRequestResponse,
+ Target = NotificationTarget.User,
+ TargetId = authRequest.UserId,
+ Payload = new AuthRequestPushNotification
+ {
+ Id = authRequest.Id,
+ UserId = authRequest.UserId,
+ },
+ ExcludeCurrentContext = true,
+ });
+
+ Task PushSyncOrganizationStatusAsync(Organization organization)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncOrganizationStatusChanged,
+ Target = NotificationTarget.Organization,
+ TargetId = organization.Id,
+ Payload = new OrganizationStatusPushNotification
+ {
+ OrganizationId = organization.Id,
+ Enabled = organization.Enabled,
+ },
+ ExcludeCurrentContext = false,
+ });
+
+ Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.SyncOrganizationCollectionSettingChanged,
+ Target = NotificationTarget.Organization,
+ TargetId = organization.Id,
+ Payload = new OrganizationCollectionManagementPushNotification
+ {
+ OrganizationId = organization.Id,
+ LimitCollectionCreation = organization.LimitCollectionCreation,
+ LimitCollectionDeletion = organization.LimitCollectionDeletion,
+ LimitItemDeletion = organization.LimitItemDeletion,
+ },
+ ExcludeCurrentContext = false,
+ });
+
+ Task PushPendingSecurityTasksAsync(Guid userId)
+ => PushAsync(new PushNotification
+ {
+ Type = PushType.PendingSecurityTasks,
+ Target = NotificationTarget.User,
+ TargetId = userId,
+ Payload = new UserPushNotification
+ {
+ UserId = userId,
+ Date = TimeProvider.GetUtcNow().UtcDateTime,
+ },
+ ExcludeCurrentContext = false,
+ });
+ #endregion
+
+ Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds);
+
+ Task PushAsync(PushNotification pushNotification)
+ where T : class;
}
diff --git a/src/Core/Platform/Push/Services/IPushRelayer.cs b/src/Core/Platform/Push/Services/IPushRelayer.cs
new file mode 100644
index 0000000000..fde0a521f3
--- /dev/null
+++ b/src/Core/Platform/Push/Services/IPushRelayer.cs
@@ -0,0 +1,44 @@
+#nullable enable
+
+using System.Text.Json;
+using Bit.Core.Enums;
+
+namespace Bit.Core.Platform.Push.Internal;
+
+///
+/// An object encapsulating the information that is available in a notification
+/// given to us from a self-hosted installation.
+///
+public class RelayedNotification
+{
+ ///
+ public required PushType Type { get; init; }
+ ///
+ public required NotificationTarget Target { get; init; }
+ ///
+ public required Guid TargetId { get; init; }
+ ///
+ public required JsonElement Payload { get; init; }
+ ///
+ public required ClientType? ClientType { get; init; }
+ public required Guid? DeviceId { get; init; }
+ public required string? Identifier { get; init; }
+}
+
+///
+/// A service for taking a notification that was relayed to us from a self-hosted installation and
+/// will be injested into our infrastructure so that we can get the notification to devices that require
+/// cloud interaction.
+///
+///
+/// This interface should be treated as internal and not consumed by other teams.
+///
+public interface IPushRelayer
+{
+ ///
+ /// Relays a notification that was received from an authenticated installation into our cloud push notification infrastructure.
+ ///
+ /// The authenticated installation this notification came from.
+ /// The information received from the self-hosted installation.
+ Task RelayAsync(Guid fromInstallation, RelayedNotification relayedNotification);
+}
diff --git a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs b/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs
index 490b690a3b..404b153fa3 100644
--- a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs
+++ b/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs
@@ -1,202 +1,77 @@
#nullable enable
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.Auth.Entities;
using Bit.Core.Enums;
-using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Settings;
-using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
-using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal;
public class MultiServicePushNotificationService : IPushNotificationService
{
- private readonly IEnumerable _services;
- private readonly ILogger _logger;
+ private readonly IEnumerable _services;
+
+ public Guid InstallationId { get; }
+
+ public TimeProvider TimeProvider { get; }
+
+ public ILogger Logger { get; }
public MultiServicePushNotificationService(
- [FromKeyedServices("implementation")] IEnumerable services,
+ IEnumerable services,
ILogger logger,
- GlobalSettings globalSettings)
+ GlobalSettings globalSettings,
+ TimeProvider timeProvider)
{
_services = services;
- _logger = logger;
- _logger.LogInformation("Hub services: {Services}", _services.Count());
+ Logger = logger;
+ Logger.LogInformation("Hub services: {Services}", _services.Count());
globalSettings.NotificationHubPool?.NotificationHubs?.ForEach(hub =>
{
- _logger.LogInformation("HubName: {HubName}, EnableSendTracing: {EnableSendTracing}, RegistrationStartDate: {RegistrationStartDate}, RegistrationEndDate: {RegistrationEndDate}", hub.HubName, hub.EnableSendTracing, hub.RegistrationStartDate, hub.RegistrationEndDate);
+ Logger.LogInformation("HubName: {HubName}, EnableSendTracing: {EnableSendTracing}, RegistrationStartDate: {RegistrationStartDate}, RegistrationEndDate: {RegistrationEndDate}", hub.HubName, hub.EnableSendTracing, hub.RegistrationStartDate, hub.RegistrationEndDate);
});
+ InstallationId = globalSettings.Installation.Id;
+ TimeProvider = timeProvider;
}
- public Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- PushToServices((s) => s.PushSyncCipherCreateAsync(cipher, collectionIds));
- return Task.FromResult(0);
- }
-
- public Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- PushToServices((s) => s.PushSyncCipherUpdateAsync(cipher, collectionIds));
- return Task.FromResult(0);
- }
-
- public Task PushSyncCipherDeleteAsync(Cipher cipher)
- {
- PushToServices((s) => s.PushSyncCipherDeleteAsync(cipher));
- return Task.FromResult(0);
- }
-
- public Task PushSyncFolderCreateAsync(Folder folder)
- {
- PushToServices((s) => s.PushSyncFolderCreateAsync(folder));
- return Task.FromResult(0);
- }
-
- public Task PushSyncFolderUpdateAsync(Folder folder)
- {
- PushToServices((s) => s.PushSyncFolderUpdateAsync(folder));
- return Task.FromResult(0);
- }
-
- public Task PushSyncFolderDeleteAsync(Folder folder)
- {
- PushToServices((s) => s.PushSyncFolderDeleteAsync(folder));
- return Task.FromResult(0);
- }
-
- public Task PushSyncCiphersAsync(Guid userId)
- {
- PushToServices((s) => s.PushSyncCiphersAsync(userId));
- return Task.FromResult(0);
- }
-
- public Task PushSyncVaultAsync(Guid userId)
- {
- PushToServices((s) => s.PushSyncVaultAsync(userId));
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrganizationsAsync(Guid userId)
- {
- PushToServices((s) => s.PushSyncOrganizationsAsync(userId));
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrgKeysAsync(Guid userId)
- {
- PushToServices((s) => s.PushSyncOrgKeysAsync(userId));
- return Task.FromResult(0);
- }
-
- public Task PushSyncSettingsAsync(Guid userId)
- {
- PushToServices((s) => s.PushSyncSettingsAsync(userId));
- return Task.FromResult(0);
- }
-
- public Task PushLogOutAsync(Guid userId, bool excludeCurrentContext = false)
- {
- PushToServices((s) => s.PushLogOutAsync(userId, excludeCurrentContext));
- return Task.FromResult(0);
- }
-
- public Task PushSyncSendCreateAsync(Send send)
- {
- PushToServices((s) => s.PushSyncSendCreateAsync(send));
- return Task.FromResult(0);
- }
-
- public Task PushSyncSendUpdateAsync(Send send)
- {
- PushToServices((s) => s.PushSyncSendUpdateAsync(send));
- return Task.FromResult(0);
- }
-
- public Task PushAuthRequestAsync(AuthRequest authRequest)
- {
- PushToServices((s) => s.PushAuthRequestAsync(authRequest));
- return Task.FromResult(0);
- }
-
- public Task PushAuthRequestResponseAsync(AuthRequest authRequest)
- {
- PushToServices((s) => s.PushAuthRequestResponseAsync(authRequest));
- return Task.FromResult(0);
- }
-
- public Task PushSyncSendDeleteAsync(Send send)
- {
- PushToServices((s) => s.PushSyncSendDeleteAsync(send));
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrganizationStatusAsync(Organization organization)
- {
- PushToServices((s) => s.PushSyncOrganizationStatusAsync(organization));
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization)
- {
- PushToServices(s => s.PushSyncOrganizationCollectionManagementSettingsAsync(organization));
- return Task.CompletedTask;
- }
-
- public Task PushNotificationAsync(Notification notification)
- {
- PushToServices((s) => s.PushNotificationAsync(notification));
- return Task.CompletedTask;
- }
-
- public Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus)
- {
- PushToServices((s) => s.PushNotificationStatusAsync(notification, notificationStatus));
- return Task.CompletedTask;
- }
-
- public Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- PushToServices((s) =>
- s.SendPayloadToInstallationAsync(installationId, type, payload, identifier, deviceId, clientType));
- return Task.CompletedTask;
- }
-
- public Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- PushToServices((s) => s.SendPayloadToUserAsync(userId, type, payload, identifier, deviceId, clientType));
- return Task.FromResult(0);
- }
-
- public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- PushToServices((s) => s.SendPayloadToOrganizationAsync(orgId, type, payload, identifier, deviceId, clientType));
- return Task.FromResult(0);
- }
-
- public Task PushPendingSecurityTasksAsync(Guid userId)
- {
- PushToServices((s) => s.PushPendingSecurityTasksAsync(userId));
- return Task.CompletedTask;
- }
-
- private void PushToServices(Func pushFunc)
+ private Task PushToServices(Func pushFunc)
{
if (!_services.Any())
{
- _logger.LogWarning("No services found to push notification");
- return;
+ Logger.LogWarning("No services found to push notification");
+ return Task.CompletedTask;
}
+
+#if DEBUG
+ var tasks = new List();
+#endif
+
foreach (var service in _services)
{
- _logger.LogDebug("Pushing notification to service {ServiceName}", service.GetType().Name);
+ Logger.LogDebug("Pushing notification to service {ServiceName}", service.GetType().Name);
+#if DEBUG
+ var task =
+#endif
pushFunc(service);
+#if DEBUG
+ tasks.Add(task);
+#endif
}
+
+#if DEBUG
+ return Task.WhenAll(tasks);
+#else
+ return Task.CompletedTask;
+#endif
+ }
+
+ public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds)
+ {
+ return PushToServices((s) => s.PushCipherAsync(cipher, pushType, collectionIds));
+ }
+ public Task PushAsync(PushNotification pushNotification) where T : class
+ {
+ return PushToServices((s) => s.PushAsync(pushNotification));
}
}
diff --git a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs b/src/Core/Platform/Push/Services/NoopPushNotificationService.cs
index 6e7278cf94..e6f71de006 100644
--- a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs
+++ b/src/Core/Platform/Push/Services/NoopPushNotificationService.cs
@@ -1,129 +1,12 @@
#nullable enable
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.Auth.Entities;
using Bit.Core.Enums;
-using Bit.Core.NotificationCenter.Entities;
-using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
namespace Bit.Core.Platform.Push.Internal;
-public class NoopPushNotificationService : IPushNotificationService
+internal class NoopPushNotificationService : IPushEngine
{
- public Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- return Task.FromResult(0);
- }
+ public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds) => Task.CompletedTask;
- public Task PushSyncCipherDeleteAsync(Cipher cipher)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncCiphersAsync(Guid userId)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncFolderCreateAsync(Folder folder)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncFolderDeleteAsync(Folder folder)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncFolderUpdateAsync(Folder folder)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrganizationsAsync(Guid userId)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrgKeysAsync(Guid userId)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncSettingsAsync(Guid userId)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncVaultAsync(Guid userId)
- {
- return Task.FromResult(0);
- }
-
- public Task PushLogOutAsync(Guid userId, bool excludeCurrentContext = false)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncSendCreateAsync(Send send)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncSendDeleteAsync(Send send)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncSendUpdateAsync(Send send)
- {
- return Task.FromResult(0);
- }
-
- public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrganizationStatusAsync(Organization organization)
- {
- return Task.FromResult(0);
- }
-
- public Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) => Task.CompletedTask;
-
- public Task PushAuthRequestAsync(AuthRequest authRequest)
- {
- return Task.FromResult(0);
- }
-
- public Task PushAuthRequestResponseAsync(AuthRequest authRequest)
- {
- return Task.FromResult(0);
- }
-
- public Task PushNotificationAsync(Notification notification) => Task.CompletedTask;
-
- public Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus) =>
- Task.CompletedTask;
-
- public Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null) => Task.CompletedTask;
-
- public Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- return Task.FromResult(0);
- }
-
- public Task PushPendingSecurityTasksAsync(Guid userId)
- {
- return Task.FromResult(0);
- }
+ public Task PushAsync(PushNotification pushNotification) where T : class => Task.CompletedTask;
}
diff --git a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs
index bdeefc0363..5e0d584ba8 100644
--- a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs
+++ b/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs
@@ -1,13 +1,9 @@
#nullable enable
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.Auth.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models;
-using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
-using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
@@ -20,18 +16,15 @@ namespace Bit.Core.Platform.Push;
/// Used by Cloud-Hosted environments.
/// Received by AzureQueueHostedService message receiver in Notifications project.
///
-public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushNotificationService
+public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushEngine
{
- private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor;
- private readonly TimeProvider _timeProvider;
public NotificationsApiPushNotificationService(
IHttpClientFactory httpFactory,
GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor,
- ILogger logger,
- TimeProvider timeProvider)
+ ILogger logger)
: base(
httpFactory,
globalSettings.BaseServiceUri.InternalNotifications,
@@ -41,27 +34,10 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
globalSettings.InternalIdentityKey,
logger)
{
- _globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor;
- _timeProvider = timeProvider;
}
- public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
- }
-
- public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherUpdate, collectionIds);
- }
-
- public async Task PushSyncCipherDeleteAsync(Cipher cipher)
- {
- await PushCipherAsync(cipher, PushType.SyncLoginDelete, null);
- }
-
- private async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
+ public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
@@ -89,174 +65,6 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
}
}
- public async Task PushSyncFolderCreateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderCreate);
- }
-
- public async Task PushSyncFolderUpdateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderUpdate);
- }
-
- public async Task PushSyncFolderDeleteAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderDelete);
- }
-
- private async Task PushFolderAsync(Folder folder, PushType type)
- {
- var message = new SyncFolderPushNotification
- {
- Id = folder.Id,
- UserId = folder.UserId,
- RevisionDate = folder.RevisionDate
- };
-
- await SendMessageAsync(type, message, true);
- }
-
- public async Task PushSyncCiphersAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncCiphers);
- }
-
- public async Task PushSyncVaultAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncVault);
- }
-
- public async Task PushSyncOrganizationsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrganizations);
- }
-
- public async Task PushSyncOrgKeysAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrgKeys);
- }
-
- public async Task PushSyncSettingsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncSettings);
- }
-
- public async Task PushLogOutAsync(Guid userId, bool excludeCurrentContext)
- {
- await PushUserAsync(userId, PushType.LogOut, excludeCurrentContext);
- }
-
- private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
- {
- var message = new UserPushNotification
- {
- UserId = userId,
- Date = _timeProvider.GetUtcNow().UtcDateTime,
- };
-
- await SendMessageAsync(type, message, excludeCurrentContext);
- }
-
- public async Task PushAuthRequestAsync(AuthRequest authRequest)
- {
- await PushAuthRequestAsync(authRequest, PushType.AuthRequest);
- }
-
- public async Task PushAuthRequestResponseAsync(AuthRequest authRequest)
- {
- await PushAuthRequestAsync(authRequest, PushType.AuthRequestResponse);
- }
-
- private async Task PushAuthRequestAsync(AuthRequest authRequest, PushType type)
- {
- var message = new AuthRequestPushNotification
- {
- Id = authRequest.Id,
- UserId = authRequest.UserId
- };
-
- await SendMessageAsync(type, message, true);
- }
-
- public async Task PushSyncSendCreateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendCreate);
- }
-
- public async Task PushSyncSendUpdateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendUpdate);
- }
-
- public async Task PushSyncSendDeleteAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendDelete);
- }
-
- public async Task PushNotificationAsync(Notification notification)
- {
- var message = new NotificationPushNotification
- {
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = notification.Global ? _globalSettings.Installation.Id : null,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate
- };
-
- await SendMessageAsync(PushType.Notification, message, true);
- }
-
- public async Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus)
- {
- var message = new NotificationPushNotification
- {
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = notification.Global ? _globalSettings.Installation.Id : null,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate,
- ReadDate = notificationStatus.ReadDate,
- DeletedDate = notificationStatus.DeletedDate
- };
-
- await SendMessageAsync(PushType.NotificationStatus, message, true);
- }
-
- public async Task PushPendingSecurityTasksAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.PendingSecurityTasks);
- }
-
- private async Task PushSendAsync(Send send, PushType type)
- {
- if (send.UserId.HasValue)
- {
- var message = new SyncSendPushNotification
- {
- Id = send.Id,
- UserId = send.UserId.Value,
- RevisionDate = send.RevisionDate
- };
-
- await SendMessageAsync(type, message, false);
- }
- }
-
private async Task SendMessageAsync(PushType type, T payload, bool excludeCurrentContext)
{
var contextId = GetContextIdentifier(excludeCurrentContext);
@@ -276,43 +84,8 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
return currentContext?.DeviceIdentifier;
}
- public Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null) =>
- // Noop
- Task.CompletedTask;
-
- public Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
+ public async Task PushAsync(PushNotification pushNotification) where T : class
{
- // Noop
- return Task.FromResult(0);
+ await SendMessageAsync(pushNotification.Type, pushNotification.Payload, pushNotification.ExcludeCurrentContext);
}
-
- public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- // Noop
- return Task.FromResult(0);
- }
-
- public async Task PushSyncOrganizationStatusAsync(Organization organization)
- {
- var message = new OrganizationStatusPushNotification
- {
- OrganizationId = organization.Id,
- Enabled = organization.Enabled
- };
-
- await SendMessageAsync(PushType.SyncOrganizationStatusChanged, message, false);
- }
-
- public async Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) =>
- await SendMessageAsync(PushType.SyncOrganizationCollectionSettingChanged,
- new OrganizationCollectionManagementPushNotification
- {
- OrganizationId = organization.Id,
- LimitCollectionCreation = organization.LimitCollectionCreation,
- LimitCollectionDeletion = organization.LimitCollectionDeletion,
- LimitItemDeletion = organization.LimitItemDeletion
- }, false);
}
diff --git a/src/Core/Platform/Push/Services/PushNotification.cs b/src/Core/Platform/Push/Services/PushNotification.cs
new file mode 100644
index 0000000000..e1d3f44cd8
--- /dev/null
+++ b/src/Core/Platform/Push/Services/PushNotification.cs
@@ -0,0 +1,78 @@
+#nullable enable
+using Bit.Core.Enums;
+
+namespace Bit.Core.Platform.Push;
+
+///
+/// Contains constants for all the available targets for a given notification.
+///
+public enum NotificationTarget
+{
+ ///
+ /// The target for the notification is a single user.
+ ///
+ User,
+ ///
+ /// The target for the notification are all the users in an organization.
+ ///
+ Organization,
+ ///
+ /// The target for the notification are all the organizations,
+ /// and all the users in that organization for a installation.
+ ///
+ Installation,
+}
+
+///
+/// An object containing all the information required for getting a notification
+/// to an end users device and the information you want available to that device.
+///
+/// The type of the payload. This type is expected to be able to be roundtripped as JSON.
+public record PushNotification
+ where T : class
+{
+ ///
+ /// The to be associated with the notification. This is used to route
+ /// the notification to the correct handler on the client side. Be sure to use the correct payload
+ /// type for the associated .
+ ///
+ public required PushType Type { get; init; }
+
+ ///
+ /// The target entity type for the notification.
+ ///
+ ///
+ /// When the target type is the
+ /// property is expected to be a users ID. When it is
+ /// it should be an organizations id. When it is a
+ /// it should be an installation id.
+ ///
+ public required NotificationTarget Target { get; init; }
+
+ ///
+ /// The indentifier for the given .
+ ///
+ public required Guid TargetId { get; init; }
+
+ ///
+ /// The payload to be sent with the notification. This object will be JSON serialized.
+ ///
+ public required T Payload { get; init; }
+
+ ///
+ /// When the notification will not include the current context identifier on it, this
+ /// means that the notification may get handled on the device that this notification could have originated from.
+ ///
+ public required bool ExcludeCurrentContext { get; init; }
+
+ ///
+ /// The type of clients the notification should be sent to, if then
+ /// is inferred.
+ ///
+ public ClientType? ClientType { get; init; }
+
+ internal Guid? GetTargetWhen(NotificationTarget notificationTarget)
+ {
+ return Target == notificationTarget ? TargetId : null;
+ }
+}
diff --git a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs
index 0ede81e719..9f2289b864 100644
--- a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs
+++ b/src/Core/Platform/Push/Services/RelayPushNotificationService.cs
@@ -1,18 +1,15 @@
#nullable enable
-using Bit.Core.AdminConsole.Entities;
-using Bit.Core.Auth.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.IdentityServer;
using Bit.Core.Models;
using Bit.Core.Models.Api;
-using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
-using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal;
@@ -22,20 +19,18 @@ namespace Bit.Core.Platform.Push.Internal;
/// Used by Self-Hosted environments.
/// Received by PushController endpoint in Api project.
///
-public class RelayPushNotificationService : BaseIdentityClientService, IPushNotificationService
+public class RelayPushNotificationService : BaseIdentityClientService, IPushEngine
{
private readonly IDeviceRepository _deviceRepository;
- private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor;
- private readonly TimeProvider _timeProvider;
+
public RelayPushNotificationService(
IHttpClientFactory httpFactory,
IDeviceRepository deviceRepository,
GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor,
- ILogger logger,
- TimeProvider timeProvider)
+ ILogger logger)
: base(
httpFactory,
globalSettings.PushRelayBaseUri,
@@ -46,27 +41,10 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
logger)
{
_deviceRepository = deviceRepository;
- _globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor;
- _timeProvider = timeProvider;
}
- public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
- }
-
- public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable collectionIds)
- {
- await PushCipherAsync(cipher, PushType.SyncCipherUpdate, collectionIds);
- }
-
- public async Task PushSyncCipherDeleteAsync(Cipher cipher)
- {
- await PushCipherAsync(cipher, PushType.SyncLoginDelete, null);
- }
-
- private async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
+ public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable? collectionIds)
{
if (cipher.OrganizationId.HasValue)
{
@@ -87,306 +65,45 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
RevisionDate = cipher.RevisionDate,
};
- await SendPayloadToUserAsync(cipher.UserId.Value, type, message, true);
- }
- }
-
- public async Task PushSyncFolderCreateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderCreate);
- }
-
- public async Task PushSyncFolderUpdateAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderUpdate);
- }
-
- public async Task PushSyncFolderDeleteAsync(Folder folder)
- {
- await PushFolderAsync(folder, PushType.SyncFolderDelete);
- }
-
- private async Task PushFolderAsync(Folder folder, PushType type)
- {
- var message = new SyncFolderPushNotification
- {
- Id = folder.Id,
- UserId = folder.UserId,
- RevisionDate = folder.RevisionDate
- };
-
- await SendPayloadToUserAsync(folder.UserId, type, message, true);
- }
-
- public async Task PushSyncCiphersAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncCiphers);
- }
-
- public async Task PushSyncVaultAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncVault);
- }
-
- public async Task PushSyncOrganizationsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrganizations);
- }
-
- public async Task PushSyncOrgKeysAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncOrgKeys);
- }
-
- public async Task PushSyncSettingsAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.SyncSettings);
- }
-
- public async Task PushLogOutAsync(Guid userId, bool excludeCurrentContext = false)
- {
- await PushUserAsync(userId, PushType.LogOut, excludeCurrentContext);
- }
-
- private async Task PushUserAsync(Guid userId, PushType type, bool excludeCurrentContext = false)
- {
- var message = new UserPushNotification { UserId = userId, Date = _timeProvider.GetUtcNow().UtcDateTime };
-
- await SendPayloadToUserAsync(userId, type, message, excludeCurrentContext);
- }
-
- public async Task PushSyncSendCreateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendCreate);
- }
-
- public async Task PushSyncSendUpdateAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendUpdate);
- }
-
- public async Task PushSyncSendDeleteAsync(Send send)
- {
- await PushSendAsync(send, PushType.SyncSendDelete);
- }
-
- private async Task PushSendAsync(Send send, PushType type)
- {
- if (send.UserId.HasValue)
- {
- var message = new SyncSendPushNotification
+ await PushAsync(new PushNotification
{
- Id = send.Id,
- UserId = send.UserId.Value,
- RevisionDate = send.RevisionDate
- };
-
- await SendPayloadToUserAsync(message.UserId, type, message, true);
+ Type = type,
+ Target = NotificationTarget.User,
+ TargetId = cipher.UserId.Value,
+ Payload = message,
+ ExcludeCurrentContext = true,
+ });
}
}
- public async Task PushAuthRequestAsync(AuthRequest authRequest)
+ public async Task PushAsync(PushNotification pushNotification)
+ where T : class
{
- await PushAuthRequestAsync(authRequest, PushType.AuthRequest);
- }
+ var deviceIdentifier = _httpContextAccessor.HttpContext
+ ?.RequestServices.GetService()
+ ?.DeviceIdentifier;
- public async Task PushAuthRequestResponseAsync(AuthRequest authRequest)
- {
- await PushAuthRequestAsync(authRequest, PushType.AuthRequestResponse);
- }
+ Guid? deviceId = null;
- private async Task PushAuthRequestAsync(AuthRequest authRequest, PushType type)
- {
- var message = new AuthRequestPushNotification { Id = authRequest.Id, UserId = authRequest.UserId };
-
- await SendPayloadToUserAsync(authRequest.UserId, type, message, true);
- }
-
- public async Task PushNotificationAsync(Notification notification)
- {
- var message = new NotificationPushNotification
+ if (!string.IsNullOrEmpty(deviceIdentifier))
{
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = notification.Global ? _globalSettings.Installation.Id : null,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate
+ var device = await _deviceRepository.GetByIdentifierAsync(deviceIdentifier);
+ deviceId = device?.Id;
+ }
+
+ var payload = new PushSendRequestModel
+ {
+ Type = pushNotification.Type,
+ UserId = pushNotification.GetTargetWhen(NotificationTarget.User),
+ OrganizationId = pushNotification.GetTargetWhen(NotificationTarget.Organization),
+ InstallationId = pushNotification.GetTargetWhen(NotificationTarget.Installation),
+ Payload = pushNotification.Payload,
+ Identifier = pushNotification.ExcludeCurrentContext ? deviceIdentifier : null,
+ // We set the device id regardless of if they want to exclude the current context or not
+ DeviceId = deviceId,
+ ClientType = pushNotification.ClientType,
};
- if (notification.Global)
- {
- await SendPayloadToInstallationAsync(PushType.Notification, message, true, notification.ClientType);
- }
- else if (notification.UserId.HasValue)
- {
- await SendPayloadToUserAsync(notification.UserId.Value, PushType.Notification, message, true,
- notification.ClientType);
- }
- else if (notification.OrganizationId.HasValue)
- {
- await SendPayloadToOrganizationAsync(notification.OrganizationId.Value, PushType.Notification, message,
- true, notification.ClientType);
- }
- else
- {
- _logger.LogWarning("Invalid notification id {NotificationId} push notification", notification.Id);
- }
- }
-
- public async Task PushNotificationStatusAsync(Notification notification, NotificationStatus notificationStatus)
- {
- var message = new NotificationPushNotification
- {
- Id = notification.Id,
- Priority = notification.Priority,
- Global = notification.Global,
- ClientType = notification.ClientType,
- UserId = notification.UserId,
- OrganizationId = notification.OrganizationId,
- InstallationId = notification.Global ? _globalSettings.Installation.Id : null,
- TaskId = notification.TaskId,
- Title = notification.Title,
- Body = notification.Body,
- CreationDate = notification.CreationDate,
- RevisionDate = notification.RevisionDate,
- ReadDate = notificationStatus.ReadDate,
- DeletedDate = notificationStatus.DeletedDate
- };
-
- if (notification.Global)
- {
- await SendPayloadToInstallationAsync(PushType.NotificationStatus, message, true, notification.ClientType);
- }
- else if (notification.UserId.HasValue)
- {
- await SendPayloadToUserAsync(notification.UserId.Value, PushType.NotificationStatus, message, true,
- notification.ClientType);
- }
- else if (notification.OrganizationId.HasValue)
- {
- await SendPayloadToOrganizationAsync(notification.OrganizationId.Value, PushType.NotificationStatus, message,
- true, notification.ClientType);
- }
- else
- {
- _logger.LogWarning("Invalid notification status id {NotificationId} push notification", notification.Id);
- }
- }
-
- public async Task PushSyncOrganizationStatusAsync(Organization organization)
- {
- var message = new OrganizationStatusPushNotification
- {
- OrganizationId = organization.Id,
- Enabled = organization.Enabled
- };
-
- await SendPayloadToOrganizationAsync(organization.Id, PushType.SyncOrganizationStatusChanged, message, false);
- }
-
- public async Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) =>
- await SendPayloadToOrganizationAsync(
- organization.Id,
- PushType.SyncOrganizationCollectionSettingChanged,
- new OrganizationCollectionManagementPushNotification
- {
- OrganizationId = organization.Id,
- LimitCollectionCreation = organization.LimitCollectionCreation,
- LimitCollectionDeletion = organization.LimitCollectionDeletion,
- LimitItemDeletion = organization.LimitItemDeletion
- },
- false
- );
-
- public async Task PushPendingSecurityTasksAsync(Guid userId)
- {
- await PushUserAsync(userId, PushType.PendingSecurityTasks);
- }
-
- private async Task SendPayloadToInstallationAsync(PushType type, object payload, bool excludeCurrentContext,
- ClientType? clientType = null)
- {
- var request = new PushSendRequestModel
- {
- InstallationId = _globalSettings.Installation.Id.ToString(),
- Type = type,
- Payload = payload,
- ClientType = clientType
- };
-
- await AddCurrentContextAsync(request, excludeCurrentContext);
- await SendAsync(HttpMethod.Post, "push/send", request);
- }
-
- private async Task SendPayloadToUserAsync(Guid userId, PushType type, object payload, bool excludeCurrentContext,
- ClientType? clientType = null)
- {
- var request = new PushSendRequestModel
- {
- UserId = userId.ToString(),
- Type = type,
- Payload = payload,
- ClientType = clientType
- };
-
- await AddCurrentContextAsync(request, excludeCurrentContext);
- await SendAsync(HttpMethod.Post, "push/send", request);
- }
-
- private async Task SendPayloadToOrganizationAsync(Guid orgId, PushType type, object payload,
- bool excludeCurrentContext, ClientType? clientType = null)
- {
- var request = new PushSendRequestModel
- {
- OrganizationId = orgId.ToString(),
- Type = type,
- Payload = payload,
- ClientType = clientType
- };
-
- await AddCurrentContextAsync(request, excludeCurrentContext);
- await SendAsync(HttpMethod.Post, "push/send", request);
- }
-
- private async Task AddCurrentContextAsync(PushSendRequestModel request, bool addIdentifier)
- {
- var currentContext =
- _httpContextAccessor.HttpContext?.RequestServices.GetService(typeof(ICurrentContext)) as ICurrentContext;
- if (!string.IsNullOrWhiteSpace(currentContext?.DeviceIdentifier))
- {
- var device = await _deviceRepository.GetByIdentifierAsync(currentContext.DeviceIdentifier);
- if (device != null)
- {
- request.DeviceId = device.Id.ToString();
- }
-
- if (addIdentifier)
- {
- request.Identifier = currentContext.DeviceIdentifier;
- }
- }
- }
-
- public Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null) =>
- throw new NotImplementedException();
-
- public Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- throw new NotImplementedException();
- }
-
- public Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier,
- string? deviceId = null, ClientType? clientType = null)
- {
- throw new NotImplementedException();
+ await SendAsync(HttpMethod.Post, "push/send", payload);
}
}
diff --git a/src/Core/Services/Implementations/SendGridMailDeliveryService.cs b/src/Core/Services/Implementations/SendGridMailDeliveryService.cs
index ea915b56f2..773f87931d 100644
--- a/src/Core/Services/Implementations/SendGridMailDeliveryService.cs
+++ b/src/Core/Services/Implementations/SendGridMailDeliveryService.cs
@@ -21,7 +21,7 @@ public class SendGridMailDeliveryService : IMailDeliveryService, IDisposable
GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment,
ILogger logger)
- : this(new SendGridClient(globalSettings.Mail.SendGridApiKey),
+ : this(new SendGridClient(globalSettings.Mail.SendGridApiKey, globalSettings.Mail.SendGridApiHost),
globalSettings, hostingEnvironment, logger)
{
}
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index f08d66c28f..7a794ec3f6 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -431,6 +431,7 @@ public class GlobalSettings : IGlobalSettings
public SmtpSettings Smtp { get; set; } = new SmtpSettings();
public string SendGridApiKey { get; set; }
public int? SendGridPercentage { get; set; }
+ public string SendGridApiHost { get; set; } = "https://api.sendgrid.com";
public class SmtpSettings
{
diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs
index f67a2550d2..9fc6238143 100644
--- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs
+++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs
@@ -56,7 +56,7 @@ public class ImportCiphersCommand : IImportCiphersCommand
{
// Make sure the user can save new ciphers to their personal vault
var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
- ? (await _policyRequirementQuery.GetAsync(importingUserId)).DisablePersonalOwnership
+ ? (await _policyRequirementQuery.GetAsync(importingUserId)).State == PersonalOwnershipState.Restricted
: await _policyService.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership);
if (isPersonalVaultRestricted)
diff --git a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs
index 87b4e581ca..804200a05f 100644
--- a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs
+++ b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs
@@ -8,6 +8,7 @@ using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
+using Microsoft.Extensions.Logging;
namespace Bit.Core.Tools.SendFeatures.Commands;
@@ -18,19 +19,22 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
private readonly IPushNotificationService _pushNotificationService;
private readonly ISendValidationService _sendValidationService;
private readonly ISendCoreHelperService _sendCoreHelperService;
+ private readonly ILogger _logger;
public NonAnonymousSendCommand(ISendRepository sendRepository,
ISendFileStorageService sendFileStorageService,
IPushNotificationService pushNotificationService,
ISendAuthorizationService sendAuthorizationService,
ISendValidationService sendValidationService,
- ISendCoreHelperService sendCoreHelperService)
+ ISendCoreHelperService sendCoreHelperService,
+ ILogger logger)
{
_sendRepository = sendRepository;
_sendFileStorageService = sendFileStorageService;
_pushNotificationService = pushNotificationService;
_sendValidationService = sendValidationService;
_sendCoreHelperService = sendCoreHelperService;
+ _logger = logger;
}
public async Task SaveSendAsync(Send send)
@@ -63,6 +67,11 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
throw new BadRequestException("No file data.");
}
+ if (fileLength > SendFileSettingHelper.MAX_FILE_SIZE)
+ {
+ throw new BadRequestException($"Max file size is {SendFileSettingHelper.MAX_FILE_SIZE_READABLE}.");
+ }
+
var storageBytesRemaining = await _sendValidationService.StorageRemainingForSendAsync(send);
if (storageBytesRemaining < fileLength)
@@ -77,13 +86,17 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
data.Id = fileId;
data.Size = fileLength;
data.Validated = false;
- send.Data = JsonSerializer.Serialize(data,
- JsonHelpers.IgnoreWritingNull);
+ send.Data = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull);
await SaveSendAsync(send);
return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId);
}
catch
{
+ _logger.LogWarning(
+ "Deleted file from {SendId} because an error occurred when creating the upload URL.",
+ send.Id
+ );
+
// Clean up since this is not transactional
await _sendFileStorageService.DeleteFileAsync(send, fileId);
throw;
@@ -135,23 +148,31 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
{
var fileData = JsonSerializer.Deserialize(send.Data);
- var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY);
+ var minimum = fileData.Size - SendFileSettingHelper.FILE_SIZE_LEEWAY;
+ var maximum = Math.Min(
+ fileData.Size + SendFileSettingHelper.FILE_SIZE_LEEWAY,
+ SendFileSettingHelper.MAX_FILE_SIZE
+ );
+ var (valid, size) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, minimum, maximum);
- if (!valid || realSize > SendFileSettingHelper.FILE_SIZE_LEEWAY)
+ // protect file service from upload hijacking by deleting invalid sends
+ if (!valid)
{
- // File reported differs in size from that promised. Must be a rogue client. Delete Send
+ _logger.LogWarning(
+ "Deleted {SendId} because its reported size {Size} was outside the expected range ({Minimum} - {Maximum}).",
+ send.Id,
+ size,
+ minimum,
+ maximum
+ );
await DeleteSendAsync(send);
return false;
}
- // Update Send data if necessary
- if (realSize != fileData.Size)
- {
- fileData.Size = realSize.Value;
- }
+ // replace expected size with validated size
+ fileData.Size = size;
fileData.Validated = true;
- send.Data = JsonSerializer.Serialize(fileData,
- JsonHelpers.IgnoreWritingNull);
+ send.Data = JsonSerializer.Serialize(fileData, JsonHelpers.IgnoreWritingNull);
await SaveSendAsync(send);
return valid;
diff --git a/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs b/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs
index 09f3be29e7..ee54ffd6b6 100644
--- a/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs
+++ b/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs
@@ -88,7 +88,7 @@ public class AzureSendFileStorageService : ISendFileStorageService
return sasUri.ToString();
}
- public async Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
+ public async Task<(bool, long)> ValidateFileAsync(Send send, string fileId, long minimum, long maximum)
{
await InitAsync();
@@ -116,17 +116,14 @@ public class AzureSendFileStorageService : ISendFileStorageService
await blobClient.SetHttpHeadersAsync(headers);
var length = blobProperties.Value.ContentLength;
- if (length < expectedFileSize - leeway || length > expectedFileSize + leeway)
- {
- return (false, length);
- }
+ var valid = minimum <= length || length <= maximum;
- return (true, length);
+ return (valid, length);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Unhandled error in ValidateFileAsync");
- return (false, null);
+ _logger.LogError(ex, $"A storage operation failed in {nameof(ValidateFileAsync)}");
+ return (false, -1);
}
}
diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs
index 29bc0c6a6a..8712d07d48 100644
--- a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs
+++ b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs
@@ -56,16 +56,13 @@ public interface ISendFileStorageService
///
/// used to help validate file
/// File id to identify which file to validate
- /// Expected file size of the file
- ///
- /// Send file size tolerance in bytes. When an uploaded file's `expectedFileSize`
- /// is outside of the leeway, the storage operation fails.
- ///
- ///
- /// ❌ Fill this in with an explanation of the error thrown when `leeway` is incorrect
- ///
- /// Task object for async operations with Tuple of boolean that determines if file was valid and long that
- /// the actual file size of the file.
+ /// The minimum allowed length of the stored file in bytes.
+ /// The maximuim allowed length of the stored file in bytes
+ ///
+ /// A task that completes when validation is finished. The first element of the tuple is
+ /// when validation succeeded, and false otherwise. The second element
+ /// of the tuple contains the observed file length in bytes. If an error occurs during validation,
+ /// this returns `-1`.
///
- Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway);
+ Task<(bool valid, long length)> ValidateFileAsync(Send send, string fileId, long minimum, long maximum);
}
diff --git a/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs b/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs
index c205028d9e..a6b3fb0faf 100644
--- a/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs
+++ b/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs
@@ -85,9 +85,9 @@ public class LocalSendStorageService : ISendFileStorageService
public Task GetSendFileUploadUrlAsync(Send send, string fileId)
=> Task.FromResult($"/sends/{send.Id}/file/{fileId}");
- public Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
+ public Task<(bool, long)> ValidateFileAsync(Send send, string fileId, long minimum, long maximum)
{
- long? length = null;
+ long length = -1;
var path = FilePath(send, fileId);
if (!File.Exists(path))
{
@@ -95,11 +95,7 @@ public class LocalSendStorageService : ISendFileStorageService
}
length = new FileInfo(path).Length;
- if (expectedFileSize < length - leeway || expectedFileSize > length + leeway)
- {
- return Task.FromResult((false, length));
- }
-
- return Task.FromResult((true, length));
+ var valid = minimum < length || length < maximum;
+ return Task.FromResult((valid, length));
}
}
diff --git a/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs b/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs
index 4fb841e7a3..16c20e521e 100644
--- a/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs
+++ b/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs
@@ -37,8 +37,8 @@ public class NoopSendFileStorageService : ISendFileStorageService
return Task.FromResult((string)null);
}
- public Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway)
+ public Task<(bool, long)> ValidateFileAsync(Send send, string fileId, long minimum, long maximum)
{
- return Task.FromResult((false, default(long?)));
+ return Task.FromResult((false, -1L));
}
}
diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs
index 5d17441024..5fa27039c2 100644
--- a/src/Core/Vault/Services/Implementations/CipherService.cs
+++ b/src/Core/Vault/Services/Implementations/CipherService.cs
@@ -143,7 +143,7 @@ public class CipherService : ICipherService
else
{
var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
- ? (await _policyRequirementQuery.GetAsync(savingUserId)).DisablePersonalOwnership
+ ? (await _policyRequirementQuery.GetAsync(savingUserId)).State == PersonalOwnershipState.Restricted
: await _policyService.AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership);
if (isPersonalVaultRestricted)
diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
index a95c2bd4c6..e64eabd5bf 100644
--- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
+++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs
@@ -70,6 +70,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
if (selfHosted)
{
diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationMemberBaseDetailRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationMemberBaseDetailRepository.cs
new file mode 100644
index 0000000000..458e72f996
--- /dev/null
+++ b/src/Infrastructure.Dapper/Dirt/OrganizationMemberBaseDetailRepository.cs
@@ -0,0 +1,39 @@
+using System.Data;
+using Bit.Core.Dirt.Reports.Models.Data;
+using Bit.Core.Dirt.Reports.Repositories;
+using Bit.Core.Settings;
+using Bit.Infrastructure.Dapper.Repositories;
+using Dapper;
+using Microsoft.Data.SqlClient;
+
+namespace Bit.Infrastructure.Dapper.Dirt;
+
+public class OrganizationMemberBaseDetailRepository : BaseRepository, IOrganizationMemberBaseDetailRepository
+{
+ public OrganizationMemberBaseDetailRepository(GlobalSettings globalSettings)
+ : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
+ {
+ }
+
+ public OrganizationMemberBaseDetailRepository(string connectionString, string readOnlyConnectionString) : base(
+ connectionString, readOnlyConnectionString)
+ {
+ }
+
+ public async Task> GetOrganizationMemberBaseDetailsByOrganizationId(
+ Guid organizationId)
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+
+
+ var result = await connection.QueryAsync(
+ "[dbo].[MemberAccessReport_GetMemberAccessCipherDetailsByOrganizationId]",
+ new
+ {
+ OrganizationId = organizationId
+
+ }, commandType: CommandType.StoredProcedure);
+
+ return result;
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/Dirt/OrganizationMemberBaseDetailRepository.cs b/src/Infrastructure.EntityFramework/Dirt/OrganizationMemberBaseDetailRepository.cs
new file mode 100644
index 0000000000..123379da90
--- /dev/null
+++ b/src/Infrastructure.EntityFramework/Dirt/OrganizationMemberBaseDetailRepository.cs
@@ -0,0 +1,32 @@
+using AutoMapper;
+using Bit.Core.Dirt.Reports.Models.Data;
+using Bit.Core.Dirt.Reports.Repositories;
+using Bit.Infrastructure.EntityFramework.Repositories;
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Infrastructure.EntityFramework.Dirt;
+
+public class OrganizationMemberBaseDetailRepository : BaseEntityFrameworkRepository, IOrganizationMemberBaseDetailRepository
+{
+ public OrganizationMemberBaseDetailRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) : base(
+ serviceScopeFactory,
+ mapper)
+ {
+ }
+
+ public async Task> GetOrganizationMemberBaseDetailsByOrganizationId(
+ Guid organizationId)
+ {
+ await using var scope = ServiceScopeFactory.CreateAsyncScope();
+ var dbContext = GetDatabaseContext(scope);
+
+ var result = await dbContext.Set()
+ .FromSqlRaw("EXEC [dbo].[MemberAccessReport_GetMemberAccessCipherDetailsByOrganizationId] @OrganizationId",
+ new SqlParameter("@OrganizationId", organizationId))
+ .ToListAsync();
+
+ return result;
+ }
+}
diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
index 321c4c90e5..616b2bc434 100644
--- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
+++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs
@@ -14,6 +14,7 @@ using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Bit.Infrastructure.EntityFramework.Auth.Repositories;
using Bit.Infrastructure.EntityFramework.Billing.Repositories;
+using Bit.Infrastructure.EntityFramework.Dirt;
using Bit.Infrastructure.EntityFramework.Dirt.Repositories;
using Bit.Infrastructure.EntityFramework.KeyManagement.Repositories;
using Bit.Infrastructure.EntityFramework.NotificationCenter.Repositories;
@@ -107,6 +108,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
if (selfHosted)
{
diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
index 647b3e3ab1..e1e29cbf41 100644
--- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
@@ -1,4 +1,5 @@
using Bit.Core;
+using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Infrastructure.EntityFramework.Auth.Models;
@@ -80,6 +81,7 @@ public class DatabaseContext : DbContext
public DbSet NotificationStatuses { get; set; }
public DbSet ClientOrganizationMigrationRecords { get; set; }
public DbSet PasswordHealthReportApplications { get; set; }
+ public DbSet OrganizationMemberBaseDetails { get; set; }
public DbSet SecurityTasks { get; set; }
public DbSet OrganizationInstallations { get; set; }
@@ -112,6 +114,7 @@ public class DatabaseContext : DbContext
var eOrganizationConnection = builder.Entity();
var eOrganizationDomain = builder.Entity();
var aWebAuthnCredential = builder.Entity();
+ var eOrganizationMemberBaseDetail = builder.Entity();
// Shadow property configurations go here
@@ -134,6 +137,8 @@ public class DatabaseContext : DbContext
eCollectionGroup.HasKey(cg => new { cg.CollectionId, cg.GroupId });
eGroupUser.HasKey(gu => new { gu.GroupId, gu.OrganizationUserId });
+ eOrganizationMemberBaseDetail.HasNoKey();
+
var dataProtector = this.GetService().CreateProtector(
Constants.DatabaseFieldProtectorPurpose);
var dataProtectionConverter = new DataProtectionConverter(dataProtector);
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index 5451400803..83015354bb 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -5,7 +5,7 @@ using System.Security.Cryptography.X509Certificates;
using AspNetCoreRateLimit;
using Azure.Storage.Queues;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
-using Bit.Core.AdminConsole.Models.Data.Integrations;
+using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.Implementations;
@@ -288,7 +288,7 @@ public static class ServiceCollectionExtensions
if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) &&
CoreHelpers.SettingHasValue(globalSettings.Installation.Key))
{
- services.AddKeyedSingleton("implementation");
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.AddSingleton();
}
else
@@ -299,20 +299,20 @@ public static class ServiceCollectionExtensions
if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) &&
CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications))
{
- services.AddKeyedSingleton("implementation");
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
}
}
else
{
services.AddSingleton();
services.AddSingleton();
- services.AddKeyedSingleton("implementation");
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.TryAddSingleton();
if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString))
{
services.AddKeyedSingleton("notifications",
(_, _) => new QueueClient(globalSettings.Notifications.ConnectionString, "notifications"));
- services.AddKeyedSingleton(
- "implementation");
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
}
}
@@ -366,7 +366,6 @@ public static class ServiceCollectionExtensions
{
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -718,7 +717,8 @@ public static class ServiceCollectionExtensions
retryQueueName: integrationRetryQueueName,
maxRetries: maxRetries,
rabbitMqService: provider.GetRequiredService(),
- logger: provider.GetRequiredService>()));
+ logger: provider.GetRequiredService>(),
+ timeProvider: provider.GetRequiredService()));
return services;
}
diff --git a/src/Sql/Dirt/Stored Procedure/MemberAccessDetail_GetMemberAccessDetailByOrganizationId.sql b/src/Sql/Dirt/Stored Procedure/MemberAccessDetail_GetMemberAccessDetailByOrganizationId.sql
new file mode 100644
index 0000000000..1aaf667f6a
--- /dev/null
+++ b/src/Sql/Dirt/Stored Procedure/MemberAccessDetail_GetMemberAccessDetailByOrganizationId.sql
@@ -0,0 +1,92 @@
+CREATE PROCEDURE dbo.MemberAccessReport_GetMemberAccessCipherDetailsByOrganizationId
+ @OrganizationId UNIQUEIDENTIFIER
+AS
+ SET NOCOUNT ON;
+
+IF @OrganizationId IS NULL
+ THROW 50000, 'OrganizationId cannot be null', 1;
+
+ SELECT
+ OU.Id AS UserGuid,
+ U.Name AS UserName,
+ ISNULL(U.Email, OU.Email) as 'Email',
+ U.TwoFactorProviders,
+ U.UsesKeyConnector,
+ OU.ResetPasswordKey,
+ CC.CollectionId,
+ C.Name AS CollectionName,
+ NULL AS GroupId,
+ NULL AS GroupName,
+ CU.ReadOnly,
+ CU.HidePasswords,
+ CU.Manage,
+ Cipher.Id AS CipherId
+ FROM dbo.OrganizationUser OU
+ LEFT JOIN dbo.[User] U ON U.Id = OU.UserId
+ INNER JOIN dbo.Organization O ON O.Id = OU.OrganizationId
+ AND O.Id = @OrganizationId
+ AND O.Enabled = 1
+ INNER JOIN dbo.CollectionUser CU ON CU.OrganizationUserId = OU.Id
+ INNER JOIN dbo.Collection C ON C.Id = CU.CollectionId and C.OrganizationId = @OrganizationId
+ INNER JOIN dbo.CollectionCipher CC ON CC.CollectionId = C.Id
+ INNER JOIN dbo.Cipher Cipher ON Cipher.Id = CC.CipherId AND Cipher.OrganizationId = @OrganizationId
+ WHERE OU.Status IN (0,1,2) -- Invited, Accepted and Confirmed Users
+ AND Cipher.DeletedDate IS NULL
+UNION ALL
+ -- Group-based collection permissions
+ SELECT
+ OU.Id AS UserGuid,
+ U.Name AS UserName,
+ ISNULL(U.Email, OU.Email) as 'Email',
+ U.TwoFactorProviders,
+ U.UsesKeyConnector,
+ OU.ResetPasswordKey,
+ CC.CollectionId,
+ C.Name AS CollectionName,
+ G.Id AS GroupId,
+ G.Name AS GroupName,
+ CG.ReadOnly,
+ CG.HidePasswords,
+ CG.Manage,
+ Cipher.Id AS CipherId
+ FROM dbo.OrganizationUser OU
+ LEFT JOIN dbo.[User] U ON U.Id = OU.UserId
+ INNER JOIN dbo.Organization O ON O.Id = OU.OrganizationId
+ AND O.Id = @OrganizationId
+ AND O.Enabled = 1
+ INNER JOIN dbo.GroupUser GU ON GU.OrganizationUserId = OU.Id
+ INNER JOIN dbo.[Group] G ON G.Id = GU.GroupId
+ INNER JOIN dbo.CollectionGroup CG ON CG.GroupId = G.Id
+ INNER JOIN dbo.Collection C ON C.Id = CG.CollectionId AND C.OrganizationId = @OrganizationId
+ INNER JOIN dbo.CollectionCipher CC ON CC.CollectionId = C.Id
+ INNER JOIN dbo.Cipher Cipher ON Cipher.Id = CC.CipherId and Cipher.OrganizationId = @OrganizationId
+ WHERE OU.Status IN (0,1,2) -- Invited, Accepted and Confirmed Users
+ AND Cipher.DeletedDate IS NULL
+UNION ALL
+ -- Users without collection access (invited users)
+ -- typically invited users who have not yet accepted the invitation
+ -- and not yet assigned to any collection
+ SELECT
+ OU.Id AS UserGuid,
+ U.Name AS UserName,
+ ISNULL(U.Email, OU.Email) as 'Email',
+ U.TwoFactorProviders,
+ U.UsesKeyConnector,
+ OU.ResetPasswordKey,
+ null as CollectionId,
+ null AS CollectionName,
+ NULL AS GroupId,
+ NULL AS GroupName,
+ null as [ReadOnly],
+ null as HidePasswords,
+ null as Manage,
+ null AS CipherId
+ FROM dbo.OrganizationUser OU
+ LEFT JOIN dbo.[User] U ON U.Id = OU.UserId
+ INNER JOIN dbo.Organization O ON O.Id = OU.OrganizationId AND O.Id = @OrganizationId AND O.Enabled = 1
+ WHERE OU.Status IN (0,1,2) -- Invited, Accepted and Confirmed Users
+ AND OU.Id not in (
+ select OU1.Id from dbo.OrganizationUser OU1
+ inner join dbo.CollectionUser CU1 on CU1.OrganizationUserId = OU1.Id
+ WHERE OU1.OrganizationId = @organizationId
+ )
diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs
index 08c5973936..173580ad8c 100644
--- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs
+++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs
@@ -28,6 +28,8 @@ public class ApiApplicationFactory : WebApplicationFactoryBase
_identityApplicationFactory.ManagesDatabase = false;
}
+ public IdentityApplicationFactory Identity => _identityApplicationFactory;
+
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
diff --git a/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs b/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs
new file mode 100644
index 0000000000..4d86817a11
--- /dev/null
+++ b/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs
@@ -0,0 +1,449 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Azure.Storage.Queues;
+using Bit.Api.IntegrationTest.Factories;
+using Bit.Core.Enums;
+using Bit.Core.Models;
+using Bit.Core.Models.Api;
+using Bit.Core.Models.Data;
+using Bit.Core.NotificationHub;
+using Bit.Core.Platform.Installations;
+using Bit.Core.Repositories;
+using NSubstitute;
+using Xunit;
+using static Bit.Core.Settings.GlobalSettings;
+
+namespace Bit.Api.IntegrationTest.Platform.Controllers;
+
+public class PushControllerTests
+{
+ private static readonly Guid _userId = Guid.NewGuid();
+ private static readonly Guid _organizationId = Guid.NewGuid();
+ private static readonly Guid _deviceId = Guid.NewGuid();
+
+ public static IEnumerable