1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-20 02:48:03 -05:00

Merge branch 'km/feature-signing-keys' into km/signing-api-changes

This commit is contained in:
Bernd Schoolmann 2025-06-18 20:07:01 +02:00 committed by GitHub
commit 49c130f91f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
116 changed files with 12583 additions and 3007 deletions

View File

@ -22,6 +22,8 @@ on:
required: false required: false
type: string type: string
permissions: {}
jobs: jobs:
setup: setup:
name: Setup name: Setup
@ -44,49 +46,11 @@ jobs:
echo "branch=$BRANCH" >> $GITHUB_OUTPUT 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: bump_version:
name: Bump Version name: Bump Version
if: ${{ always() }} if: ${{ always() }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- cut_branch
- setup - setup
outputs: outputs:
version: ${{ steps.set-final-version-output.outputs.version }} version: ${{ steps.set-final-version-output.outputs.version }}
@ -187,14 +151,13 @@ jobs:
- name: Push changes - name: Push changes
run: git push run: git push
cut_branch:
cherry_pick: name: Cut branch
name: Cherry-Pick Commit(s)
if: ${{ needs.setup.outputs.branch != 'none' }} if: ${{ needs.setup.outputs.branch != 'none' }}
runs-on: ubuntu-24.04
needs: needs:
- bump_version
- setup - setup
- bump_version
runs-on: ubuntu-24.04
steps: steps:
- name: Generate GH App token - name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
@ -203,78 +166,30 @@ jobs:
app-id: ${{ secrets.BW_GHAPP_ID }} app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }} private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out main branch - name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 ref: ${{ inputs.target_ref }}
ref: main
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Configure Git - name: Check if ${{ needs.setup.outputs.branch }} branch exists
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)
env: env:
CUT_BRANCH: ${{ needs.setup.outputs.branch }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
# Function for cherry-picking if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
cherry_pick () { echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
local source_branch=$1 exit 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
fi 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: move_future_db_scripts:
name: Move finalization database scripts name: Move finalization database scripts
needs: cherry_pick needs: cut_branch
uses: ./.github/workflows/_move_finalization_db_scripts.yml uses: ./.github/workflows/_move_finalization_db_scripts.yml
secrets: inherit secrets: inherit

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.6.1</Version> <Version>2025.6.2</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -403,16 +403,15 @@ public class OrganizationUsersController : Controller
} }
[HttpPost("{id}/confirm")] [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(orgId))
if (!await _currentContext.ManageUsers(orgGuidId))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var userId = _userService.GetProperUserId(User); 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")] [HttpPost("confirm")]

View File

@ -2,7 +2,7 @@
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; 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.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
#nullable enable #nullable enable

View File

@ -60,6 +60,10 @@ public class OrganizationUserConfirmRequestModel
{ {
[Required] [Required]
public string Key { get; set; } public string Key { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string DefaultUserCollectionName { get; set; }
} }
public class OrganizationUserBulkConfirmRequestModelEntry public class OrganizationUserBulkConfirmRequestModelEntry

View File

@ -1,5 +1,6 @@
using Bit.Api.Dirt.Models; using Bit.Api.Dirt.Models;
using Bit.Api.Dirt.Models.Response; using Bit.Api.Dirt.Models.Response;
using Bit.Api.Tools.Models.Response;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Dirt.Reports.Entities; using Bit.Core.Dirt.Reports.Entities;
using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Reports.Models.Data;
@ -17,21 +18,24 @@ namespace Bit.Api.Dirt.Controllers;
public class ReportsController : Controller public class ReportsController : Controller
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery; private readonly IMemberAccessReportQuery _memberAccessReportQuery;
private readonly IRiskInsightsReportQuery _riskInsightsReportQuery;
private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand; private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand;
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand; private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand;
public ReportsController( public ReportsController(
ICurrentContext currentContext, ICurrentContext currentContext,
IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery, IMemberAccessReportQuery memberAccessReportQuery,
IRiskInsightsReportQuery riskInsightsReportQuery,
IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand, IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand,
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery, IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery,
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand
) )
{ {
_currentContext = currentContext; _currentContext = currentContext;
_memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery; _memberAccessReportQuery = memberAccessReportQuery;
_riskInsightsReportQuery = riskInsightsReportQuery;
_addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand; _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand;
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery; _getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand; _dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
@ -54,9 +58,9 @@ public class ReportsController : Controller
throw new NotFoundException(); 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; return responses;
} }
@ -69,16 +73,16 @@ public class ReportsController : Controller
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns> /// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception> /// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-access/{orgId}")] [HttpGet("member-access/{orgId}")]
public async Task<IEnumerable<MemberAccessReportResponseModel>> GetMemberAccessReport(Guid orgId) public async Task<IEnumerable<MemberAccessDetailReportResponseModel>> GetMemberAccessReport(Guid orgId)
{ {
if (!await _currentContext.AccessReports(orgId)) if (!await _currentContext.AccessReports(orgId))
{ {
throw new NotFoundException(); 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; return responses;
} }
@ -87,13 +91,28 @@ public class ReportsController : Controller
/// Contains the organization member info, the cipher ids associated with the member, /// Contains the organization member info, the cipher ids associated with the member,
/// and details on their collections, groups, and permissions /// and details on their collections, groups, and permissions
/// </summary> /// </summary>
/// <param name="request">Request to the MemberAccessCipherDetailsQuery</param> /// <param name="request">Request parameters</param>
/// <returns>IEnumerable of MemberAccessCipherDetails</returns> /// <returns>
private async Task<IEnumerable<MemberAccessCipherDetails>> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request) /// List of a user's permissions at a group and collection level as well as the number of ciphers
/// associated with that group/collection
/// </returns>
private async Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessDetails(
MemberAccessReportRequest request)
{ {
var memberCipherDetails = var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request);
await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request); return accessDetails;
return memberCipherDetails; }
/// <summary>
/// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids
/// </summary>
/// <param name="request">Request parameters</param>
/// <returns>A list of risk insights data associating the user to cipher ids</returns>
private async Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(
RiskInsightsReportRequest request)
{
var riskDetails = await _riskInsightsReportQuery.GetRiskInsightsReportDetails(request);
return riskDetails;
} }
/// <summary> /// <summary>

View File

@ -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<Guid> 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;
}
}

View File

@ -15,12 +15,12 @@ public class MemberCipherDetailsResponseModel
/// </summary> /// </summary>
public IEnumerable<string> CipherIds { get; set; } public IEnumerable<string> CipherIds { get; set; }
public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) public MemberCipherDetailsResponseModel(RiskInsightsReportDetail reportDetail)
{ {
this.UserGuid = memberAccessCipherDetails.UserGuid; this.UserGuid = reportDetail.UserGuid;
this.UserName = memberAccessCipherDetails.UserName; this.UserName = reportDetail.UserName;
this.Email = memberAccessCipherDetails.Email; this.Email = reportDetail.Email;
this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector; this.UsesKeyConnector = reportDetail.UsesKeyConnector;
this.CipherIds = memberAccessCipherDetails.CipherIds; this.CipherIds = reportDetail.CipherIds;
} }
} }

View File

@ -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.Exceptions;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.NotificationHub; using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -20,14 +23,14 @@ namespace Bit.Api.Platform.Push;
public class PushController : Controller public class PushController : Controller
{ {
private readonly IPushRegistrationService _pushRegistrationService; private readonly IPushRegistrationService _pushRegistrationService;
private readonly IPushNotificationService _pushNotificationService; private readonly IPushRelayer _pushRelayer;
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
public PushController( public PushController(
IPushRegistrationService pushRegistrationService, IPushRegistrationService pushRegistrationService,
IPushNotificationService pushNotificationService, IPushRelayer pushRelayer,
IWebHostEnvironment environment, IWebHostEnvironment environment,
ICurrentContext currentContext, ICurrentContext currentContext,
IGlobalSettings globalSettings) IGlobalSettings globalSettings)
@ -35,7 +38,7 @@ public class PushController : Controller
_currentContext = currentContext; _currentContext = currentContext;
_environment = environment; _environment = environment;
_pushRegistrationService = pushRegistrationService; _pushRegistrationService = pushRegistrationService;
_pushNotificationService = pushNotificationService; _pushRelayer = pushRelayer;
_globalSettings = globalSettings; _globalSettings = globalSettings;
} }
@ -74,31 +77,50 @@ public class PushController : Controller
} }
[HttpPost("send")] [HttpPost("send")]
public async Task SendAsync([FromBody] PushSendRequestModel model) public async Task SendAsync([FromBody] PushSendRequestModel<JsonElement> model)
{ {
CheckUsage(); 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."); throw new BadRequestException("InstallationId does not match current context.");
} }
await _pushNotificationService.SendPayloadToInstallationAsync( target = NotificationTarget.Installation;
_currentContext.InstallationId.Value.ToString(), model.Type, model.Payload, Prefix(model.Identifier), targetId = _currentContext.InstallationId.Value;
Prefix(model.DeviceId), model.ClientType);
} }
else if (!string.IsNullOrWhiteSpace(model.UserId)) else if (model.UserId.HasValue)
{ {
await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId), target = NotificationTarget.User;
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType); targetId = model.UserId.Value;
} }
else if (!string.IsNullOrWhiteSpace(model.OrganizationId)) else if (model.OrganizationId.HasValue)
{ {
await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId), target = NotificationTarget.Organization;
model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType); 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) private string Prefix(string value)

View File

@ -2,7 +2,7 @@
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public interface IIntegrationMessage public interface IIntegrationMessage
{ {

View File

@ -1,6 +1,6 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationHandlerResult public class IntegrationHandlerResult
{ {

View File

@ -3,7 +3,7 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationMessage : IIntegrationMessage public class IntegrationMessage : IIntegrationMessage
{ {

View File

@ -5,7 +5,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class IntegrationTemplateContext(EventMessage eventMessage) public class IntegrationTemplateContext(EventMessage eventMessage)
{ {

View File

@ -1,5 +1,5 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegration(string token); public record SlackIntegration(string token);

View File

@ -1,5 +1,5 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfiguration(string channelId); public record SlackIntegrationConfiguration(string channelId);

View File

@ -1,5 +1,5 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfigurationDetails(string channelId, string token); public record SlackIntegrationConfigurationDetails(string channelId, string token);

View File

@ -1,5 +1,5 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfiguration(string url); public record WebhookIntegrationConfiguration(string url);

View File

@ -1,5 +1,5 @@
#nullable enable #nullable enable
namespace Bit.Core.AdminConsole.Models.Data.Integrations; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfigurationDetails(string url); public record WebhookIntegrationConfigurationDetails(string url);

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -28,6 +29,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ICollectionRepository _collectionRepository;
public ConfirmOrganizationUserCommand( public ConfirmOrganizationUserCommand(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -41,7 +43,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
IPolicyService policyService, IPolicyService policyService,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService) IFeatureService featureService,
ICollectionRepository collectionRepository)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -55,10 +58,11 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_featureService = featureService; _featureService = featureService;
_collectionRepository = collectionRepository;
} }
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
Guid confirmingUserId) Guid confirmingUserId, string defaultUserCollectionName = null)
{ {
var result = await ConfirmUsersAsync( var result = await ConfirmUsersAsync(
organizationId, organizationId,
@ -75,6 +79,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
{ {
throw new BadRequestException(error); throw new BadRequestException(error);
} }
await HandleConfirmationSideEffectsAsync(organizationId, orgUser, defaultUserCollectionName);
return orgUser; return orgUser;
} }
@ -213,4 +220,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) .Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString()); .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<bool> 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<PersonalOwnershipPolicyRequirement>(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<CollectionAccessSelection>
{
new CollectionAccessSelection
{
Id = organizationUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true
}
};
await _collectionRepository.CreateAsync(collection, groups: null, users: userAccess);
}
} }

View File

@ -15,9 +15,10 @@ public interface IConfirmOrganizationUserCommand
/// <param name="organizationUserId">The ID of the organization user to confirm.</param> /// <param name="organizationUserId">The ID of the organization user to confirm.</param>
/// <param name="key">The encrypted organization key for the user.</param> /// <param name="key">The encrypted organization key for the user.</param>
/// <param name="confirmingUserId">The ID of the user performing the confirmation.</param> /// <param name="confirmingUserId">The ID of the user performing the confirmation.</param>
/// <param name="defaultUserCollectionName">Optional encrypted collection name for creating a default collection.</param>
/// <returns>The confirmed organization user.</returns> /// <returns>The confirmed organization user.</returns>
/// <exception cref="BadRequestException">Thrown when the user is not valid or cannot be confirmed.</exception> /// <exception cref="BadRequestException">Thrown when the user is not valid or cannot be confirmed.</exception>
Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null);
/// <summary> /// <summary>
/// Confirms multiple organization users who have accepted their invitations. /// Confirms multiple organization users who have accepted their invitations.

View File

@ -3,15 +3,55 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
/// <summary>
/// Represents the personal ownership policy state.
/// </summary>
public enum PersonalOwnershipState
{
/// <summary>
/// Personal ownership is allowed - users can save items to their personal vault.
/// </summary>
Allowed,
/// <summary>
/// Personal ownership is restricted - members are required to save items to an organization.
/// </summary>
Restricted
}
/// <summary> /// <summary>
/// Policy requirements for the Disable Personal Ownership policy. /// Policy requirements for the Disable Personal Ownership policy.
/// </summary> /// </summary>
public class PersonalOwnershipPolicyRequirement : IPolicyRequirement public class PersonalOwnershipPolicyRequirement : IPolicyRequirement
{ {
private readonly IEnumerable<Guid> _organizationIdsWithPolicyEnabled;
/// <param name="personalOwnershipState">
/// The personal ownership state for the user.
/// </param>
/// <param name="organizationIdsWithPolicyEnabled">
/// The collection of Organization IDs that have the Disable Personal Ownership policy enabled.
/// </param>
public PersonalOwnershipPolicyRequirement(
PersonalOwnershipState personalOwnershipState,
IEnumerable<Guid> organizationIdsWithPolicyEnabled)
{
_organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? [];
State = personalOwnershipState;
}
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
public bool DisablePersonalOwnership { get; init; } public PersonalOwnershipState State { get; }
/// <summary>
/// Returns true if the Disable Personal Ownership policy is enforced in that organization.
/// </summary>
public bool RequiresDefaultCollection(Guid organizationId)
{
return _organizationIdsWithPolicyEnabled.Contains(organizationId);
}
} }
public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory<PersonalOwnershipPolicyRequirement> public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory<PersonalOwnershipPolicyRequirement>
@ -20,7 +60,13 @@ public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFa
public override PersonalOwnershipPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails) public override PersonalOwnershipPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{ {
var result = new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = policyDetails.Any() }; var personalOwnershipState = policyDetails.Any()
return result; ? PersonalOwnershipState.Restricted
: PersonalOwnershipState.Allowed;
var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet();
return new PersonalOwnershipPolicyRequirement(
personalOwnershipState,
organizationIdsWithPolicyEnabled);
} }
} }

View File

@ -1,5 +1,5 @@
using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using RabbitMQ.Client; using RabbitMQ.Client;
using RabbitMQ.Client.Events; using RabbitMQ.Client.Events;

View File

@ -33,6 +33,13 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
await _processor.StartProcessingAsync(cancellationToken); 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) internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
{ {
_logger.LogError( _logger.LogError(
@ -49,16 +56,4 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId); await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId);
await args.CompleteMessageAsync(args.Message); 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();
}
} }

View File

@ -1,5 +1,5 @@
using Azure.Messaging.ServiceBus; 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.Enums;
using Bit.Core.Settings; using Bit.Core.Settings;

View File

@ -1,7 +1,7 @@
#nullable enable #nullable enable
using System.Text.Json; 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.AdminConsole.Utilities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;

View File

@ -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<T>` where `<T>` 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<T>` 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<T>` 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<T>` where `<T>` 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<ExampleIntegrationConfigurationDetails, ExampleIntegrationHandler>(
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<ExampleIntegrationConfigurationDetails, ExampleIntegrationHandler>(
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.

View File

@ -2,7 +2,7 @@
using System.Text; using System.Text;
using System.Text.Json; 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.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using RabbitMQ.Client; using RabbitMQ.Client;
@ -20,6 +20,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
private readonly Lazy<Task<IChannel>> _lazyChannel; private readonly Lazy<Task<IChannel>> _lazyChannel;
private readonly IRabbitMqService _rabbitMqService; private readonly IRabbitMqService _rabbitMqService;
private readonly ILogger<RabbitMqIntegrationListenerService> _logger; private readonly ILogger<RabbitMqIntegrationListenerService> _logger;
private readonly TimeProvider _timeProvider;
public RabbitMqIntegrationListenerService(IIntegrationHandler handler, public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
string routingKey, string routingKey,
@ -27,7 +28,8 @@ public class RabbitMqIntegrationListenerService : BackgroundService
string retryQueueName, string retryQueueName,
int maxRetries, int maxRetries,
IRabbitMqService rabbitMqService, IRabbitMqService rabbitMqService,
ILogger<RabbitMqIntegrationListenerService> logger) ILogger<RabbitMqIntegrationListenerService> logger,
TimeProvider timeProvider)
{ {
_handler = handler; _handler = handler;
_routingKey = routingKey; _routingKey = routingKey;
@ -35,6 +37,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
_queueName = queueName; _queueName = queueName;
_rabbitMqService = rabbitMqService; _rabbitMqService = rabbitMqService;
_logger = logger; _logger = logger;
_timeProvider = timeProvider;
_maxRetries = maxRetries; _maxRetries = maxRetries;
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync()); _lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
} }
@ -74,7 +77,7 @@ public class RabbitMqIntegrationListenerService : BackgroundService
var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json); var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json);
if (integrationMessage is not null && if (integrationMessage is not null &&
integrationMessage.DelayUntilDate.HasValue && integrationMessage.DelayUntilDate.HasValue &&
integrationMessage.DelayUntilDate.Value > DateTime.UtcNow) integrationMessage.DelayUntilDate.Value > _timeProvider.GetUtcNow().UtcDateTime)
{ {
await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea); await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);

View File

@ -1,7 +1,7 @@
#nullable enable #nullable enable
using System.Text; using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Settings; using Bit.Core.Settings;
using RabbitMQ.Client; using RabbitMQ.Client;

View File

@ -1,6 +1,6 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services; namespace Bit.Core.Services;

View File

@ -3,13 +3,15 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Text; using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
#nullable enable #nullable enable
namespace Bit.Core.Services; namespace Bit.Core.Services;
public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) public class WebhookIntegrationHandler(
IHttpClientFactory httpClientFactory,
TimeProvider timeProvider)
: IntegrationHandlerBase<WebhookIntegrationConfigurationDetails> : IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
{ {
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
@ -39,7 +41,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
if (int.TryParse(value, out var seconds)) if (int.TryParse(value, out var seconds))
{ {
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of 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, else if (DateTimeOffset.TryParseExact(value,
"r", // "r" is the round-trip format: RFC1123 "r", // "r" is the round-trip format: RFC1123

View File

@ -107,11 +107,11 @@ public static class FeatureFlagKeys
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; 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 LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string PolicyRequirements = "pm-14439-policy-requirements"; 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 ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript"; public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript";
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string CreateDefaultLocation = "pm-19467-create-default-location";
/* Auth Team */ /* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
@ -181,6 +181,7 @@ public static class FeatureFlagKeys
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
public const string MobileErrorReporting = "mobile-error-reporting"; public const string MobileErrorReporting = "mobile-error-reporting";
public const string AndroidChromeAutofill = "android-chrome-autofill"; 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 EnablePMPreloginSettings = "enable-pm-prelogin-settings";
public const string AppIntents = "app-intents"; public const string AppIntents = "app-intents";
@ -205,6 +206,7 @@ public static class FeatureFlagKeys
public const string EndUserNotifications = "pm-10609-end-user-notifications"; public const string EndUserNotifications = "pm-10609-end-user-notifications";
public const string PhishingDetection = "phishing-detection"; public const string PhishingDetection = "phishing-detection";
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -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<Guid> CipherIds { get; set; }
}

View File

@ -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; }
}

View File

@ -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<string> CipherIds { get; set; }
}

View File

@ -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<IEnumerable<MemberAccessCipherDetails>> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="orgGroups">Organization groups collection</param>
/// <param name="orgCollectionsWithAccess">Collections for the organization and the groups/users and permissions</param>
/// <param name="orgItems">Cipher items for the organization with the collections associated with them</param>
/// <param name="organizationUsersTwoFactorEnabled">Organization users and two factor status</param>
/// <param name="orgAbility">Organization ability for account recovery status</param>
/// <returns>List of the MemberAccessCipherDetailsModel</returns>;
private IEnumerable<MemberAccessCipherDetails> GenerateAccessDataParallel(
ICollection<Group> orgGroups,
ICollection<Tuple<Collection, CollectionAccessDetails>> orgCollectionsWithAccess,
IEnumerable<CipherOrganizationDetailsWithCollections> 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<MemberAccessCipherDetails>();
Parallel.ForEach(orgUsers, user =>
{
var groupAccessDetails = new List<MemberAccessDetails>();
var userCollectionAccessDetails = new List<MemberAccessDetails>();
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<MemberAccessDetails>();
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;
}
}

View File

@ -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<IEnumerable<MemberAccessReportDetail>> 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;
}
}

View File

@ -3,7 +3,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
public interface IMemberAccessCipherDetailsQuery public interface IMemberAccessReportQuery
{ {
Task<IEnumerable<MemberAccessCipherDetails>> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request); Task<IEnumerable<MemberAccessReportDetail>> GetMemberAccessReportsAsync(MemberAccessReportRequest request);
} }

View File

@ -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<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportDetails(RiskInsightsReportRequest request);
}

View File

@ -8,7 +8,8 @@ public static class ReportingServiceCollectionExtensions
{ {
public static void AddReportingServices(this IServiceCollection services) public static void AddReportingServices(this IServiceCollection services)
{ {
services.AddScoped<IMemberAccessCipherDetailsQuery, MemberAccessCipherDetailsQuery>(); services.AddScoped<IRiskInsightsReportQuery, RiskInsightsReportQuery>();
services.AddScoped<IMemberAccessReportQuery, MemberAccessReportQuery>();
services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>(); services.AddScoped<IAddPasswordHealthReportApplicationCommand, AddPasswordHealthReportApplicationCommand>();
services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>(); services.AddScoped<IGetPasswordHealthReportApplicationQuery, GetPasswordHealthReportApplicationQuery>();
services.AddScoped<IDropPasswordHealthReportApplicationCommand, DropPasswordHealthReportApplicationCommand>(); services.AddScoped<IDropPasswordHealthReportApplicationCommand, DropPasswordHealthReportApplicationCommand>();

View File

@ -1,6 +1,6 @@
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class MemberAccessCipherDetailsRequest public class MemberAccessReportRequest
{ {
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
} }

View File

@ -0,0 +1,6 @@
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
public class RiskInsightsReportRequest
{
public Guid OrganizationId { get; set; }
}

View File

@ -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<IEnumerable<RiskInsightsReportDetail>> 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;
}
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Dirt.Reports.Models.Data;
namespace Bit.Core.Dirt.Reports.Repositories;
public interface IOrganizationMemberBaseDetailRepository
{
Task<IEnumerable<OrganizationMemberBaseDetail>> GetOrganizationMemberBaseDetailsByOrganizationId(Guid organizationId);
}

View File

@ -4,22 +4,22 @@ using Bit.Core.Enums;
namespace Bit.Core.Models.Api; namespace Bit.Core.Models.Api;
public class PushSendRequestModel : IValidatableObject public class PushSendRequestModel<T> : IValidatableObject
{ {
public string? UserId { get; set; } public Guid? UserId { get; set; }
public string? OrganizationId { get; set; } public Guid? OrganizationId { get; set; }
public string? DeviceId { get; set; } public Guid? DeviceId { get; set; }
public string? Identifier { get; set; } public string? Identifier { get; set; }
public required PushType Type { 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 ClientType? ClientType { get; set; }
public string? InstallationId { get; set; } public Guid? InstallationId { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{ {
if (string.IsNullOrWhiteSpace(UserId) && if (!UserId.HasValue &&
string.IsNullOrWhiteSpace(OrganizationId) && !OrganizationId.HasValue &&
string.IsNullOrWhiteSpace(InstallationId)) !InstallationId.HasValue)
{ {
yield return new ValidationResult( yield return new ValidationResult(
$"{nameof(UserId)} or {nameof(OrganizationId)} or {nameof(InstallationId)} is required."); $"{nameof(UserId)} or {nameof(OrganizationId)} or {nameof(InstallationId)} is required.");

View File

@ -1,21 +1,17 @@
#nullable enable #nullable enable
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models; using Bit.Core.Models;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Notification = Bit.Core.NotificationCenter.Entities.Notification;
namespace Bit.Core.NotificationHub; namespace Bit.Core.NotificationHub;
@ -26,52 +22,32 @@ namespace Bit.Core.NotificationHub;
/// Used by Cloud-Hosted environments. /// Used by Cloud-Hosted environments.
/// Received by Firebase for Android or APNS for iOS. /// Received by Firebase for Android or APNS for iOS.
/// </summary> /// </summary>
public class NotificationHubPushNotificationService : IPushNotificationService public class NotificationHubPushNotificationService : IPushEngine, IPushRelayer
{ {
private readonly IInstallationDeviceRepository _installationDeviceRepository; private readonly IInstallationDeviceRepository _installationDeviceRepository;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly bool _enableTracing = false; private readonly bool _enableTracing = false;
private readonly INotificationHubPool _notificationHubPool; private readonly INotificationHubPool _notificationHubPool;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IGlobalSettings _globalSettings;
private readonly TimeProvider _timeProvider;
public NotificationHubPushNotificationService( public NotificationHubPushNotificationService(
IInstallationDeviceRepository installationDeviceRepository, IInstallationDeviceRepository installationDeviceRepository,
INotificationHubPool notificationHubPool, INotificationHubPool notificationHubPool,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<NotificationHubPushNotificationService> logger, ILogger<NotificationHubPushNotificationService> logger,
IGlobalSettings globalSettings, IGlobalSettings globalSettings)
TimeProvider timeProvider)
{ {
_installationDeviceRepository = installationDeviceRepository; _installationDeviceRepository = installationDeviceRepository;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_notificationHubPool = notificationHubPool; _notificationHubPool = notificationHubPool;
_logger = logger; _logger = logger;
_globalSettings = globalSettings;
_timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty) if (globalSettings.Installation.Id == Guid.Empty)
{ {
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
} }
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
}
public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable<Guid> 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<Guid>? collectionIds)
{ {
if (cipher.OrganizationId.HasValue) if (cipher.OrganizationId.HasValue)
{ {
@ -93,311 +69,17 @@ public class NotificationHubPushNotificationService : IPushNotificationService
CollectionIds = collectionIds, CollectionIds = collectionIds,
}; };
await SendPayloadToUserAsync(cipher.UserId.Value, type, message, true); await PushAsync(new PushNotification<SyncCipherPushNotification>
{
Type = type,
Target = NotificationTarget.User,
TargetId = cipher.UserId.Value,
Payload = message,
ExcludeCurrentContext = 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
{
Id = send.Id,
UserId = send.UserId.Value,
RevisionDate = send.RevisionDate
};
await SendPayloadToUserAsync(message.UserId, type, message, 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) private string? GetContextIdentifier(bool excludeCurrentContext)
{ {
if (!excludeCurrentContext) if (!excludeCurrentContext)
@ -425,13 +107,73 @@ public class NotificationHubPushNotificationService : IPushNotificationService
return $"({tag})"; return $"({tag})";
} }
private async Task SendPayloadAsync(string tag, PushType type, object payload) public async Task PushAsync<T>(PushNotification<T> 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<T>(string initialTag, string? contextId, PushType pushType, ClientType? clientType, T payload)
{
var finalTag = BuildTag(initialTag, contextId, clientType);
var results = await _notificationHubPool.AllClients.SendTemplateNotificationAsync( var results = await _notificationHubPool.AllClients.SendTemplateNotificationAsync(
new Dictionary<string, string> new Dictionary<string, string>
{ {
{ "type", ((byte)type).ToString() }, { "payload", JsonSerializer.Serialize(payload) } { "type", ((byte)pushType).ToString() },
}, tag); { "payload", JsonSerializer.Serialize(payload) },
},
finalTag
);
if (_enableTracing) if (_enableTracing)
{ {
@ -444,7 +186,7 @@ public class NotificationHubPushNotificationService : IPushNotificationService
_logger.LogInformation( _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}", "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);
} }
} }
} }

View File

@ -1,14 +1,10 @@
#nullable enable #nullable enable
using System.Text.Json; using System.Text.Json;
using Azure.Storage.Queues; using Azure.Storage.Queues;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models; using Bit.Core.Models;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -17,12 +13,10 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal; namespace Bit.Core.Platform.Push.Internal;
public class AzureQueuePushNotificationService : IPushNotificationService public class AzureQueuePushNotificationService : IPushEngine
{ {
private readonly QueueClient _queueClient; private readonly QueueClient _queueClient;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IGlobalSettings _globalSettings;
private readonly TimeProvider _timeProvider;
public AzureQueuePushNotificationService( public AzureQueuePushNotificationService(
[FromKeyedServices("notifications")] QueueClient queueClient, [FromKeyedServices("notifications")] QueueClient queueClient,
@ -33,30 +27,13 @@ public class AzureQueuePushNotificationService : IPushNotificationService
{ {
_queueClient = queueClient; _queueClient = queueClient;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_globalSettings = globalSettings;
_timeProvider = timeProvider;
if (globalSettings.Installation.Id == Guid.Empty) if (globalSettings.Installation.Id == Guid.Empty)
{ {
logger.LogWarning("Installation ID is not set. Push notifications for installations will not work."); logger.LogWarning("Installation ID is not set. Push notifications for installations will not work.");
} }
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
}
public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable<Guid> 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<Guid>? collectionIds)
{ {
if (cipher.OrganizationId.HasValue) 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<T>(PushType type, T payload, bool excludeCurrentContext) private async Task SendMessageAsync<T>(PushType type, T payload, bool excludeCurrentContext)
{ {
var contextId = GetContextIdentifier(excludeCurrentContext); var contextId = GetContextIdentifier(excludeCurrentContext);
@ -263,42 +80,9 @@ public class AzureQueuePushNotificationService : IPushNotificationService
return currentContext?.DeviceIdentifier; return currentContext?.DeviceIdentifier;
} }
public Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier, public async Task PushAsync<T>(PushNotification<T> pushNotification)
string? deviceId = null, ClientType? clientType = null) => where T : class
// Noop
Task.CompletedTask;
public Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier,
string? deviceId = null, ClientType? clientType = null)
{ {
// Noop await SendMessageAsync(pushNotification.Type, pushNotification.Payload, pushNotification.ExcludeCurrentContext);
return Task.FromResult(0);
} }
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);
} }

View File

@ -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<Guid>? collectionIds);
Task PushAsync<T>(PushNotification<T> pushNotification)
where T : class;
}

View File

@ -2,41 +2,410 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Tools.Entities; using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push; namespace Bit.Core.Platform.Push;
public interface IPushNotificationService public interface IPushNotificationService
{ {
Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds); Guid InstallationId { get; }
Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable<Guid> collectionIds); TimeProvider TimeProvider { get; }
Task PushSyncCipherDeleteAsync(Cipher cipher); ILogger Logger { get; }
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);
Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier, #region Legacy method, to be removed soon.
string? deviceId = null, ClientType? clientType = null); Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds)
Task SendPayloadToUserAsync(string userId, PushType type, object payload, string? identifier, => PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
string? deviceId = null, ClientType? clientType = null);
Task SendPayloadToOrganizationAsync(string orgId, PushType type, object payload, string? identifier, Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable<Guid> collectionIds)
string? deviceId = null, ClientType? clientType = null); => PushCipherAsync(cipher, PushType.SyncCipherUpdate, collectionIds);
Task PushPendingSecurityTasksAsync(Guid userId);
Task PushSyncCipherDeleteAsync(Cipher cipher)
=> PushCipherAsync(cipher, PushType.SyncLoginDelete, null);
Task PushSyncFolderCreateAsync(Folder folder)
=> PushAsync(new PushNotification<SyncFolderPushNotification>
{
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<SyncFolderPushNotification>
{
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<SyncFolderPushNotification>
{
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<UserPushNotification>
{
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<UserPushNotification>
{
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<UserPushNotification>
{
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<UserPushNotification>
{
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<UserPushNotification>
{
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<UserPushNotification>
{
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<SyncSendPushNotification>
{
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<SyncSendPushNotification>
{
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<SyncSendPushNotification>
{
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<NotificationPushNotification>
{
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<NotificationPushNotification>
{
Type = PushType.NotificationStatus,
Target = target,
TargetId = targetId,
Payload = message,
ExcludeCurrentContext = true,
ClientType = notification.ClientType,
});
}
Task PushAuthRequestAsync(AuthRequest authRequest)
=> PushAsync(new PushNotification<AuthRequestPushNotification>
{
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<AuthRequestPushNotification>
{
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<OrganizationStatusPushNotification>
{
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<OrganizationCollectionManagementPushNotification>
{
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<UserPushNotification>
{
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<Guid>? collectionIds);
Task PushAsync<T>(PushNotification<T> pushNotification)
where T : class;
} }

View File

@ -0,0 +1,44 @@
#nullable enable
using System.Text.Json;
using Bit.Core.Enums;
namespace Bit.Core.Platform.Push.Internal;
/// <summary>
/// An object encapsulating the information that is available in a notification
/// given to us from a self-hosted installation.
/// </summary>
public class RelayedNotification
{
/// <inheritdoc cref="PushNotification{T}.Type"/>
public required PushType Type { get; init; }
/// <inheritdoc cref="PushNotification{T}.Target"/>
public required NotificationTarget Target { get; init; }
/// <inheritdoc cref="PushNotification{T}.TargetId"/>
public required Guid TargetId { get; init; }
/// <inheritdoc cref="PushNotification{T}.Payload"/>
public required JsonElement Payload { get; init; }
/// <inheritdoc cref="PushNotification{T}.ClientType"/>
public required ClientType? ClientType { get; init; }
public required Guid? DeviceId { get; init; }
public required string? Identifier { get; init; }
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This interface should be treated as internal and not consumed by other teams.
/// </remarks>
public interface IPushRelayer
{
/// <summary>
/// Relays a notification that was received from an authenticated installation into our cloud push notification infrastructure.
/// </summary>
/// <param name="fromInstallation">The authenticated installation this notification came from.</param>
/// <param name="relayedNotification">The information received from the self-hosted installation.</param>
Task RelayAsync(Guid fromInstallation, RelayedNotification relayedNotification);
}

View File

@ -1,202 +1,77 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal; namespace Bit.Core.Platform.Push.Internal;
public class MultiServicePushNotificationService : IPushNotificationService public class MultiServicePushNotificationService : IPushNotificationService
{ {
private readonly IEnumerable<IPushNotificationService> _services; private readonly IEnumerable<IPushEngine> _services;
private readonly ILogger<MultiServicePushNotificationService> _logger;
public Guid InstallationId { get; }
public TimeProvider TimeProvider { get; }
public ILogger Logger { get; }
public MultiServicePushNotificationService( public MultiServicePushNotificationService(
[FromKeyedServices("implementation")] IEnumerable<IPushNotificationService> services, IEnumerable<IPushEngine> services,
ILogger<MultiServicePushNotificationService> logger, ILogger<MultiServicePushNotificationService> logger,
GlobalSettings globalSettings) GlobalSettings globalSettings,
TimeProvider timeProvider)
{ {
_services = services; _services = services;
_logger = logger; Logger = logger;
_logger.LogInformation("Hub services: {Services}", _services.Count()); Logger.LogInformation("Hub services: {Services}", _services.Count());
globalSettings.NotificationHubPool?.NotificationHubs?.ForEach(hub => 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<Guid> collectionIds) private Task PushToServices(Func<IPushEngine, Task> pushFunc)
{
PushToServices((s) => s.PushSyncCipherCreateAsync(cipher, collectionIds));
return Task.FromResult(0);
}
public Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable<Guid> 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<IPushNotificationService, Task> pushFunc)
{ {
if (!_services.Any()) if (!_services.Any())
{ {
_logger.LogWarning("No services found to push notification"); Logger.LogWarning("No services found to push notification");
return; return Task.CompletedTask;
} }
#if DEBUG
var tasks = new List<Task>();
#endif
foreach (var service in _services) 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); 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<Guid>? collectionIds)
{
return PushToServices((s) => s.PushCipherAsync(cipher, pushType, collectionIds));
}
public Task PushAsync<T>(PushNotification<T> pushNotification) where T : class
{
return PushToServices((s) => s.PushAsync(pushNotification));
} }
} }

View File

@ -1,129 +1,12 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
namespace Bit.Core.Platform.Push.Internal; namespace Bit.Core.Platform.Push.Internal;
public class NoopPushNotificationService : IPushNotificationService internal class NoopPushNotificationService : IPushEngine
{ {
public Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds) => Task.CompletedTask;
{
return Task.FromResult(0); public Task PushAsync<T>(PushNotification<T> pushNotification) where T : class => 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<Guid> 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);
}
} }

View File

@ -1,13 +1,9 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models; using Bit.Core.Models;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -20,18 +16,15 @@ namespace Bit.Core.Platform.Push;
/// Used by Cloud-Hosted environments. /// Used by Cloud-Hosted environments.
/// Received by AzureQueueHostedService message receiver in Notifications project. /// Received by AzureQueueHostedService message receiver in Notifications project.
/// </summary> /// </summary>
public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushNotificationService public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushEngine
{ {
private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;
public NotificationsApiPushNotificationService( public NotificationsApiPushNotificationService(
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<NotificationsApiPushNotificationService> logger, ILogger<NotificationsApiPushNotificationService> logger)
TimeProvider timeProvider)
: base( : base(
httpFactory, httpFactory,
globalSettings.BaseServiceUri.InternalNotifications, globalSettings.BaseServiceUri.InternalNotifications,
@ -41,27 +34,10 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
globalSettings.InternalIdentityKey, globalSettings.InternalIdentityKey,
logger) logger)
{ {
_globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
}
public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable<Guid> 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<Guid>? collectionIds)
{ {
if (cipher.OrganizationId.HasValue) 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<T>(PushType type, T payload, bool excludeCurrentContext) private async Task SendMessageAsync<T>(PushType type, T payload, bool excludeCurrentContext)
{ {
var contextId = GetContextIdentifier(excludeCurrentContext); var contextId = GetContextIdentifier(excludeCurrentContext);
@ -276,43 +84,8 @@ public class NotificationsApiPushNotificationService : BaseIdentityClientService
return currentContext?.DeviceIdentifier; return currentContext?.DeviceIdentifier;
} }
public Task SendPayloadToInstallationAsync(string installationId, PushType type, object payload, string? identifier, public async Task PushAsync<T>(PushNotification<T> pushNotification) where T : class
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)
{ {
// Noop await SendMessageAsync(pushNotification.Type, pushNotification.Payload, pushNotification.ExcludeCurrentContext);
return Task.FromResult(0);
} }
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);
} }

View File

@ -0,0 +1,78 @@
#nullable enable
using Bit.Core.Enums;
namespace Bit.Core.Platform.Push;
/// <summary>
/// Contains constants for all the available targets for a given notification.
/// </summary>
public enum NotificationTarget
{
/// <summary>
/// The target for the notification is a single user.
/// </summary>
User,
/// <summary>
/// The target for the notification are all the users in an organization.
/// </summary>
Organization,
/// <summary>
/// The target for the notification are all the organizations,
/// and all the users in that organization for a installation.
/// </summary>
Installation,
}
/// <summary>
/// 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.
/// </summary>
/// <typeparam name="T">The type of the payload. This type is expected to be able to be roundtripped as JSON.</typeparam>
public record PushNotification<T>
where T : class
{
/// <summary>
/// The <see cref="PushType"/> 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 <see cref="PushType"/>.
/// </summary>
public required PushType Type { get; init; }
/// <summary>
/// The target entity type for the notification.
/// </summary>
/// <remarks>
/// When the target type is <see cref="NotificationTarget.User"/> the <see cref="TargetId"/>
/// property is expected to be a users ID. When it is <see cref="NotificationTarget.Organization"/>
/// it should be an organizations id. When it is a <see cref="NotificationTarget.Installation"/>
/// it should be an installation id.
/// </remarks>
public required NotificationTarget Target { get; init; }
/// <summary>
/// The indentifier for the given <see cref="Target"/>.
/// </summary>
public required Guid TargetId { get; init; }
/// <summary>
/// The payload to be sent with the notification. This object will be JSON serialized.
/// </summary>
public required T Payload { get; init; }
/// <summary>
/// When <see langword="true"/> 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.
/// </summary>
public required bool ExcludeCurrentContext { get; init; }
/// <summary>
/// The type of clients the notification should be sent to, if <see langword="null"/> then
/// <see cref="ClientType.All"/> is inferred.
/// </summary>
public ClientType? ClientType { get; init; }
internal Guid? GetTargetWhen(NotificationTarget notificationTarget)
{
return Target == notificationTarget ? TargetId : null;
}
}

View File

@ -1,18 +1,15 @@
#nullable enable #nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Models; using Bit.Core.Models;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Push.Internal; namespace Bit.Core.Platform.Push.Internal;
@ -22,20 +19,18 @@ namespace Bit.Core.Platform.Push.Internal;
/// Used by Self-Hosted environments. /// Used by Self-Hosted environments.
/// Received by PushController endpoint in Api project. /// Received by PushController endpoint in Api project.
/// </summary> /// </summary>
public class RelayPushNotificationService : BaseIdentityClientService, IPushNotificationService public class RelayPushNotificationService : BaseIdentityClientService, IPushEngine
{ {
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly IGlobalSettings _globalSettings;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;
public RelayPushNotificationService( public RelayPushNotificationService(
IHttpClientFactory httpFactory, IHttpClientFactory httpFactory,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILogger<RelayPushNotificationService> logger, ILogger<RelayPushNotificationService> logger)
TimeProvider timeProvider)
: base( : base(
httpFactory, httpFactory,
globalSettings.PushRelayBaseUri, globalSettings.PushRelayBaseUri,
@ -46,27 +41,10 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
logger) logger)
{ {
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_globalSettings = globalSettings;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
} }
public async Task PushSyncCipherCreateAsync(Cipher cipher, IEnumerable<Guid> collectionIds) public async Task PushCipherAsync(Cipher cipher, PushType type, IEnumerable<Guid>? collectionIds)
{
await PushCipherAsync(cipher, PushType.SyncCipherCreate, collectionIds);
}
public async Task PushSyncCipherUpdateAsync(Cipher cipher, IEnumerable<Guid> 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<Guid>? collectionIds)
{ {
if (cipher.OrganizationId.HasValue) if (cipher.OrganizationId.HasValue)
{ {
@ -87,306 +65,45 @@ public class RelayPushNotificationService : BaseIdentityClientService, IPushNoti
RevisionDate = cipher.RevisionDate, RevisionDate = cipher.RevisionDate,
}; };
await SendPayloadToUserAsync(cipher.UserId.Value, type, message, true); await PushAsync(new PushNotification<SyncCipherPushNotification>
}
}
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
{
Id = send.Id,
UserId = send.UserId.Value,
RevisionDate = send.RevisionDate
};
await SendPayloadToUserAsync(message.UserId, type, message, true);
}
}
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 SendPayloadToUserAsync(authRequest.UserId, type, message, true);
}
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
};
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, Type = type,
Payload = payload, Target = NotificationTarget.User,
ClientType = clientType TargetId = cipher.UserId.Value,
Payload = message,
ExcludeCurrentContext = true,
});
}
}
public async Task PushAsync<T>(PushNotification<T> pushNotification)
where T : class
{
var deviceIdentifier = _httpContextAccessor.HttpContext
?.RequestServices.GetService<ICurrentContext>()
?.DeviceIdentifier;
Guid? deviceId = null;
if (!string.IsNullOrEmpty(deviceIdentifier))
{
var device = await _deviceRepository.GetByIdentifierAsync(deviceIdentifier);
deviceId = device?.Id;
}
var payload = new PushSendRequestModel<T>
{
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,
}; };
await AddCurrentContextAsync(request, excludeCurrentContext); await SendAsync(HttpMethod.Post, "push/send", payload);
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();
} }
} }

View File

@ -21,7 +21,7 @@ public class SendGridMailDeliveryService : IMailDeliveryService, IDisposable
GlobalSettings globalSettings, GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment, IWebHostEnvironment hostingEnvironment,
ILogger<SendGridMailDeliveryService> logger) ILogger<SendGridMailDeliveryService> logger)
: this(new SendGridClient(globalSettings.Mail.SendGridApiKey), : this(new SendGridClient(globalSettings.Mail.SendGridApiKey, globalSettings.Mail.SendGridApiHost),
globalSettings, hostingEnvironment, logger) globalSettings, hostingEnvironment, logger)
{ {
} }

View File

@ -431,6 +431,7 @@ public class GlobalSettings : IGlobalSettings
public SmtpSettings Smtp { get; set; } = new SmtpSettings(); public SmtpSettings Smtp { get; set; } = new SmtpSettings();
public string SendGridApiKey { get; set; } public string SendGridApiKey { get; set; }
public int? SendGridPercentage { get; set; } public int? SendGridPercentage { get; set; }
public string SendGridApiHost { get; set; } = "https://api.sendgrid.com";
public class SmtpSettings public class SmtpSettings
{ {

View File

@ -56,7 +56,7 @@ public class ImportCiphersCommand : IImportCiphersCommand
{ {
// Make sure the user can save new ciphers to their personal vault // Make sure the user can save new ciphers to their personal vault
var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
? (await _policyRequirementQuery.GetAsync<PersonalOwnershipPolicyRequirement>(importingUserId)).DisablePersonalOwnership ? (await _policyRequirementQuery.GetAsync<PersonalOwnershipPolicyRequirement>(importingUserId)).State == PersonalOwnershipState.Restricted
: await _policyService.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership); : await _policyService.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership);
if (isPersonalVaultRestricted) if (isPersonalVaultRestricted)

View File

@ -8,6 +8,7 @@ using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces; using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services; using Bit.Core.Tools.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Tools.SendFeatures.Commands; namespace Bit.Core.Tools.SendFeatures.Commands;
@ -18,19 +19,22 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
private readonly IPushNotificationService _pushNotificationService; private readonly IPushNotificationService _pushNotificationService;
private readonly ISendValidationService _sendValidationService; private readonly ISendValidationService _sendValidationService;
private readonly ISendCoreHelperService _sendCoreHelperService; private readonly ISendCoreHelperService _sendCoreHelperService;
private readonly ILogger<NonAnonymousSendCommand> _logger;
public NonAnonymousSendCommand(ISendRepository sendRepository, public NonAnonymousSendCommand(ISendRepository sendRepository,
ISendFileStorageService sendFileStorageService, ISendFileStorageService sendFileStorageService,
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
ISendAuthorizationService sendAuthorizationService, ISendAuthorizationService sendAuthorizationService,
ISendValidationService sendValidationService, ISendValidationService sendValidationService,
ISendCoreHelperService sendCoreHelperService) ISendCoreHelperService sendCoreHelperService,
ILogger<NonAnonymousSendCommand> logger)
{ {
_sendRepository = sendRepository; _sendRepository = sendRepository;
_sendFileStorageService = sendFileStorageService; _sendFileStorageService = sendFileStorageService;
_pushNotificationService = pushNotificationService; _pushNotificationService = pushNotificationService;
_sendValidationService = sendValidationService; _sendValidationService = sendValidationService;
_sendCoreHelperService = sendCoreHelperService; _sendCoreHelperService = sendCoreHelperService;
_logger = logger;
} }
public async Task SaveSendAsync(Send send) public async Task SaveSendAsync(Send send)
@ -63,6 +67,11 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
throw new BadRequestException("No file data."); 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); var storageBytesRemaining = await _sendValidationService.StorageRemainingForSendAsync(send);
if (storageBytesRemaining < fileLength) if (storageBytesRemaining < fileLength)
@ -77,13 +86,17 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
data.Id = fileId; data.Id = fileId;
data.Size = fileLength; data.Size = fileLength;
data.Validated = false; data.Validated = false;
send.Data = JsonSerializer.Serialize(data, send.Data = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull);
JsonHelpers.IgnoreWritingNull);
await SaveSendAsync(send); await SaveSendAsync(send);
return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId); return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId);
} }
catch 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 // Clean up since this is not transactional
await _sendFileStorageService.DeleteFileAsync(send, fileId); await _sendFileStorageService.DeleteFileAsync(send, fileId);
throw; throw;
@ -135,23 +148,31 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
{ {
var fileData = JsonSerializer.Deserialize<SendFileData>(send.Data); var fileData = JsonSerializer.Deserialize<SendFileData>(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); await DeleteSendAsync(send);
return false; return false;
} }
// Update Send data if necessary // replace expected size with validated size
if (realSize != fileData.Size) fileData.Size = size;
{
fileData.Size = realSize.Value;
}
fileData.Validated = true; fileData.Validated = true;
send.Data = JsonSerializer.Serialize(fileData, send.Data = JsonSerializer.Serialize(fileData, JsonHelpers.IgnoreWritingNull);
JsonHelpers.IgnoreWritingNull);
await SaveSendAsync(send); await SaveSendAsync(send);
return valid; return valid;

View File

@ -88,7 +88,7 @@ public class AzureSendFileStorageService : ISendFileStorageService
return sasUri.ToString(); 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(); await InitAsync();
@ -116,17 +116,14 @@ public class AzureSendFileStorageService : ISendFileStorageService
await blobClient.SetHttpHeadersAsync(headers); await blobClient.SetHttpHeadersAsync(headers);
var length = blobProperties.Value.ContentLength; var length = blobProperties.Value.ContentLength;
if (length < expectedFileSize - leeway || length > expectedFileSize + leeway) var valid = minimum <= length || length <= maximum;
{
return (false, length);
}
return (true, length); return (valid, length);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unhandled error in ValidateFileAsync"); _logger.LogError(ex, $"A storage operation failed in {nameof(ValidateFileAsync)}");
return (false, null); return (false, -1);
} }
} }

View File

@ -56,16 +56,13 @@ public interface ISendFileStorageService
/// </summary> /// </summary>
/// <param name="send"><see cref="Send" /> used to help validate file</param> /// <param name="send"><see cref="Send" /> used to help validate file</param>
/// <param name="fileId">File id to identify which file to validate</param> /// <param name="fileId">File id to identify which file to validate</param>
/// <param name="expectedFileSize">Expected file size of the file</param> /// <param name="minimum">The minimum allowed length of the stored file in bytes.</param>
/// <param name="leeway"> /// <param name="maximum">The maximuim allowed length of the stored file in bytes</param>
/// Send file size tolerance in bytes. When an uploaded file's `expectedFileSize` /// <returns>
/// is outside of the leeway, the storage operation fails. /// A task that completes when validation is finished. The first element of the tuple is
/// </param> /// <see langword="true" /> when validation succeeded, and false otherwise. The second element
/// <throws> /// of the tuple contains the observed file length in bytes. If an error occurs during validation,
/// ❌ Fill this in with an explanation of the error thrown when `leeway` is incorrect /// this returns `-1`.
/// </throws>
/// <returns>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.
/// </returns> /// </returns>
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);
} }

View File

@ -85,9 +85,9 @@ public class LocalSendStorageService : ISendFileStorageService
public Task<string> GetSendFileUploadUrlAsync(Send send, string fileId) public Task<string> GetSendFileUploadUrlAsync(Send send, string fileId)
=> Task.FromResult($"/sends/{send.Id}/file/{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); var path = FilePath(send, fileId);
if (!File.Exists(path)) if (!File.Exists(path))
{ {
@ -95,11 +95,7 @@ public class LocalSendStorageService : ISendFileStorageService
} }
length = new FileInfo(path).Length; length = new FileInfo(path).Length;
if (expectedFileSize < length - leeway || expectedFileSize > length + leeway) var valid = minimum < length || length < maximum;
{ return Task.FromResult((valid, length));
return Task.FromResult((false, length));
}
return Task.FromResult((true, length));
} }
} }

View File

@ -37,8 +37,8 @@ public class NoopSendFileStorageService : ISendFileStorageService
return Task.FromResult((string)null); 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));
} }
} }

View File

@ -143,7 +143,7 @@ public class CipherService : ICipherService
else else
{ {
var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
? (await _policyRequirementQuery.GetAsync<PersonalOwnershipPolicyRequirement>(savingUserId)).DisablePersonalOwnership ? (await _policyRequirementQuery.GetAsync<PersonalOwnershipPolicyRequirement>(savingUserId)).State == PersonalOwnershipState.Restricted
: await _policyService.AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership); : await _policyService.AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership);
if (isPersonalVaultRestricted) if (isPersonalVaultRestricted)

View File

@ -71,6 +71,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>(); services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>(); services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
services.AddSingleton<IUserSignatureKeyPairRepository, UserSignatureKeyPairRepository>(); services.AddSingleton<IUserSignatureKeyPairRepository, UserSignatureKeyPairRepository>();
services.AddSingleton<IOrganizationMemberBaseDetailRepository, OrganizationMemberBaseDetailRepository>();
if (selfHosted) if (selfHosted)
{ {

View File

@ -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<IEnumerable<OrganizationMemberBaseDetail>> GetOrganizationMemberBaseDetailsByOrganizationId(
Guid organizationId)
{
await using var connection = new SqlConnection(ConnectionString);
var result = await connection.QueryAsync<OrganizationMemberBaseDetail>(
"[dbo].[MemberAccessReport_GetMemberAccessCipherDetailsByOrganizationId]",
new
{
OrganizationId = organizationId
}, commandType: CommandType.StoredProcedure);
return result;
}
}

View File

@ -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<IEnumerable<OrganizationMemberBaseDetail>> GetOrganizationMemberBaseDetailsByOrganizationId(
Guid organizationId)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var result = await dbContext.Set<OrganizationMemberBaseDetail>()
.FromSqlRaw("EXEC [dbo].[MemberAccessReport_GetMemberAccessCipherDetailsByOrganizationId] @OrganizationId",
new SqlParameter("@OrganizationId", organizationId))
.ToListAsync();
return result;
}
}

View File

@ -14,6 +14,7 @@ using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories; using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Bit.Infrastructure.EntityFramework.Auth.Repositories; using Bit.Infrastructure.EntityFramework.Auth.Repositories;
using Bit.Infrastructure.EntityFramework.Billing.Repositories; using Bit.Infrastructure.EntityFramework.Billing.Repositories;
using Bit.Infrastructure.EntityFramework.Dirt;
using Bit.Infrastructure.EntityFramework.Dirt.Repositories; using Bit.Infrastructure.EntityFramework.Dirt.Repositories;
using Bit.Infrastructure.EntityFramework.KeyManagement.Repositories; using Bit.Infrastructure.EntityFramework.KeyManagement.Repositories;
using Bit.Infrastructure.EntityFramework.NotificationCenter.Repositories; using Bit.Infrastructure.EntityFramework.NotificationCenter.Repositories;
@ -108,6 +109,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>(); services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
services.AddSingleton<IUserSignatureKeyPairRepository, UserSignatureKeyPairRepository>(); services.AddSingleton<IUserSignatureKeyPairRepository, UserSignatureKeyPairRepository>();
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>(); services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
services.AddSingleton<IOrganizationMemberBaseDetailRepository, OrganizationMemberBaseDetailRepository>();
if (selfHosted) if (selfHosted)
{ {

View File

@ -1,4 +1,5 @@
using Bit.Core; using Bit.Core;
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Infrastructure.EntityFramework.Auth.Models; using Bit.Infrastructure.EntityFramework.Auth.Models;
@ -81,6 +82,7 @@ public class DatabaseContext : DbContext
public DbSet<NotificationStatus> NotificationStatuses { get; set; } public DbSet<NotificationStatus> NotificationStatuses { get; set; }
public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; } public DbSet<ClientOrganizationMigrationRecord> ClientOrganizationMigrationRecords { get; set; }
public DbSet<PasswordHealthReportApplication> PasswordHealthReportApplications { get; set; } public DbSet<PasswordHealthReportApplication> PasswordHealthReportApplications { get; set; }
public DbSet<OrganizationMemberBaseDetail> OrganizationMemberBaseDetails { get; set; }
public DbSet<SecurityTask> SecurityTasks { get; set; } public DbSet<SecurityTask> SecurityTasks { get; set; }
public DbSet<OrganizationInstallation> OrganizationInstallations { get; set; } public DbSet<OrganizationInstallation> OrganizationInstallations { get; set; }
@ -113,6 +115,7 @@ public class DatabaseContext : DbContext
var eOrganizationConnection = builder.Entity<OrganizationConnection>(); var eOrganizationConnection = builder.Entity<OrganizationConnection>();
var eOrganizationDomain = builder.Entity<OrganizationDomain>(); var eOrganizationDomain = builder.Entity<OrganizationDomain>();
var aWebAuthnCredential = builder.Entity<WebAuthnCredential>(); var aWebAuthnCredential = builder.Entity<WebAuthnCredential>();
var eOrganizationMemberBaseDetail = builder.Entity<OrganizationMemberBaseDetail>();
// Shadow property configurations go here // Shadow property configurations go here
@ -135,6 +138,8 @@ public class DatabaseContext : DbContext
eCollectionGroup.HasKey(cg => new { cg.CollectionId, cg.GroupId }); eCollectionGroup.HasKey(cg => new { cg.CollectionId, cg.GroupId });
eGroupUser.HasKey(gu => new { gu.GroupId, gu.OrganizationUserId }); eGroupUser.HasKey(gu => new { gu.GroupId, gu.OrganizationUserId });
eOrganizationMemberBaseDetail.HasNoKey();
var dataProtector = this.GetService<DP.IDataProtectionProvider>().CreateProtector( var dataProtector = this.GetService<DP.IDataProtectionProvider>().CreateProtector(
Constants.DatabaseFieldProtectorPurpose); Constants.DatabaseFieldProtectorPurpose);
var dataProtectionConverter = new DataProtectionConverter(dataProtector); var dataProtectionConverter = new DataProtectionConverter(dataProtector);

View File

@ -5,7 +5,7 @@ using System.Security.Cryptography.X509Certificates;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Azure.Storage.Queues; using Azure.Storage.Queues;
using Bit.Core.AdminConsole.Models.Business.Tokenables; 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.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.Implementations;
@ -288,7 +288,7 @@ public static class ServiceCollectionExtensions
if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) &&
CoreHelpers.SettingHasValue(globalSettings.Installation.Key)) CoreHelpers.SettingHasValue(globalSettings.Installation.Key))
{ {
services.AddKeyedSingleton<IPushNotificationService, RelayPushNotificationService>("implementation"); services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, RelayPushNotificationService>());
services.AddSingleton<IPushRegistrationService, RelayPushRegistrationService>(); services.AddSingleton<IPushRegistrationService, RelayPushRegistrationService>();
} }
else else
@ -299,20 +299,20 @@ public static class ServiceCollectionExtensions
if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) &&
CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications))
{ {
services.AddKeyedSingleton<IPushNotificationService, NotificationsApiPushNotificationService>("implementation"); services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, NotificationsApiPushNotificationService>());
} }
} }
else else
{ {
services.AddSingleton<INotificationHubPool, NotificationHubPool>(); services.AddSingleton<INotificationHubPool, NotificationHubPool>();
services.AddSingleton<IPushRegistrationService, NotificationHubPushRegistrationService>(); services.AddSingleton<IPushRegistrationService, NotificationHubPushRegistrationService>();
services.AddKeyedSingleton<IPushNotificationService, NotificationHubPushNotificationService>("implementation"); services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, NotificationHubPushNotificationService>());
services.TryAddSingleton<IPushRelayer, NotificationHubPushNotificationService>();
if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString)) if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString))
{ {
services.AddKeyedSingleton("notifications", services.AddKeyedSingleton("notifications",
(_, _) => new QueueClient(globalSettings.Notifications.ConnectionString, "notifications")); (_, _) => new QueueClient(globalSettings.Notifications.ConnectionString, "notifications"));
services.AddKeyedSingleton<IPushNotificationService, AzureQueuePushNotificationService>( services.TryAddEnumerable(ServiceDescriptor.Singleton<IPushEngine, AzureQueuePushNotificationService>());
"implementation");
} }
} }
@ -366,7 +366,6 @@ public static class ServiceCollectionExtensions
{ {
services.AddSingleton<IMailService, NoopMailService>(); services.AddSingleton<IMailService, NoopMailService>();
services.AddSingleton<IMailDeliveryService, NoopMailDeliveryService>(); services.AddSingleton<IMailDeliveryService, NoopMailDeliveryService>();
services.AddSingleton<IPushNotificationService, NoopPushNotificationService>();
services.AddSingleton<IPushRegistrationService, NoopPushRegistrationService>(); services.AddSingleton<IPushRegistrationService, NoopPushRegistrationService>();
services.AddSingleton<IAttachmentStorageService, NoopAttachmentStorageService>(); services.AddSingleton<IAttachmentStorageService, NoopAttachmentStorageService>();
services.AddSingleton<ILicensingService, NoopLicensingService>(); services.AddSingleton<ILicensingService, NoopLicensingService>();
@ -718,7 +717,8 @@ public static class ServiceCollectionExtensions
retryQueueName: integrationRetryQueueName, retryQueueName: integrationRetryQueueName,
maxRetries: maxRetries, maxRetries: maxRetries,
rabbitMqService: provider.GetRequiredService<IRabbitMqService>(), rabbitMqService: provider.GetRequiredService<IRabbitMqService>(),
logger: provider.GetRequiredService<ILogger<RabbitMqIntegrationListenerService>>())); logger: provider.GetRequiredService<ILogger<RabbitMqIntegrationListenerService>>(),
timeProvider: provider.GetRequiredService<TimeProvider>()));
return services; return services;
} }

View File

@ -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
)

View File

@ -28,6 +28,8 @@ public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
_identityApplicationFactory.ManagesDatabase = false; _identityApplicationFactory.ManagesDatabase = false;
} }
public IdentityApplicationFactory Identity => _identityApplicationFactory;
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
base.ConfigureWebHost(builder); base.ConfigureWebHost(builder);

View File

@ -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<object[]> SendData()
{
static object[] Typed<T>(PushSendRequestModel<T> pushSendRequestModel, string expectedHubTagExpression, bool expectHubCall = true)
{
return [pushSendRequestModel, expectedHubTagExpression, expectHubCall];
}
static object[] UserTyped(PushType pushType)
{
return Typed(new PushSendRequestModel<UserPushNotification>
{
Type = pushType,
UserId = _userId,
DeviceId = _deviceId,
Payload = new UserPushNotification
{
Date = DateTime.UtcNow,
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
}
// User cipher
yield return Typed(new PushSendRequestModel<SyncCipherPushNotification>
{
Type = PushType.SyncCipherUpdate,
UserId = _userId,
DeviceId = _deviceId,
Payload = new SyncCipherPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
// Organization cipher, an org cipher would not naturally be synced from our
// code but it is technically possible to be submitted to the endpoint.
yield return Typed(new PushSendRequestModel<SyncCipherPushNotification>
{
Type = PushType.SyncCipherUpdate,
OrganizationId = _organizationId,
DeviceId = _deviceId,
Payload = new SyncCipherPushNotification
{
Id = Guid.NewGuid(),
OrganizationId = _organizationId,
},
}, $"(template:payload && organizationId:%installation%_{_organizationId})");
yield return Typed(new PushSendRequestModel<SyncCipherPushNotification>
{
Type = PushType.SyncCipherCreate,
UserId = _userId,
DeviceId = _deviceId,
Payload = new SyncCipherPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
// Organization cipher, an org cipher would not naturally be synced from our
// code but it is technically possible to be submitted to the endpoint.
yield return Typed(new PushSendRequestModel<SyncCipherPushNotification>
{
Type = PushType.SyncCipherCreate,
OrganizationId = _organizationId,
DeviceId = _deviceId,
Payload = new SyncCipherPushNotification
{
Id = Guid.NewGuid(),
OrganizationId = _organizationId,
},
}, $"(template:payload && organizationId:%installation%_{_organizationId})");
yield return Typed(new PushSendRequestModel<SyncCipherPushNotification>
{
Type = PushType.SyncCipherDelete,
UserId = _userId,
DeviceId = _deviceId,
Payload = new SyncCipherPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
// Organization cipher, an org cipher would not naturally be synced from our
// code but it is technically possible to be submitted to the endpoint.
yield return Typed(new PushSendRequestModel<SyncCipherPushNotification>
{
Type = PushType.SyncCipherDelete,
OrganizationId = _organizationId,
DeviceId = _deviceId,
Payload = new SyncCipherPushNotification
{
Id = Guid.NewGuid(),
OrganizationId = _organizationId,
},
}, $"(template:payload && organizationId:%installation%_{_organizationId})");
yield return Typed(new PushSendRequestModel<SyncFolderPushNotification>
{
Type = PushType.SyncFolderDelete,
UserId = _userId,
DeviceId = _deviceId,
Payload = new SyncFolderPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
yield return Typed(new PushSendRequestModel<SyncFolderPushNotification>
{
Type = PushType.SyncFolderCreate,
UserId = _userId,
DeviceId = _deviceId,
Payload = new SyncFolderPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
yield return Typed(new PushSendRequestModel<SyncFolderPushNotification>
{
Type = PushType.SyncFolderCreate,
UserId = _userId,
DeviceId = _deviceId,
Payload = new SyncFolderPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
yield return UserTyped(PushType.SyncCiphers);
yield return UserTyped(PushType.SyncVault);
yield return UserTyped(PushType.SyncOrganizations);
yield return UserTyped(PushType.SyncOrgKeys);
yield return UserTyped(PushType.SyncSettings);
yield return UserTyped(PushType.LogOut);
yield return UserTyped(PushType.PendingSecurityTasks);
yield return Typed(new PushSendRequestModel<AuthRequestPushNotification>
{
Type = PushType.AuthRequest,
UserId = _userId,
DeviceId = _deviceId,
Payload = new AuthRequestPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
yield return Typed(new PushSendRequestModel<AuthRequestPushNotification>
{
Type = PushType.AuthRequestResponse,
UserId = _userId,
DeviceId = _deviceId,
Payload = new AuthRequestPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
yield return Typed(new PushSendRequestModel<NotificationPushNotification>
{
Type = PushType.Notification,
UserId = _userId,
DeviceId = _deviceId,
Payload = new NotificationPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload_userId:%installation%_{_userId})");
yield return Typed(new PushSendRequestModel<NotificationPushNotification>
{
Type = PushType.Notification,
UserId = _userId,
DeviceId = _deviceId,
ClientType = ClientType.All,
Payload = new NotificationPushNotification
{
Id = Guid.NewGuid(),
Global = true,
},
}, $"(template:payload_userId:%installation%_{_userId})");
yield return Typed(new PushSendRequestModel<NotificationPushNotification>
{
Type = PushType.NotificationStatus,
OrganizationId = _organizationId,
DeviceId = _deviceId,
Payload = new NotificationPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload && organizationId:%installation%_{_organizationId})");
yield return Typed(new PushSendRequestModel<NotificationPushNotification>
{
Type = PushType.NotificationStatus,
OrganizationId = _organizationId,
DeviceId = _deviceId,
Payload = new NotificationPushNotification
{
Id = Guid.NewGuid(),
UserId = _userId,
},
}, $"(template:payload && organizationId:%installation%_{_organizationId})");
}
[Theory]
[MemberData(nameof(SendData))]
public async Task Send_Works<T>(PushSendRequestModel<T> pushSendRequestModel, string expectedHubTagExpression, bool expectHubCall)
{
var (apiFactory, httpClient, installation, queueClient, notificationHubProxy) = await SetupTest();
// Act
var pushSendResponse = await httpClient.PostAsJsonAsync("push/send", pushSendRequestModel);
// Assert
pushSendResponse.EnsureSuccessStatusCode();
// Relayed notifications, the ones coming to this endpoint should
// not make their way into our Azure Queue and instead should only be sent to Azure Notifications
// hub.
await queueClient
.Received(0)
.SendMessageAsync(Arg.Any<string>());
// Check that this notification was sent through hubs the expected number of times
await notificationHubProxy
.Received(expectHubCall ? 1 : 0)
.SendTemplateNotificationAsync(
Arg.Any<Dictionary<string, string>>(),
Arg.Is(expectedHubTagExpression.Replace("%installation%", installation.Id.ToString()))
);
// TODO: Expect on the dictionary more?
// Notifications being relayed from SH should have the device id
// tracked so that we can later send the notification to that device.
await apiFactory.GetService<IInstallationDeviceRepository>()
.Received(1)
.UpsertAsync(Arg.Is<InstallationDeviceEntity>(
ide => ide.PartitionKey == installation.Id.ToString() && ide.RowKey == pushSendRequestModel.DeviceId.ToString()
));
}
[Fact]
public async Task Send_InstallationNotification_NotAuthenticatedInstallation_Fails()
{
var (_, httpClient, _, _, _) = await SetupTest();
var response = await httpClient.PostAsJsonAsync("push/send", new PushSendRequestModel<object>
{
Type = PushType.NotificationStatus,
InstallationId = Guid.NewGuid(),
Payload = new { }
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonNode>();
Assert.Equal(JsonValueKind.Object, body.GetValueKind());
Assert.True(body.AsObject().TryGetPropertyValue("message", out var message));
Assert.Equal(JsonValueKind.String, message.GetValueKind());
Assert.Equal("InstallationId does not match current context.", message.GetValue<string>());
}
[Fact]
public async Task Send_InstallationNotification_Works()
{
var (apiFactory, httpClient, installation, _, notificationHubProxy) = await SetupTest();
var deviceId = Guid.NewGuid();
var response = await httpClient.PostAsJsonAsync("push/send", new PushSendRequestModel<object>
{
Type = PushType.NotificationStatus,
InstallationId = installation.Id,
Payload = new { },
DeviceId = deviceId,
ClientType = ClientType.Web,
});
response.EnsureSuccessStatusCode();
await notificationHubProxy
.Received(1)
.SendTemplateNotificationAsync(
Arg.Any<Dictionary<string, string>>(),
Arg.Is($"(template:payload && installationId:{installation.Id} && clientType:Web)")
);
await apiFactory.GetService<IInstallationDeviceRepository>()
.Received(1)
.UpsertAsync(Arg.Is<InstallationDeviceEntity>(
ide => ide.PartitionKey == installation.Id.ToString() && ide.RowKey == deviceId.ToString()
));
}
[Fact]
public async Task Send_NoOrganizationNoInstallationNoUser_FailsModelValidation()
{
var (_, client, _, _, _) = await SetupTest();
var response = await client.PostAsJsonAsync("push/send", new PushSendRequestModel<object>
{
Type = PushType.AuthRequest,
Payload = new { },
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonNode>();
Assert.Equal(JsonValueKind.Object, body.GetValueKind());
Assert.True(body.AsObject().TryGetPropertyValue("message", out var message));
Assert.Equal(JsonValueKind.String, message.GetValueKind());
Assert.Equal("The model state is invalid.", message.GetValue<string>());
}
private static async Task<(ApiApplicationFactory Factory, HttpClient AuthedClient, Installation Installation, QueueClient MockedQueue, INotificationHubProxy MockedHub)> SetupTest()
{
// Arrange
var apiFactory = new ApiApplicationFactory();
var queueClient = Substitute.For<QueueClient>();
// Substitute the underlying queue messages will go to.
apiFactory.ConfigureServices(services =>
{
var queueClientService = services.FirstOrDefault(
sd => sd.ServiceKey == (object)"notifications"
&& sd.ServiceType == typeof(QueueClient)
) ?? throw new InvalidOperationException("Expected service was not found.");
services.Remove(queueClientService);
services.AddKeyedSingleton("notifications", queueClient);
});
var notificationHubProxy = Substitute.For<INotificationHubProxy>();
apiFactory.SubstituteService<INotificationHubPool>(s =>
{
s.AllClients
.Returns(notificationHubProxy);
});
apiFactory.SubstituteService<IInstallationDeviceRepository>(s => { });
// Setup as cloud with NotificationHub setup and Azure Queue
apiFactory.UpdateConfiguration("GlobalSettings:Notifications:ConnectionString", "any_value");
// Configure hubs
var index = 0;
void AddHub(NotificationHubSettings notificationHubSettings)
{
apiFactory.UpdateConfiguration(
$"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:ConnectionString",
notificationHubSettings.ConnectionString
);
apiFactory.UpdateConfiguration(
$"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:HubName",
notificationHubSettings.HubName
);
apiFactory.UpdateConfiguration(
$"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:RegistrationStartDate",
notificationHubSettings.RegistrationStartDate?.ToString()
);
apiFactory.UpdateConfiguration(
$"GlobalSettings:NotificationHubPool:NotificationHubs:{index}:RegistrationEndDate",
notificationHubSettings.RegistrationEndDate?.ToString()
);
index++;
}
AddHub(new NotificationHubSettings
{
ConnectionString = "some_value",
RegistrationStartDate = DateTime.UtcNow.AddDays(-2),
});
var httpClient = apiFactory.CreateClient();
// Add installation into database
var installationRepository = apiFactory.GetService<IInstallationRepository>();
var installation = await installationRepository.CreateAsync(new Installation
{
Key = "my_test_key",
Email = "test@example.com",
Enabled = true,
});
var identityClient = apiFactory.Identity.CreateDefaultClient();
var connectTokenResponse = await identityClient.PostAsync("connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "grant_type", "client_credentials" },
{ "scope", "api.push" },
{ "client_id", $"installation.{installation.Id}" },
{ "client_secret", installation.Key },
}));
connectTokenResponse.EnsureSuccessStatusCode();
var connectTokenResponseModel = await connectTokenResponse.Content.ReadFromJsonAsync<JsonNode>();
// Setup authentication
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
connectTokenResponseModel["token_type"].GetValue<string>(),
connectTokenResponseModel["access_token"].GetValue<string>()
);
return (apiFactory, httpClient, installation, queueClient, notificationHubProxy);
}
}

View File

@ -3,7 +3,7 @@ using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities; 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.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;

View File

@ -1,6 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
using Xunit; using Xunit;

View File

@ -18,210 +18,6 @@ namespace Bit.Api.Test.Platform.Push.Controllers;
[SutProviderCustomize] [SutProviderCustomize]
public class PushControllerTests public class PushControllerTests
{ {
[Theory]
[BitAutoData(false, true)]
[BitAutoData(false, false)]
[BitAutoData(true, true)]
public async Task SendAsync_InstallationIdNotSetOrSelfHosted_BadRequest(bool haveInstallationId, bool selfHosted,
SutProvider<PushController> sutProvider, Guid installationId, Guid userId, Guid organizationId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted = selfHosted;
if (haveInstallationId)
{
sutProvider.GetDependency<ICurrentContext>().InstallationId.Returns(installationId);
}
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.SendAsync(new PushSendRequestModel
{
Type = PushType.Notification,
UserId = userId.ToString(),
OrganizationId = organizationId.ToString(),
InstallationId = installationId.ToString(),
Payload = "test-payload"
}));
Assert.Equal("Not correctly configured for push relays.", exception.Message);
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToUserAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToOrganizationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToInstallationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
}
[Theory]
[BitAutoData]
public async Task SendAsync_UserIdAndOrganizationIdAndInstallationIdEmpty_NoPushNotificationSent(
SutProvider<PushController> sutProvider, Guid installationId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<ICurrentContext>().InstallationId.Returns(installationId);
await sutProvider.Sut.SendAsync(new PushSendRequestModel
{
Type = PushType.Notification,
UserId = null,
OrganizationId = null,
InstallationId = null,
Payload = "test-payload"
});
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToUserAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToOrganizationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToInstallationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
}
[Theory]
[RepeatingPatternBitAutoData([false, true], [false, true], [false, true])]
public async Task SendAsync_UserIdSet_SendPayloadToUserAsync(bool haveIdentifier, bool haveDeviceId,
bool haveOrganizationId, SutProvider<PushController> sutProvider, Guid installationId, Guid userId,
Guid identifier, Guid deviceId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<ICurrentContext>().InstallationId.Returns(installationId);
var expectedUserId = $"{installationId}_{userId}";
var expectedIdentifier = haveIdentifier ? $"{installationId}_{identifier}" : null;
var expectedDeviceId = haveDeviceId ? $"{installationId}_{deviceId}" : null;
await sutProvider.Sut.SendAsync(new PushSendRequestModel
{
Type = PushType.Notification,
UserId = userId.ToString(),
OrganizationId = haveOrganizationId ? Guid.NewGuid().ToString() : null,
InstallationId = null,
Payload = "test-payload",
DeviceId = haveDeviceId ? deviceId.ToString() : null,
Identifier = haveIdentifier ? identifier.ToString() : null,
ClientType = ClientType.All,
});
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.SendPayloadToUserAsync(expectedUserId, PushType.Notification, "test-payload", expectedIdentifier,
expectedDeviceId, ClientType.All);
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToOrganizationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToInstallationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
}
[Theory]
[RepeatingPatternBitAutoData([false, true], [false, true])]
public async Task SendAsync_OrganizationIdSet_SendPayloadToOrganizationAsync(bool haveIdentifier, bool haveDeviceId,
SutProvider<PushController> sutProvider, Guid installationId, Guid organizationId, Guid identifier,
Guid deviceId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<ICurrentContext>().InstallationId.Returns(installationId);
var expectedOrganizationId = $"{installationId}_{organizationId}";
var expectedIdentifier = haveIdentifier ? $"{installationId}_{identifier}" : null;
var expectedDeviceId = haveDeviceId ? $"{installationId}_{deviceId}" : null;
await sutProvider.Sut.SendAsync(new PushSendRequestModel
{
Type = PushType.Notification,
UserId = null,
OrganizationId = organizationId.ToString(),
InstallationId = null,
Payload = "test-payload",
DeviceId = haveDeviceId ? deviceId.ToString() : null,
Identifier = haveIdentifier ? identifier.ToString() : null,
ClientType = ClientType.All,
});
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.SendPayloadToOrganizationAsync(expectedOrganizationId, PushType.Notification, "test-payload",
expectedIdentifier, expectedDeviceId, ClientType.All);
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToUserAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToInstallationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
}
[Theory]
[RepeatingPatternBitAutoData([false, true], [false, true])]
public async Task SendAsync_InstallationIdSet_SendPayloadToInstallationAsync(bool haveIdentifier, bool haveDeviceId,
SutProvider<PushController> sutProvider, Guid installationId, Guid identifier, Guid deviceId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<ICurrentContext>().InstallationId.Returns(installationId);
var expectedIdentifier = haveIdentifier ? $"{installationId}_{identifier}" : null;
var expectedDeviceId = haveDeviceId ? $"{installationId}_{deviceId}" : null;
await sutProvider.Sut.SendAsync(new PushSendRequestModel
{
Type = PushType.Notification,
UserId = null,
OrganizationId = null,
InstallationId = installationId.ToString(),
Payload = "test-payload",
DeviceId = haveDeviceId ? deviceId.ToString() : null,
Identifier = haveIdentifier ? identifier.ToString() : null,
ClientType = ClientType.All,
});
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.SendPayloadToInstallationAsync(installationId.ToString(), PushType.Notification, "test-payload",
expectedIdentifier, expectedDeviceId, ClientType.All);
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToOrganizationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToUserAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<ClientType?>());
}
[Theory]
[BitAutoData]
public async Task SendAsync_InstallationIdNotMatching_BadRequest(SutProvider<PushController> sutProvider,
Guid installationId)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<ICurrentContext>().InstallationId.Returns(installationId);
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.SendAsync(new PushSendRequestModel
{
Type = PushType.Notification,
UserId = null,
OrganizationId = null,
InstallationId = Guid.NewGuid().ToString(),
Payload = "test-payload",
DeviceId = null,
Identifier = null,
ClientType = ClientType.All,
}));
Assert.Equal("InstallationId does not match current context.", exception.Message);
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToInstallationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToOrganizationAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(),
Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<ClientType?>());
await sutProvider.GetDependency<IPushNotificationService>().Received(0)
.SendPayloadToUserAsync(Arg.Any<string>(), Arg.Any<PushType>(), Arg.Any<object>(), Arg.Any<string>(),
Arg.Any<string?>(), Arg.Any<ClientType?>());
}
[Theory] [Theory]
[BitAutoData(false, true)] [BitAutoData(false, true)]
[BitAutoData(false, false)] [BitAutoData(false, false)]

View File

@ -1,9 +1,9 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
using Xunit; using Xunit;
namespace Bit.Core.Test.Models.Data.Integrations; namespace Bit.Core.Test.Models.Data.EventIntegrations;
public class IntegrationMessageTests public class IntegrationMessageTests
{ {
@ -45,6 +45,7 @@ public class IntegrationMessageTests
var json = message.ToJson(); var json = message.ToJson();
var result = IntegrationMessage<WebhookIntegrationConfigurationDetails>.FromJson(json); var result = IntegrationMessage<WebhookIntegrationConfigurationDetails>.FromJson(json);
Assert.NotNull(result);
Assert.Equal(message.Configuration, result.Configuration); Assert.Equal(message.Configuration, result.Configuration);
Assert.Equal(message.MessageId, result.MessageId); Assert.Equal(message.MessageId, result.MessageId);
Assert.Equal(message.RenderedTemplate, result.RenderedTemplate); Assert.Equal(message.RenderedTemplate, result.RenderedTemplate);

View File

@ -10,6 +10,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -442,4 +443,98 @@ public class ConfirmOrganizationUserCommandTests
await sutProvider.GetDependency<IMailService>().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); await sutProvider.GetDependency<IMailService>().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager);
await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is<List<OrganizationUser>>(users => users.Contains(orgUser) && users.Count == 1)); await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is<List<OrganizationUser>>(users => users.Contains(orgUser) && users.Count == 1));
} }
[Theory, BitAutoData]
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithPersonalOwnershipPolicyApplicable_WithValidCollectionName_CreatesDefaultCollection(
Organization organization, OrganizationUser confirmingUser,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
string key, string collectionName, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually;
orgUser.OrganizationId = confirmingUser.OrganizationId = organization.Id;
orgUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<PersonalOwnershipPolicyRequirement>(user.Id)
.Returns(new PersonalOwnershipPolicyRequirement(
PersonalOwnershipState.Restricted,
[organization.Id]));
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName);
await sutProvider.GetDependency<ICollectionRepository>()
.Received(1)
.CreateAsync(
Arg.Is<Collection>(c => c.Name == collectionName &&
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection),
Arg.Is<IEnumerable<CollectionAccessSelection>>(groups => groups == null),
Arg.Is<IEnumerable<CollectionAccessSelection>>(u =>
u.Count() == 1 &&
u.First().Id == orgUser.Id &&
u.First().Manage == true));
}
[Theory, BitAutoData]
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithPersonalOwnershipPolicyApplicable_WithInvalidCollectionName_DoesNotCreateDefaultCollection(
Organization org, OrganizationUser confirmingUser,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
string key, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
{
org.PlanType = PlanType.EnterpriseAnnually;
orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
orgUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<PersonalOwnershipPolicyRequirement>(user.Id)
.Returns(new PersonalOwnershipPolicyRequirement(
PersonalOwnershipState.Restricted,
[org.Id]));
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, "");
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.CreateAsync(Arg.Any<Collection>(), Arg.Any<IEnumerable<CollectionAccessSelection>>(), Arg.Any<IEnumerable<CollectionAccessSelection>>());
}
[Theory, BitAutoData]
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithPersonalOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection(
Organization org, OrganizationUser confirmingUser,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
string key, string collectionName, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
{
org.PlanType = PlanType.EnterpriseAnnually;
orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id;
orgUser.UserId = user.Id;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<PersonalOwnershipPolicyRequirement>(user.Id)
.Returns(new PersonalOwnershipPolicyRequirement(
PersonalOwnershipState.Restricted,
[Guid.NewGuid()]));
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName);
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.CreateAsync(Arg.Any<Collection>(), Arg.Any<IEnumerable<CollectionAccessSelection>>(), Arg.Any<IEnumerable<CollectionAccessSelection>>());
}
} }

View File

@ -12,20 +12,42 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequire
public class PersonalOwnershipPolicyRequirementFactoryTests public class PersonalOwnershipPolicyRequirementFactoryTests
{ {
[Theory, BitAutoData] [Theory, BitAutoData]
public void DisablePersonalOwnership_WithNoPolicies_ReturnsFalse(SutProvider<PersonalOwnershipPolicyRequirementFactory> sutProvider) public void State_WithNoPolicies_ReturnsAllowed(SutProvider<PersonalOwnershipPolicyRequirementFactory> sutProvider)
{ {
var actual = sutProvider.Sut.Create([]); var actual = sutProvider.Sut.Create([]);
Assert.False(actual.DisablePersonalOwnership); Assert.Equal(PersonalOwnershipState.Allowed, actual.State);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public void DisablePersonalOwnership_WithPersonalOwnershipPolicies_ReturnsTrue( public void State_WithPersonalOwnershipPolicies_ReturnsRestricted(
[PolicyDetails(PolicyType.PersonalOwnership)] PolicyDetails[] policies, [PolicyDetails(PolicyType.PersonalOwnership)] PolicyDetails[] policies,
SutProvider<PersonalOwnershipPolicyRequirementFactory> sutProvider) SutProvider<PersonalOwnershipPolicyRequirementFactory> sutProvider)
{ {
var actual = sutProvider.Sut.Create(policies); var actual = sutProvider.Sut.Create(policies);
Assert.True(actual.DisablePersonalOwnership); Assert.Equal(PersonalOwnershipState.Restricted, actual.State);
}
[Theory, BitAutoData]
public void RequiresDefaultCollection_WithNoPolicies_ReturnsFalse(
Guid organizationId,
SutProvider<PersonalOwnershipPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create([]);
Assert.False(actual.RequiresDefaultCollection(organizationId));
}
[Theory, BitAutoData]
public void RequiresDefaultCollection_WithPersonalOwnershipPolicies_ReturnsCorrectResult(
[PolicyDetails(PolicyType.PersonalOwnership)] PolicyDetails[] policies,
Guid nonPolicyOrganizationId,
SutProvider<PersonalOwnershipPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create(policies);
Assert.True(actual.RequiresDefaultCollection(policies[0].OrganizationId));
Assert.False(actual.RequiresDefaultCollection(nonPolicyOrganizationId));
} }
} }

View File

@ -1,7 +1,7 @@
#nullable enable #nullable enable
using Azure.Messaging.ServiceBus; using Azure.Messaging.ServiceBus;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
@ -52,7 +52,6 @@ public class AzureServiceBusIntegrationListenerServiceTests
public async Task HandleMessageAsync_FailureNotRetryable_PublishesToDeadLetterQueue(IntegrationMessage<WebhookIntegrationConfiguration> message) public async Task HandleMessageAsync_FailureNotRetryable_PublishesToDeadLetterQueue(IntegrationMessage<WebhookIntegrationConfiguration> message)
{ {
var sutProvider = GetSutProvider(); var sutProvider = GetSutProvider();
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1);
message.RetryCount = 0; message.RetryCount = 0;
var result = new IntegrationHandlerResult(false, message); var result = new IntegrationHandlerResult(false, message);
@ -71,7 +70,6 @@ public class AzureServiceBusIntegrationListenerServiceTests
public async Task HandleMessageAsync_FailureRetryableButTooManyRetries_PublishesToDeadLetterQueue(IntegrationMessage<WebhookIntegrationConfiguration> message) public async Task HandleMessageAsync_FailureRetryableButTooManyRetries_PublishesToDeadLetterQueue(IntegrationMessage<WebhookIntegrationConfiguration> message)
{ {
var sutProvider = GetSutProvider(); var sutProvider = GetSutProvider();
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1);
message.RetryCount = _maxRetries; message.RetryCount = _maxRetries;
var result = new IntegrationHandlerResult(false, message); var result = new IntegrationHandlerResult(false, message);
result.Retryable = true; result.Retryable = true;
@ -90,12 +88,10 @@ public class AzureServiceBusIntegrationListenerServiceTests
public async Task HandleMessageAsync_FailureRetryable_PublishesToRetryQueue(IntegrationMessage<WebhookIntegrationConfiguration> message) public async Task HandleMessageAsync_FailureRetryable_PublishesToRetryQueue(IntegrationMessage<WebhookIntegrationConfiguration> message)
{ {
var sutProvider = GetSutProvider(); var sutProvider = GetSutProvider();
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1);
message.RetryCount = 0; message.RetryCount = 0;
var result = new IntegrationHandlerResult(false, message); var result = new IntegrationHandlerResult(false, message);
result.Retryable = true; result.Retryable = true;
result.DelayUntilDate = DateTime.UtcNow.AddMinutes(1);
_handler.HandleAsync(Arg.Any<string>()).Returns(result); _handler.HandleAsync(Arg.Any<string>()).Returns(result);
var expected = (IntegrationMessage<WebhookIntegrationConfiguration>)IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson())!; var expected = (IntegrationMessage<WebhookIntegrationConfiguration>)IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson())!;
@ -110,7 +106,6 @@ public class AzureServiceBusIntegrationListenerServiceTests
public async Task HandleMessageAsync_SuccessfulResult_Succeeds(IntegrationMessage<WebhookIntegrationConfiguration> message) public async Task HandleMessageAsync_SuccessfulResult_Succeeds(IntegrationMessage<WebhookIntegrationConfiguration> message)
{ {
var sutProvider = GetSutProvider(); var sutProvider = GetSutProvider();
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1);
var result = new IntegrationHandlerResult(true, message); var result = new IntegrationHandlerResult(true, message);
_handler.HandleAsync(Arg.Any<string>()).Returns(result); _handler.HandleAsync(Arg.Any<string>()).Returns(result);

View File

@ -1,6 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Services; using Bit.Core.Services;
using Xunit; using Xunit;

View File

@ -1,9 +1,10 @@
using System.Text; using System.Text;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers; using Bit.Test.Common.Helpers;
using Microsoft.Extensions.Time.Testing;
using NSubstitute; using NSubstitute;
using RabbitMQ.Client; using RabbitMQ.Client;
using RabbitMQ.Client.Events; using RabbitMQ.Client.Events;
@ -18,19 +19,24 @@ public class RabbitMqIntegrationListenerServiceTests
private const string _queueName = "test_queue"; private const string _queueName = "test_queue";
private const string _retryQueueName = "test_queue_retry"; private const string _retryQueueName = "test_queue_retry";
private const string _routingKey = "test_routing_key"; private const string _routingKey = "test_routing_key";
private readonly DateTime _now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc);
private readonly IIntegrationHandler _handler = Substitute.For<IIntegrationHandler>(); private readonly IIntegrationHandler _handler = Substitute.For<IIntegrationHandler>();
private readonly IRabbitMqService _rabbitMqService = Substitute.For<IRabbitMqService>(); private readonly IRabbitMqService _rabbitMqService = Substitute.For<IRabbitMqService>();
private SutProvider<RabbitMqIntegrationListenerService> GetSutProvider() private SutProvider<RabbitMqIntegrationListenerService> GetSutProvider()
{ {
return new SutProvider<RabbitMqIntegrationListenerService>() var sutProvider = new SutProvider<RabbitMqIntegrationListenerService>()
.SetDependency(_handler) .SetDependency(_handler)
.SetDependency(_rabbitMqService) .SetDependency(_rabbitMqService)
.SetDependency(_queueName, "queueName") .SetDependency(_queueName, "queueName")
.SetDependency(_retryQueueName, "retryQueueName") .SetDependency(_retryQueueName, "retryQueueName")
.SetDependency(_routingKey, "routingKey") .SetDependency(_routingKey, "routingKey")
.SetDependency(_maxRetries, "maxRetries") .SetDependency(_maxRetries, "maxRetries")
.WithFakeTimeProvider()
.Create(); .Create();
sutProvider.GetDependency<FakeTimeProvider>().SetUtcNow(_now);
return sutProvider;
} }
[Fact] [Fact]
@ -55,7 +61,7 @@ public class RabbitMqIntegrationListenerServiceTests
var cancellationToken = CancellationToken.None; var cancellationToken = CancellationToken.None;
await sutProvider.Sut.StartAsync(cancellationToken); await sutProvider.Sut.StartAsync(cancellationToken);
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1); message.DelayUntilDate = null;
message.RetryCount = 0; message.RetryCount = 0;
var eventArgs = new BasicDeliverEventArgs( var eventArgs = new BasicDeliverEventArgs(
consumerTag: string.Empty, consumerTag: string.Empty,
@ -94,7 +100,7 @@ public class RabbitMqIntegrationListenerServiceTests
var cancellationToken = CancellationToken.None; var cancellationToken = CancellationToken.None;
await sutProvider.Sut.StartAsync(cancellationToken); await sutProvider.Sut.StartAsync(cancellationToken);
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1); message.DelayUntilDate = null;
message.RetryCount = _maxRetries; message.RetryCount = _maxRetries;
var eventArgs = new BasicDeliverEventArgs( var eventArgs = new BasicDeliverEventArgs(
consumerTag: string.Empty, consumerTag: string.Empty,
@ -132,7 +138,7 @@ public class RabbitMqIntegrationListenerServiceTests
var cancellationToken = CancellationToken.None; var cancellationToken = CancellationToken.None;
await sutProvider.Sut.StartAsync(cancellationToken); await sutProvider.Sut.StartAsync(cancellationToken);
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1); message.DelayUntilDate = null;
message.RetryCount = 0; message.RetryCount = 0;
var eventArgs = new BasicDeliverEventArgs( var eventArgs = new BasicDeliverEventArgs(
consumerTag: string.Empty, consumerTag: string.Empty,
@ -145,7 +151,7 @@ public class RabbitMqIntegrationListenerServiceTests
); );
var result = new IntegrationHandlerResult(false, message); var result = new IntegrationHandlerResult(false, message);
result.Retryable = true; result.Retryable = true;
result.DelayUntilDate = DateTime.UtcNow.AddMinutes(1); result.DelayUntilDate = _now.AddMinutes(1);
_handler.HandleAsync(Arg.Any<string>()).Returns(result); _handler.HandleAsync(Arg.Any<string>()).Returns(result);
var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson()); var expected = IntegrationMessage<WebhookIntegrationConfiguration>.FromJson(message.ToJson());
@ -173,7 +179,7 @@ public class RabbitMqIntegrationListenerServiceTests
var cancellationToken = CancellationToken.None; var cancellationToken = CancellationToken.None;
await sutProvider.Sut.StartAsync(cancellationToken); await sutProvider.Sut.StartAsync(cancellationToken);
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(-1); message.DelayUntilDate = null;
var eventArgs = new BasicDeliverEventArgs( var eventArgs = new BasicDeliverEventArgs(
consumerTag: string.Empty, consumerTag: string.Empty,
deliveryTag: 0, deliveryTag: 0,
@ -205,7 +211,7 @@ public class RabbitMqIntegrationListenerServiceTests
var cancellationToken = CancellationToken.None; var cancellationToken = CancellationToken.None;
await sutProvider.Sut.StartAsync(cancellationToken); await sutProvider.Sut.StartAsync(cancellationToken);
message.DelayUntilDate = DateTime.UtcNow.AddMinutes(1); message.DelayUntilDate = _now.AddMinutes(1);
var eventArgs = new BasicDeliverEventArgs( var eventArgs = new BasicDeliverEventArgs(
consumerTag: string.Empty, consumerTag: string.Empty,
deliveryTag: 0, deliveryTag: 0,

View File

@ -1,4 +1,4 @@
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;

View File

@ -1,10 +1,11 @@
using System.Net; using System.Net;
using Bit.Core.AdminConsole.Models.Data.Integrations; using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers; using Bit.Test.Common.Helpers;
using Bit.Test.Common.MockedHttpClient; using Bit.Test.Common.MockedHttpClient;
using Microsoft.Extensions.Time.Testing;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -33,6 +34,7 @@ public class WebhookIntegrationHandlerTests
return new SutProvider<WebhookIntegrationHandler>() return new SutProvider<WebhookIntegrationHandler>()
.SetDependency(clientFactory) .SetDependency(clientFactory)
.WithFakeTimeProvider()
.Create(); .Create();
} }
@ -62,9 +64,13 @@ public class WebhookIntegrationHandlerTests
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task HandleAsync_TooManyRequests_ReturnsFailureSetsNotBeforUtc(IntegrationMessage<WebhookIntegrationConfigurationDetails> message) public async Task HandleAsync_TooManyRequests_ReturnsFailureSetsDelayUntilDate(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{ {
var sutProvider = GetSutProvider(); var sutProvider = GetSutProvider();
var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc);
var retryAfter = now.AddSeconds(60);
sutProvider.GetDependency<FakeTimeProvider>().SetUtcNow(now);
message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl); message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl);
_handler.Fallback _handler.Fallback
@ -78,19 +84,21 @@ public class WebhookIntegrationHandlerTests
Assert.True(result.Retryable); Assert.True(result.Retryable);
Assert.Equal(result.Message, message); Assert.Equal(result.Message, message);
Assert.True(result.DelayUntilDate.HasValue); Assert.True(result.DelayUntilDate.HasValue);
Assert.InRange(result.DelayUntilDate.Value, DateTime.UtcNow.AddSeconds(59), DateTime.UtcNow.AddSeconds(61)); Assert.Equal(retryAfter, result.DelayUntilDate.Value);
Assert.Equal("Too Many Requests", result.FailureReason); Assert.Equal("Too Many Requests", result.FailureReason);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task HandleAsync_TooManyRequestsWithDate_ReturnsFailureSetsNotBeforUtc(IntegrationMessage<WebhookIntegrationConfigurationDetails> message) public async Task HandleAsync_TooManyRequestsWithDate_ReturnsFailureSetsDelayUntilDate(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{ {
var sutProvider = GetSutProvider(); var sutProvider = GetSutProvider();
var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc);
var retryAfter = now.AddSeconds(60);
message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl); message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl);
_handler.Fallback _handler.Fallback
.WithStatusCode(HttpStatusCode.TooManyRequests) .WithStatusCode(HttpStatusCode.TooManyRequests)
.WithHeader("Retry-After", DateTime.UtcNow.AddSeconds(60).ToString("r")) // "r" is the round-trip format: RFC1123 .WithHeader("Retry-After", retryAfter.ToString("r"))
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>")); .WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
var result = await sutProvider.Sut.HandleAsync(message); var result = await sutProvider.Sut.HandleAsync(message);
@ -99,7 +107,7 @@ public class WebhookIntegrationHandlerTests
Assert.True(result.Retryable); Assert.True(result.Retryable);
Assert.Equal(result.Message, message); Assert.Equal(result.Message, message);
Assert.True(result.DelayUntilDate.HasValue); Assert.True(result.DelayUntilDate.HasValue);
Assert.InRange(result.DelayUntilDate.Value, DateTime.UtcNow.AddSeconds(59), DateTime.UtcNow.AddSeconds(61)); Assert.Equal(retryAfter, result.DelayUntilDate.Value);
Assert.Equal("Too Many Requests", result.FailureReason); Assert.Equal("Too Many Requests", result.FailureReason);
} }

View File

@ -11,16 +11,14 @@ namespace Bit.Core.Test.Models.Api.Request;
public class PushSendRequestModelTests public class PushSendRequestModelTests
{ {
[Theory] [Fact]
[RepeatingPatternBitAutoData([null, "", " "], [null, "", " "], [null, "", " "])] public void Validate_UserIdOrganizationIdInstallationIdNull_Invalid()
public void Validate_UserIdOrganizationIdInstallationIdNullOrEmpty_Invalid(string? userId, string? organizationId,
string? installationId)
{ {
var model = new PushSendRequestModel var model = new PushSendRequestModel<string>
{ {
UserId = userId, UserId = null,
OrganizationId = organizationId, OrganizationId = null,
InstallationId = installationId, InstallationId = null,
Type = PushType.SyncCiphers, Type = PushType.SyncCiphers,
Payload = "test" Payload = "test"
}; };
@ -32,16 +30,14 @@ public class PushSendRequestModelTests
result => result.ErrorMessage == "UserId or OrganizationId or InstallationId is required."); result => result.ErrorMessage == "UserId or OrganizationId or InstallationId is required.");
} }
[Theory] [Fact]
[RepeatingPatternBitAutoData([null, "", " "], [null, "", " "])] public void Validate_UserIdProvidedOrganizationIdInstallationIdNull_Valid()
public void Validate_UserIdProvidedOrganizationIdInstallationIdNullOrEmpty_Valid(string? organizationId,
string? installationId)
{ {
var model = new PushSendRequestModel var model = new PushSendRequestModel<string>
{ {
UserId = Guid.NewGuid().ToString(), UserId = Guid.NewGuid(),
OrganizationId = organizationId, OrganizationId = null,
InstallationId = installationId, InstallationId = null,
Type = PushType.SyncCiphers, Type = PushType.SyncCiphers,
Payload = "test" Payload = "test"
}; };
@ -51,16 +47,14 @@ public class PushSendRequestModelTests
Assert.Empty(results); Assert.Empty(results);
} }
[Theory] [Fact]
[RepeatingPatternBitAutoData([null, "", " "], [null, "", " "])] public void Validate_OrganizationIdProvidedUserIdInstallationIdNull_Valid()
public void Validate_OrganizationIdProvidedUserIdInstallationIdNullOrEmpty_Valid(string? userId,
string? installationId)
{ {
var model = new PushSendRequestModel var model = new PushSendRequestModel<string>
{ {
UserId = userId, UserId = null,
OrganizationId = Guid.NewGuid().ToString(), OrganizationId = Guid.NewGuid(),
InstallationId = installationId, InstallationId = null,
Type = PushType.SyncCiphers, Type = PushType.SyncCiphers,
Payload = "test" Payload = "test"
}; };
@ -70,16 +64,14 @@ public class PushSendRequestModelTests
Assert.Empty(results); Assert.Empty(results);
} }
[Theory] [Fact]
[RepeatingPatternBitAutoData([null, "", " "], [null, "", " "])] public void Validate_InstallationIdProvidedUserIdOrganizationIdNull_Valid()
public void Validate_InstallationIdProvidedUserIdOrganizationIdNullOrEmpty_Valid(string? userId,
string? organizationId)
{ {
var model = new PushSendRequestModel var model = new PushSendRequestModel<string>
{ {
UserId = userId, UserId = null,
OrganizationId = organizationId, OrganizationId = null,
InstallationId = Guid.NewGuid().ToString(), InstallationId = Guid.NewGuid(),
Type = PushType.SyncCiphers, Type = PushType.SyncCiphers,
Payload = "test" Payload = "test"
}; };
@ -94,10 +86,10 @@ public class PushSendRequestModelTests
[BitAutoData("Type")] [BitAutoData("Type")]
public void Validate_RequiredFieldNotProvided_Invalid(string requiredField) public void Validate_RequiredFieldNotProvided_Invalid(string requiredField)
{ {
var model = new PushSendRequestModel var model = new PushSendRequestModel<string>
{ {
UserId = Guid.NewGuid().ToString(), UserId = Guid.NewGuid(),
OrganizationId = Guid.NewGuid().ToString(), OrganizationId = Guid.NewGuid(),
Type = PushType.SyncCiphers, Type = PushType.SyncCiphers,
Payload = "test" Payload = "test"
}; };
@ -115,7 +107,7 @@ public class PushSendRequestModelTests
var serialized = JsonSerializer.Serialize(dictionary, JsonHelpers.IgnoreWritingNull); var serialized = JsonSerializer.Serialize(dictionary, JsonHelpers.IgnoreWritingNull);
var jsonException = var jsonException =
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PushSendRequestModel>(serialized)); Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PushSendRequestModel<string>>(serialized));
Assert.Contains($"missing required properties, including the following: {requiredField}", Assert.Contains($"missing required properties, including the following: {requiredField}",
jsonException.Message); jsonException.Message);
} }
@ -123,15 +115,15 @@ public class PushSendRequestModelTests
[Fact] [Fact]
public void Validate_AllFieldsPresent_Valid() public void Validate_AllFieldsPresent_Valid()
{ {
var model = new PushSendRequestModel var model = new PushSendRequestModel<string>
{ {
UserId = Guid.NewGuid().ToString(), UserId = Guid.NewGuid(),
OrganizationId = Guid.NewGuid().ToString(), OrganizationId = Guid.NewGuid(),
Type = PushType.SyncCiphers, Type = PushType.SyncCiphers,
Payload = "test payload", Payload = "test payload",
Identifier = Guid.NewGuid().ToString(), Identifier = Guid.NewGuid().ToString(),
ClientType = ClientType.All, ClientType = ClientType.All,
DeviceId = Guid.NewGuid().ToString() DeviceId = Guid.NewGuid()
}; };
var results = Validate(model); var results = Validate(model);
@ -139,7 +131,7 @@ public class PushSendRequestModelTests
Assert.Empty(results); Assert.Empty(results);
} }
private static List<ValidationResult> Validate(PushSendRequestModel model) private static List<ValidationResult> Validate<T>(PushSendRequestModel<T> model)
{ {
var results = new List<ValidationResult>(); var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true); Validator.TryValidateObject(model, new ValidationContext(model), results, true);

View File

@ -5,12 +5,11 @@ using Bit.Core.Auth.Entities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models; using Bit.Core.Models;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Enums; using Bit.Core.NotificationCenter.Enums;
using Bit.Core.NotificationHub; using Bit.Core.NotificationHub;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Test.NotificationCenter.AutoFixture; using Bit.Core.Test.NotificationCenter.AutoFixture;
using Bit.Core.Tools.Entities; using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
@ -33,483 +32,6 @@ public class NotificationHubPushNotificationServiceTests
private static readonly DateTime _now = DateTime.UtcNow; private static readonly DateTime _now = DateTime.UtcNow;
private static readonly Guid _installationId = Guid.Parse("da73177b-513f-4444-b582-595c890e1022"); private static readonly Guid _installationId = Guid.Parse("da73177b-513f-4444-b582-595c890e1022");
[Theory]
[BitAutoData]
[NotificationCustomize]
public async Task PushNotificationAsync_GlobalInstallationIdDefault_NotSent(
SutProvider<NotificationHubPushNotificationService> sutProvider, Notification notification)
{
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = default;
await sutProvider.Sut.PushNotificationAsync(notification);
await sutProvider.GetDependency<INotificationHubPool>()
.Received(0)
.AllClients
.Received(0)
.SendTemplateNotificationAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<string>());
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData]
[NotificationCustomize]
public async Task PushNotificationAsync_GlobalInstallationIdSetClientTypeAll_SentToInstallationId(
SutProvider<NotificationHubPushNotificationService> sutProvider, Notification notification, Guid installationId)
{
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
notification.ClientType = ClientType.All;
var expectedNotification = ToNotificationPushNotification(notification, null, installationId);
await sutProvider.Sut.PushNotificationAsync(notification);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.Notification,
expectedNotification,
$"(template:payload && installationId:{installationId})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize]
public async Task PushNotificationAsync_GlobalInstallationIdSetClientTypeNotAll_SentToInstallationIdAndClientType(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification, Guid installationId)
{
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, null, installationId);
await sutProvider.Sut.PushNotificationAsync(notification);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.Notification,
expectedNotification,
$"(template:payload && installationId:{installationId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(false)]
[BitAutoData(true)]
[NotificationCustomize(false)]
public async Task PushNotificationAsync_UserIdProvidedClientTypeAll_SentToUser(
bool organizationIdNull, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification)
{
if (organizationIdNull)
{
notification.OrganizationId = null;
}
notification.ClientType = ClientType.All;
var expectedNotification = ToNotificationPushNotification(notification, null, null);
await sutProvider.Sut.PushNotificationAsync(notification);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.Notification,
expectedNotification,
$"(template:payload_userId:{notification.UserId})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize(false)]
public async Task PushNotificationAsync_UserIdProvidedOrganizationIdNullClientTypeNotAll_SentToUser(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification)
{
notification.OrganizationId = null;
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, null, null);
await sutProvider.Sut.PushNotificationAsync(notification);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.Notification,
expectedNotification,
$"(template:payload_userId:{notification.UserId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize(false)]
public async Task PushNotificationAsync_UserIdProvidedOrganizationIdProvidedClientTypeNotAll_SentToUser(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification)
{
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, null, null);
await sutProvider.Sut.PushNotificationAsync(notification);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.Notification,
expectedNotification,
$"(template:payload_userId:{notification.UserId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData]
[NotificationCustomize(false)]
public async Task PushNotificationAsync_UserIdNullOrganizationIdProvidedClientTypeAll_SentToOrganization(
SutProvider<NotificationHubPushNotificationService> sutProvider, Notification notification)
{
notification.UserId = null;
notification.ClientType = ClientType.All;
var expectedNotification = ToNotificationPushNotification(notification, null, null);
await sutProvider.Sut.PushNotificationAsync(notification);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.Notification,
expectedNotification,
$"(template:payload && organizationId:{notification.OrganizationId})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize(false)]
public async Task PushNotificationAsync_UserIdNullOrganizationIdProvidedClientTypeNotAll_SentToOrganization(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification)
{
notification.UserId = null;
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, null, null);
await sutProvider.Sut.PushNotificationAsync(notification);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.Notification,
expectedNotification,
$"(template:payload && organizationId:{notification.OrganizationId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData]
[NotificationCustomize]
public async Task PushNotificationStatusAsync_GlobalInstallationIdDefault_NotSent(
SutProvider<NotificationHubPushNotificationService> sutProvider, Notification notification,
NotificationStatus notificationStatus)
{
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = default;
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await sutProvider.GetDependency<INotificationHubPool>()
.Received(0)
.AllClients
.Received(0)
.SendTemplateNotificationAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<string>());
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData]
[NotificationCustomize]
public async Task PushNotificationStatusAsync_GlobalInstallationIdSetClientTypeAll_SentToInstallationId(
SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification, NotificationStatus notificationStatus, Guid installationId)
{
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
notification.ClientType = ClientType.All;
var expectedNotification = ToNotificationPushNotification(notification, notificationStatus, installationId);
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.NotificationStatus,
expectedNotification,
$"(template:payload && installationId:{installationId})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize]
public async Task
PushNotificationStatusAsync_GlobalInstallationIdSetClientTypeNotAll_SentToInstallationIdAndClientType(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification, NotificationStatus notificationStatus, Guid installationId)
{
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, notificationStatus, installationId);
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.NotificationStatus,
expectedNotification,
$"(template:payload && installationId:{installationId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(false)]
[BitAutoData(true)]
[NotificationCustomize(false)]
public async Task PushNotificationStatusAsync_UserIdProvidedClientTypeAll_SentToUser(
bool organizationIdNull, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification, NotificationStatus notificationStatus)
{
if (organizationIdNull)
{
notification.OrganizationId = null;
}
notification.ClientType = ClientType.All;
var expectedNotification = ToNotificationPushNotification(notification, notificationStatus, null);
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.NotificationStatus,
expectedNotification,
$"(template:payload_userId:{notification.UserId})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize(false)]
public async Task PushNotificationStatusAsync_UserIdProvidedOrganizationIdNullClientTypeNotAll_SentToUser(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification, NotificationStatus notificationStatus)
{
notification.OrganizationId = null;
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, notificationStatus, null);
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.NotificationStatus,
expectedNotification,
$"(template:payload_userId:{notification.UserId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize(false)]
public async Task PushNotificationStatusAsync_UserIdProvidedOrganizationIdProvidedClientTypeNotAll_SentToUser(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification, NotificationStatus notificationStatus)
{
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, notificationStatus, null);
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.NotificationStatus,
expectedNotification,
$"(template:payload_userId:{notification.UserId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData]
[NotificationCustomize(false)]
public async Task PushNotificationStatusAsync_UserIdNullOrganizationIdProvidedClientTypeAll_SentToOrganization(
SutProvider<NotificationHubPushNotificationService> sutProvider, Notification notification,
NotificationStatus notificationStatus)
{
notification.UserId = null;
notification.ClientType = ClientType.All;
var expectedNotification = ToNotificationPushNotification(notification, notificationStatus, null);
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.NotificationStatus,
expectedNotification,
$"(template:payload && organizationId:{notification.OrganizationId})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Web)]
[BitAutoData(ClientType.Mobile)]
[NotificationCustomize(false)]
public async Task
PushNotificationStatusAsync_UserIdNullOrganizationIdProvidedClientTypeNotAll_SentToOrganization(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider,
Notification notification, NotificationStatus notificationStatus)
{
notification.UserId = null;
notification.ClientType = clientType;
var expectedNotification = ToNotificationPushNotification(notification, notificationStatus, null);
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await AssertSendTemplateNotificationAsync(sutProvider, PushType.NotificationStatus,
expectedNotification,
$"(template:payload && organizationId:{notification.OrganizationId} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData([null])]
[BitAutoData(ClientType.All)]
public async Task SendPayloadToUserAsync_ClientTypeNullOrAll_SentToUser(ClientType? clientType,
SutProvider<NotificationHubPushNotificationService> sutProvider, Guid userId, PushType pushType, string payload,
string identifier)
{
await sutProvider.Sut.SendPayloadToUserAsync(userId.ToString(), pushType, payload, identifier, null,
clientType);
await AssertSendTemplateNotificationAsync(sutProvider, pushType, payload,
$"(template:payload_userId:{userId} && !deviceIdentifier:{identifier})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Mobile)]
[BitAutoData(ClientType.Web)]
public async Task SendPayloadToUserAsync_ClientTypeExplicit_SentToUserAndClientType(ClientType clientType,
SutProvider<NotificationHubPushNotificationService> sutProvider, Guid userId, PushType pushType, string payload,
string identifier)
{
await sutProvider.Sut.SendPayloadToUserAsync(userId.ToString(), pushType, payload, identifier, null,
clientType);
await AssertSendTemplateNotificationAsync(sutProvider, pushType, payload,
$"(template:payload_userId:{userId} && !deviceIdentifier:{identifier} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData([null])]
[BitAutoData(ClientType.All)]
public async Task SendPayloadToOrganizationAsync_ClientTypeNullOrAll_SentToOrganization(ClientType? clientType,
SutProvider<NotificationHubPushNotificationService> sutProvider, Guid organizationId, PushType pushType,
string payload, string identifier)
{
await sutProvider.Sut.SendPayloadToOrganizationAsync(organizationId.ToString(), pushType, payload, identifier,
null, clientType);
await AssertSendTemplateNotificationAsync(sutProvider, pushType, payload,
$"(template:payload && organizationId:{organizationId} && !deviceIdentifier:{identifier})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Mobile)]
[BitAutoData(ClientType.Web)]
public async Task SendPayloadToOrganizationAsync_ClientTypeExplicit_SentToOrganizationAndClientType(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider, Guid organizationId,
PushType pushType, string payload, string identifier)
{
await sutProvider.Sut.SendPayloadToOrganizationAsync(organizationId.ToString(), pushType, payload, identifier,
null, clientType);
await AssertSendTemplateNotificationAsync(sutProvider, pushType, payload,
$"(template:payload && organizationId:{organizationId} && !deviceIdentifier:{identifier} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData([null])]
[BitAutoData(ClientType.All)]
public async Task SendPayloadToInstallationAsync_ClientTypeNullOrAll_SentToInstallation(ClientType? clientType,
SutProvider<NotificationHubPushNotificationService> sutProvider, Guid installationId, PushType pushType,
string payload, string identifier)
{
await sutProvider.Sut.SendPayloadToInstallationAsync(installationId.ToString(), pushType, payload, identifier,
null, clientType);
await AssertSendTemplateNotificationAsync(sutProvider, pushType, payload,
$"(template:payload && installationId:{installationId} && !deviceIdentifier:{identifier})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Mobile)]
[BitAutoData(ClientType.Web)]
public async Task SendPayloadToInstallationAsync_ClientTypeExplicit_SentToInstallationAndClientType(
ClientType clientType, SutProvider<NotificationHubPushNotificationService> sutProvider, Guid installationId,
PushType pushType, string payload, string identifier)
{
await sutProvider.Sut.SendPayloadToInstallationAsync(installationId.ToString(), pushType, payload, identifier,
null, clientType);
await AssertSendTemplateNotificationAsync(sutProvider, pushType, payload,
$"(template:payload && installationId:{installationId} && !deviceIdentifier:{identifier} && clientType:{clientType})");
await sutProvider.GetDependency<IInstallationDeviceRepository>()
.Received(0)
.UpsertAsync(Arg.Any<InstallationDeviceEntity>());
}
[Fact] [Fact]
public async Task PushSyncCipherCreateAsync_SendExpectedData() public async Task PushSyncCipherCreateAsync_SendExpectedData()
{ {
@ -1066,7 +588,7 @@ public class NotificationHubPushNotificationServiceTests
); );
} }
private async Task VerifyNotificationAsync(Func<NotificationHubPushNotificationService, Task> test, private async Task VerifyNotificationAsync(Func<IPushNotificationService, Task> test,
PushType type, JsonNode expectedPayload, string tag) PushType type, JsonNode expectedPayload, string tag)
{ {
var installationDeviceRepository = Substitute.For<IInstallationDeviceRepository>(); var installationDeviceRepository = Substitute.For<IInstallationDeviceRepository>();
@ -1104,12 +626,11 @@ public class NotificationHubPushNotificationServiceTests
notificationHubPool, notificationHubPool,
httpContextAccessor, httpContextAccessor,
NullLogger<NotificationHubPushNotificationService>.Instance, NullLogger<NotificationHubPushNotificationService>.Instance,
globalSettings, globalSettings
fakeTimeProvider
); );
// Act // Act
await test(sut); await test(new EngineWrapper(sut, fakeTimeProvider, _installationId));
// Assert // Assert
var calls = notificationHubProxy.ReceivedCalls(); var calls = notificationHubProxy.ReceivedCalls();

View File

@ -9,14 +9,11 @@ using Bit.Core.Enums;
using Bit.Core.Models; using Bit.Core.Models;
using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Enums; using Bit.Core.NotificationCenter.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal; using Bit.Core.Platform.Push.Internal;
using Bit.Core.Settings;
using Bit.Core.Test.AutoFixture; using Bit.Core.Test.AutoFixture;
using Bit.Core.Test.AutoFixture.CurrentContextFixtures;
using Bit.Core.Test.NotificationCenter.AutoFixture;
using Bit.Core.Tools.Entities; using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -42,96 +39,6 @@ public class AzureQueuePushNotificationServiceTests
_fakeTimeProvider.SetUtcNow(DateTime.UtcNow); _fakeTimeProvider.SetUtcNow(DateTime.UtcNow);
} }
[Theory]
[BitAutoData]
[NotificationCustomize]
[CurrentContextCustomize]
public async Task PushNotificationAsync_NotificationGlobal_Sent(
SutProvider<AzureQueuePushNotificationService> sutProvider, Notification notification, Guid deviceIdentifier,
ICurrentContext currentContext, Guid installationId)
{
currentContext.DeviceIdentifier.Returns(deviceIdentifier.ToString());
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext!.RequestServices
.GetService(Arg.Any<Type>()).Returns(currentContext);
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
await sutProvider.Sut.PushNotificationAsync(notification);
await sutProvider.GetDependency<QueueClient>().Received(1)
.SendMessageAsync(Arg.Is<string>(message =>
MatchMessage(PushType.Notification, message,
new NotificationPushNotificationEquals(notification, null, installationId),
deviceIdentifier.ToString())));
}
[Theory]
[BitAutoData]
[NotificationCustomize(false)]
[CurrentContextCustomize]
public async Task PushNotificationAsync_NotificationNotGlobal_Sent(
SutProvider<AzureQueuePushNotificationService> sutProvider, Notification notification, Guid deviceIdentifier,
ICurrentContext currentContext, Guid installationId)
{
currentContext.DeviceIdentifier.Returns(deviceIdentifier.ToString());
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext!.RequestServices
.GetService(Arg.Any<Type>()).Returns(currentContext);
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
await sutProvider.Sut.PushNotificationAsync(notification);
await sutProvider.GetDependency<QueueClient>().Received(1)
.SendMessageAsync(Arg.Is<string>(message =>
MatchMessage(PushType.Notification, message,
new NotificationPushNotificationEquals(notification, null, null),
deviceIdentifier.ToString())));
}
[Theory]
[BitAutoData]
[NotificationCustomize]
[NotificationStatusCustomize]
[CurrentContextCustomize]
public async Task PushNotificationStatusAsync_NotificationGlobal_Sent(
SutProvider<AzureQueuePushNotificationService> sutProvider, Notification notification, Guid deviceIdentifier,
ICurrentContext currentContext, NotificationStatus notificationStatus, Guid installationId)
{
currentContext.DeviceIdentifier.Returns(deviceIdentifier.ToString());
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext!.RequestServices
.GetService(Arg.Any<Type>()).Returns(currentContext);
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await sutProvider.GetDependency<QueueClient>().Received(1)
.SendMessageAsync(Arg.Is<string>(message =>
MatchMessage(PushType.NotificationStatus, message,
new NotificationPushNotificationEquals(notification, notificationStatus, installationId),
deviceIdentifier.ToString())));
}
[Theory]
[BitAutoData]
[NotificationCustomize(false)]
[NotificationStatusCustomize]
[CurrentContextCustomize]
public async Task PushNotificationStatusAsync_NotificationNotGlobal_Sent(
SutProvider<AzureQueuePushNotificationService> sutProvider, Notification notification, Guid deviceIdentifier,
ICurrentContext currentContext, NotificationStatus notificationStatus, Guid installationId)
{
currentContext.DeviceIdentifier.Returns(deviceIdentifier.ToString());
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext!.RequestServices
.GetService(Arg.Any<Type>()).Returns(currentContext);
sutProvider.GetDependency<IGlobalSettings>().Installation.Id = installationId;
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await sutProvider.GetDependency<QueueClient>().Received(1)
.SendMessageAsync(Arg.Is<string>(message =>
MatchMessage(PushType.NotificationStatus, message,
new NotificationPushNotificationEquals(notification, notificationStatus, null),
deviceIdentifier.ToString())));
}
[Theory] [Theory]
[InlineData("6a5bbe1b-cf16-49a6-965f-5c2eac56a531", null)] [InlineData("6a5bbe1b-cf16-49a6-965f-5c2eac56a531", null)]
[InlineData(null, "b9a3fcb4-2447-45c1-aad2-24de43c88c44")] [InlineData(null, "b9a3fcb4-2447-45c1-aad2-24de43c88c44")]
@ -844,7 +751,7 @@ public class AzureQueuePushNotificationServiceTests
// ); // );
// } // }
private async Task VerifyNotificationAsync(Func<AzureQueuePushNotificationService, Task> test, JsonNode expectedMessage) private async Task VerifyNotificationAsync(Func<IPushNotificationService, Task> test, JsonNode expectedMessage)
{ {
var queueClient = Substitute.For<QueueClient>(); var queueClient = Substitute.For<QueueClient>();
@ -872,7 +779,7 @@ public class AzureQueuePushNotificationServiceTests
_fakeTimeProvider _fakeTimeProvider
); );
await test(sut); await test(new EngineWrapper(sut, _fakeTimeProvider, _globalSettings.Installation.Id));
// Hoist equality checker outside the expression so that we // Hoist equality checker outside the expression so that we
// can more easily place a breakpoint // can more easily place a breakpoint

View File

@ -1,98 +1,8 @@
#nullable enable #nullable enable
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Platform.Push;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Test.NotificationCenter.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Push.Services; namespace Bit.Core.Test.Platform.Push.Services;
[SutProviderCustomize]
public class MultiServicePushNotificationServiceTests public class MultiServicePushNotificationServiceTests
{ {
[Theory] // TODO: Can add a couple tests here
[BitAutoData]
[NotificationCustomize]
public async Task PushNotificationAsync_Notification_Sent(
SutProvider<MultiServicePushNotificationService> sutProvider, Notification notification)
{
await sutProvider.Sut.PushNotificationAsync(notification);
await sutProvider.GetDependency<IEnumerable<IPushNotificationService>>()
.First()
.Received(1)
.PushNotificationAsync(notification);
}
[Theory]
[BitAutoData]
[NotificationCustomize]
[NotificationStatusCustomize]
public async Task PushNotificationStatusAsync_Notification_Sent(
SutProvider<MultiServicePushNotificationService> sutProvider, Notification notification,
NotificationStatus notificationStatus)
{
await sutProvider.Sut.PushNotificationStatusAsync(notification, notificationStatus);
await sutProvider.GetDependency<IEnumerable<IPushNotificationService>>()
.First()
.Received(1)
.PushNotificationStatusAsync(notification, notificationStatus);
}
[Theory]
[BitAutoData([null, null])]
[BitAutoData(ClientType.All, null)]
[BitAutoData([null, "test device id"])]
[BitAutoData(ClientType.All, "test device id")]
public async Task SendPayloadToUserAsync_Message_Sent(ClientType? clientType, string? deviceId, string userId,
PushType type, object payload, string identifier, SutProvider<MultiServicePushNotificationService> sutProvider)
{
await sutProvider.Sut.SendPayloadToUserAsync(userId, type, payload, identifier, deviceId, clientType);
await sutProvider.GetDependency<IEnumerable<IPushNotificationService>>()
.First()
.Received(1)
.SendPayloadToUserAsync(userId, type, payload, identifier, deviceId, clientType);
}
[Theory]
[BitAutoData([null, null])]
[BitAutoData(ClientType.All, null)]
[BitAutoData([null, "test device id"])]
[BitAutoData(ClientType.All, "test device id")]
public async Task SendPayloadToOrganizationAsync_Message_Sent(ClientType? clientType, string? deviceId,
string organizationId, PushType type, object payload, string identifier,
SutProvider<MultiServicePushNotificationService> sutProvider)
{
await sutProvider.Sut.SendPayloadToOrganizationAsync(organizationId, type, payload, identifier, deviceId,
clientType);
await sutProvider.GetDependency<IEnumerable<IPushNotificationService>>()
.First()
.Received(1)
.SendPayloadToOrganizationAsync(organizationId, type, payload, identifier, deviceId, clientType);
}
[Theory]
[BitAutoData([null, null])]
[BitAutoData(ClientType.All, null)]
[BitAutoData([null, "test device id"])]
[BitAutoData(ClientType.All, "test device id")]
public async Task SendPayloadToInstallationAsync_Message_Sent(ClientType? clientType, string? deviceId,
string installationId, PushType type, object payload, string identifier,
SutProvider<MultiServicePushNotificationService> sutProvider)
{
await sutProvider.Sut.SendPayloadToInstallationAsync(installationId, type, payload, identifier, deviceId,
clientType);
await sutProvider.GetDependency<IEnumerable<IPushNotificationService>>()
.First()
.Received(1)
.SendPayloadToInstallationAsync(installationId, type, payload, identifier, deviceId, clientType);
}
} }

View File

@ -19,14 +19,13 @@ public class NotificationsApiPushNotificationServiceTests : PushTestBase
protected override string ExpectedClientUrl() => "https://localhost:7777/send"; protected override string ExpectedClientUrl() => "https://localhost:7777/send";
protected override IPushNotificationService CreateService() protected override IPushEngine CreateService()
{ {
return new NotificationsApiPushNotificationService( return new NotificationsApiPushNotificationService(
HttpClientFactory, HttpClientFactory,
GlobalSettings, GlobalSettings,
HttpContextAccessor, HttpContextAccessor,
NullLogger<NotificationsApiPushNotificationService>.Instance, NullLogger<NotificationsApiPushNotificationService>.Instance
FakeTimeProvider
); );
} }
@ -221,7 +220,7 @@ public class NotificationsApiPushNotificationServiceTests : PushTestBase
["UserId"] = send.UserId, ["UserId"] = send.UserId,
["RevisionDate"] = send.RevisionDate, ["RevisionDate"] = send.RevisionDate,
}, },
["ContextId"] = null, ["ContextId"] = DeviceIdentifier,
}; };
} }
@ -236,7 +235,7 @@ public class NotificationsApiPushNotificationServiceTests : PushTestBase
["UserId"] = send.UserId, ["UserId"] = send.UserId,
["RevisionDate"] = send.RevisionDate, ["RevisionDate"] = send.RevisionDate,
}, },
["ContextId"] = null, ["ContextId"] = DeviceIdentifier,
}; };
} }
@ -251,7 +250,7 @@ public class NotificationsApiPushNotificationServiceTests : PushTestBase
["UserId"] = send.UserId, ["UserId"] = send.UserId,
["RevisionDate"] = send.RevisionDate, ["RevisionDate"] = send.RevisionDate,
}, },
["ContextId"] = null, ["ContextId"] = DeviceIdentifier,
}; };
} }

View File

@ -15,11 +15,28 @@ using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using NSubstitute; using NSubstitute;
using RichardSzalay.MockHttp; using RichardSzalay.MockHttp;
using Xunit; using Xunit;
public class EngineWrapper(IPushEngine pushEngine, FakeTimeProvider fakeTimeProvider, Guid installationId) : IPushNotificationService
{
public Guid InstallationId { get; } = installationId;
public TimeProvider TimeProvider { get; } = fakeTimeProvider;
public ILogger Logger => NullLogger<EngineWrapper>.Instance;
public Task PushAsync<T>(PushNotification<T> pushNotification) where T : class
=> pushEngine.PushAsync(pushNotification);
public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable<Guid>? collectionIds)
=> pushEngine.PushCipherAsync(cipher, pushType, collectionIds);
}
public abstract class PushTestBase public abstract class PushTestBase
{ {
protected static readonly string DeviceIdentifier = "test_device_identifier"; protected static readonly string DeviceIdentifier = "test_device_identifier";
@ -51,7 +68,7 @@ public abstract class PushTestBase
FakeTimeProvider.SetUtcNow(DateTimeOffset.UtcNow); FakeTimeProvider.SetUtcNow(DateTimeOffset.UtcNow);
} }
protected abstract IPushNotificationService CreateService(); protected abstract IPushEngine CreateService();
protected abstract string ExpectedClientUrl(); protected abstract string ExpectedClientUrl();
@ -480,7 +497,7 @@ public abstract class PushTestBase
}) })
.Respond(HttpStatusCode.OK); .Respond(HttpStatusCode.OK);
await test(CreateService()); await test(new EngineWrapper(CreateService(), FakeTimeProvider, GlobalSettings.Installation.Id));
Assert.NotNull(actualNode); Assert.NotNull(actualNode);

Some files were not shown because too many files have changed in this diff Show More