mirror of
https://github.com/bitwarden/server.git
synced 2025-06-18 10:03:50 -05:00
Merge branch 'main' into pm-18699-add-trial-path-to-stripe-metadata
This commit is contained in:
commit
1579a490fe
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@ -350,14 +350,6 @@ jobs:
|
|||||||
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
|
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
|
||||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||||
|
|
||||||
- name: Make Docker stub checksums
|
|
||||||
if: |
|
|
||||||
github.event_name != 'pull_request'
|
|
||||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
|
||||||
run: |
|
|
||||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
|
||||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
|
||||||
|
|
||||||
- name: Upload Docker stub US artifact
|
- name: Upload Docker stub US artifact
|
||||||
if: |
|
if: |
|
||||||
github.event_name != 'pull_request'
|
github.event_name != 'pull_request'
|
||||||
@ -378,26 +370,6 @@ jobs:
|
|||||||
path: docker-stub-EU.zip
|
path: docker-stub-EU.zip
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload Docker stub US checksum artifact
|
|
||||||
if: |
|
|
||||||
github.event_name != 'pull_request'
|
|
||||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
|
||||||
with:
|
|
||||||
name: docker-stub-US-sha256.txt
|
|
||||||
path: docker-stub-US-sha256.txt
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Upload Docker stub EU checksum artifact
|
|
||||||
if: |
|
|
||||||
github.event_name != 'pull_request'
|
|
||||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
|
||||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
|
||||||
with:
|
|
||||||
name: docker-stub-EU-sha256.txt
|
|
||||||
path: docker-stub-EU-sha256.txt
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Build Public API Swagger
|
- name: Build Public API Swagger
|
||||||
run: |
|
run: |
|
||||||
cd ./src/Api
|
cd ./src/Api
|
||||||
|
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@ -17,6 +17,9 @@ on:
|
|||||||
env:
|
env:
|
||||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
setup:
|
setup:
|
||||||
name: Setup
|
name: Setup
|
||||||
@ -65,9 +68,7 @@ jobs:
|
|||||||
workflow_conclusion: success
|
workflow_conclusion: success
|
||||||
branch: ${{ needs.setup.outputs.branch-name }}
|
branch: ${{ needs.setup.outputs.branch-name }}
|
||||||
artifacts: "docker-stub-US.zip,
|
artifacts: "docker-stub-US.zip,
|
||||||
docker-stub-US-sha256.txt,
|
|
||||||
docker-stub-EU.zip,
|
docker-stub-EU.zip,
|
||||||
docker-stub-EU-sha256.txt,
|
|
||||||
swagger.json"
|
swagger.json"
|
||||||
|
|
||||||
- name: Dry Run - Download latest release Docker stubs
|
- name: Dry Run - Download latest release Docker stubs
|
||||||
@ -78,9 +79,7 @@ jobs:
|
|||||||
workflow_conclusion: success
|
workflow_conclusion: success
|
||||||
branch: main
|
branch: main
|
||||||
artifacts: "docker-stub-US.zip,
|
artifacts: "docker-stub-US.zip,
|
||||||
docker-stub-US-sha256.txt,
|
|
||||||
docker-stub-EU.zip,
|
docker-stub-EU.zip,
|
||||||
docker-stub-EU-sha256.txt,
|
|
||||||
swagger.json"
|
swagger.json"
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
@ -88,9 +87,7 @@ jobs:
|
|||||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
||||||
with:
|
with:
|
||||||
artifacts: "docker-stub-US.zip,
|
artifacts: "docker-stub-US.zip,
|
||||||
docker-stub-US-sha256.txt,
|
|
||||||
docker-stub-EU.zip,
|
docker-stub-EU.zip,
|
||||||
docker-stub-EU-sha256.txt,
|
|
||||||
swagger.json"
|
swagger.json"
|
||||||
commit: ${{ github.sha }}
|
commit: ${{ github.sha }}
|
||||||
tag: "v${{ needs.setup.outputs.release_version }}"
|
tag: "v${{ needs.setup.outputs.release_version }}"
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2025.6.0</Version>
|
<Version>2025.6.2</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
@ -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")]
|
||||||
@ -521,7 +520,9 @@ public class OrganizationUsersController : Controller
|
|||||||
.Concat(readonlyCollectionAccess)
|
.Concat(readonlyCollectionAccess)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), userId,
|
var existingUserType = organizationUser.Type;
|
||||||
|
|
||||||
|
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId,
|
||||||
collectionsToSave, groupsToSave);
|
collectionsToSave, groupsToSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -177,9 +177,10 @@ public class MembersController : Controller
|
|||||||
{
|
{
|
||||||
return new NotFoundResult();
|
return new NotFoundResult();
|
||||||
}
|
}
|
||||||
|
var existingUserType = existingUser.Type;
|
||||||
var updatedUser = model.ToOrganizationUser(existingUser);
|
var updatedUser = model.ToOrganizationUser(existingUser);
|
||||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
|
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
|
||||||
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, null, associations, model.Groups);
|
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
|
||||||
MemberResponseModel response = null;
|
MemberResponseModel response = null;
|
||||||
if (existingUser.UserId.HasValue)
|
if (existingUser.UserId.HasValue)
|
||||||
{
|
{
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Interfaces;
|
using Bit.Core.AdminConsole.Interfaces;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models;
|
using Bit.Core.Models;
|
||||||
@ -9,23 +10,75 @@ using Bit.Core.Utilities;
|
|||||||
|
|
||||||
namespace Bit.Core.Entities;
|
namespace Bit.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An association table between one <see cref="User"/> and one <see cref="Organization"/>, representing that user's
|
||||||
|
/// membership in the organization. "Member" refers to the OrganizationUser object.
|
||||||
|
/// </summary>
|
||||||
public class OrganizationUser : ITableObject<Guid>, IExternal, IOrganizationUser
|
public class OrganizationUser : ITableObject<Guid>, IExternal, IOrganizationUser
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A unique random identifier.
|
||||||
|
/// </summary>
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the Organization that the user is a member of.
|
||||||
|
/// </summary>
|
||||||
public Guid OrganizationId { get; set; }
|
public Guid OrganizationId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the User that is the member. This is NULL if the Status is Invited (or Invited and then Revoked), because
|
||||||
|
/// it is not linked to a specific User yet.
|
||||||
|
/// </summary>
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The email address of the user invited to the organization. This is NULL if the Status is not Invited (or
|
||||||
|
/// Invited and then Revoked), because in that case the OrganizationUser is linked to a User
|
||||||
|
/// and the email is stored on the User object.
|
||||||
|
/// </summary>
|
||||||
[MaxLength(256)]
|
[MaxLength(256)]
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The Organization symmetric key encrypted with the User's public key. NULL if the user is not in a Confirmed
|
||||||
|
/// (or Confirmed and then Revoked) status.
|
||||||
|
/// </summary>
|
||||||
public string? Key { get; set; }
|
public string? Key { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The User's symmetric key encrypted with the Organization's public key. NULL if the OrganizationUser
|
||||||
|
/// is not enrolled in account recovery.
|
||||||
|
/// </summary>
|
||||||
public string? ResetPasswordKey { get; set; }
|
public string? ResetPasswordKey { get; set; }
|
||||||
|
/// <inheritdoc cref="OrganizationUserStatusType"/>
|
||||||
public OrganizationUserStatusType Status { get; set; }
|
public OrganizationUserStatusType Status { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The User's role in the Organization.
|
||||||
|
/// </summary>
|
||||||
public OrganizationUserType Type { get; set; }
|
public OrganizationUserType Type { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector
|
||||||
|
/// and SCIM.
|
||||||
|
/// </summary>
|
||||||
[MaxLength(300)]
|
[MaxLength(300)]
|
||||||
public string? ExternalId { get; set; }
|
public string? ExternalId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization.
|
||||||
|
/// </summary>
|
||||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||||
|
/// <summary>
|
||||||
|
/// The last date the OrganizationUser entry was updated.
|
||||||
|
/// </summary>
|
||||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||||
|
/// <summary>
|
||||||
|
/// A json blob representing the <see cref="Bit.Core.Models.Data.Permissions"/> of the OrganizationUser if they
|
||||||
|
/// are a Custom user role (i.e. the <see cref="OrganizationUserType"/> is Custom). MAY be NULL if they are not
|
||||||
|
/// a custom user, but this is not guaranteed; do not use this to determine their role.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Avoid using this property directly - instead use the <see cref="GetPermissions"/> and <see cref="SetPermissions"/>
|
||||||
|
/// helper methods.
|
||||||
|
/// </remarks>
|
||||||
public string? Permissions { get; set; }
|
public string? Permissions { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// True if the User has access to Secrets Manager for this Organization, false otherwise.
|
||||||
|
/// </summary>
|
||||||
public bool AccessSecretsManager { get; set; }
|
public bool AccessSecretsManager { get; set; }
|
||||||
|
|
||||||
public void SetNewId()
|
public void SetNewId()
|
||||||
|
@ -1,9 +1,34 @@
|
|||||||
namespace Bit.Core.Enums;
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the different stages of a member's lifecycle in an organization.
|
||||||
|
/// The <see cref="OrganizationUser"/> object is populated differently depending on their Status.
|
||||||
|
/// </summary>
|
||||||
public enum OrganizationUserStatusType : short
|
public enum OrganizationUserStatusType : short
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The OrganizationUser entry only represents an invitation to join the organization. It is not linked to a
|
||||||
|
/// specific User yet.
|
||||||
|
/// </summary>
|
||||||
Invited = 0,
|
Invited = 0,
|
||||||
|
/// <summary>
|
||||||
|
/// The User has accepted the invitation and linked their User account to the OrganizationUser entry.
|
||||||
|
/// </summary>
|
||||||
Accepted = 1,
|
Accepted = 1,
|
||||||
|
/// <summary>
|
||||||
|
/// An administrator has granted the User access to the organization. This is the final step in the User becoming
|
||||||
|
/// a "full" member of the organization, including a key exchange so that they can decrypt organization data.
|
||||||
|
/// </summary>
|
||||||
Confirmed = 2,
|
Confirmed = 2,
|
||||||
|
/// <summary>
|
||||||
|
/// The OrganizationUser has been revoked from the organization and cannot access organization data while in this state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An OrganizationUser may move into this status from any other status, and will move back to their original status
|
||||||
|
/// if restored. This allows an administrator to easily suspend and restore access without going through the
|
||||||
|
/// Invite flow again.
|
||||||
|
/// </remarks>
|
||||||
Revoked = -1,
|
Revoked = -1,
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
{
|
{
|
@ -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
|
||||||
{
|
{
|
@ -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
|
||||||
{
|
{
|
@ -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)
|
||||||
{
|
{
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Models.Data;
|
using Bit.Core.Models.Data;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||||
|
|
||||||
public interface IUpdateOrganizationUserCommand
|
public interface IUpdateOrganizationUserCommand
|
||||||
{
|
{
|
||||||
Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, Guid? savingUserId,
|
||||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess);
|
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess);
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||||
@ -83,14 +84,9 @@ public class InviteUsersPasswordManagerValidator(
|
|||||||
return invalidEnvironment.Map(request);
|
return invalidEnvironment.Map(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
// Organizations managed by a provider need to be scaled by the provider. This needs to be checked in the event seats are increasing.
|
||||||
|
|
||||||
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
|
||||||
{
|
|
||||||
return organizationValidation.Map(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
||||||
|
|
||||||
if (provider is not null)
|
if (provider is not null)
|
||||||
{
|
{
|
||||||
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
||||||
@ -101,6 +97,13 @@ public class InviteUsersPasswordManagerValidator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||||
|
|
||||||
|
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||||
|
{
|
||||||
|
return organizationValidation.Map(request);
|
||||||
|
}
|
||||||
|
|
||||||
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
||||||
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
||||||
|
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
|
||||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
|
|
||||||
public static class InviteUserPaymentValidation
|
public static class InviteUserPaymentValidation
|
||||||
{
|
{
|
||||||
|
@ -55,11 +55,13 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
|||||||
/// Update an organization user.
|
/// Update an organization user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="organizationUser">The modified organization user to save.</param>
|
/// <param name="organizationUser">The modified organization user to save.</param>
|
||||||
|
/// <param name="existingUserType">The current type (member role) of the user.</param>
|
||||||
/// <param name="savingUserId">The userId of the currently logged in user who is making the change.</param>
|
/// <param name="savingUserId">The userId of the currently logged in user who is making the change.</param>
|
||||||
/// <param name="collectionAccess">The user's updated collection access. If set to null, this removes all collection access.</param>
|
/// <param name="collectionAccess">The user's updated collection access. If set to null, this removes all collection access.</param>
|
||||||
/// <param name="groupAccess">The user's updated group access. If set to null, groups are not updated.</param>
|
/// <param name="groupAccess">The user's updated group access. If set to null, groups are not updated.</param>
|
||||||
/// <exception cref="BadRequestException"></exception>
|
/// <exception cref="BadRequestException"></exception>
|
||||||
public async Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
public async Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType,
|
||||||
|
Guid? savingUserId,
|
||||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess)
|
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess)
|
||||||
{
|
{
|
||||||
// Avoid multiple enumeration
|
// Avoid multiple enumeration
|
||||||
@ -83,15 +85,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organizationUser.UserId.HasValue && organization.PlanType == PlanType.Free && organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner)
|
await EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(organizationUser, existingUserType, organization);
|
||||||
{
|
|
||||||
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
|
||||||
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(organizationUser.UserId.Value);
|
|
||||||
if (adminCount > 0)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("User can only be an admin of one free organization.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (collectionAccessList.Count != 0)
|
if (collectionAccessList.Count != 0)
|
||||||
{
|
{
|
||||||
@ -151,6 +145,40 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
|||||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated);
|
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(OrganizationUser updatedOrgUser, OrganizationUserType existingUserType, Entities.Organization organization)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (organization.PlanType != PlanType.Free)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!updatedOrgUser.UserId.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updatedOrgUser.Type is not (OrganizationUserType.Admin or OrganizationUserType.Owner))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
||||||
|
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(updatedOrgUser.UserId!.Value);
|
||||||
|
|
||||||
|
var isCurrentAdminOrOwner = existingUserType is OrganizationUserType.Admin or OrganizationUserType.Owner;
|
||||||
|
|
||||||
|
if (isCurrentAdminOrOwner && adminCount <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentAdminOrOwner && adminCount == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException("User can only be an admin of one free organization.");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser,
|
private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser,
|
||||||
ICollection<CollectionAccessSelection> collectionAccess)
|
ICollection<CollectionAccessSelection> collectionAccess)
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -104,8 +104,8 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
|
|||||||
throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages));
|
throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x =>
|
await Task.WhenAll(nonCompliantUsers.Select(nonCompliantUser =>
|
||||||
_mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email)));
|
_mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), nonCompliantUser.user.Email)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MembersWithNoMasterPasswordWillLoseAccess(
|
private static bool MembersWithNoMasterPasswordWillLoseAccess(
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -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;
|
||||||
|
|
@ -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;
|
@ -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.
|
@ -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;
|
@ -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;
|
@ -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;
|
||||||
|
|
@ -3,7 +3,7 @@
|
|||||||
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
|
||||||
|
|
@ -112,6 +112,7 @@ public static class FeatureFlagKeys
|
|||||||
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 +182,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";
|
||||||
|
|
||||||
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
@ -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);
|
||||||
|
}
|
@ -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>();
|
||||||
|
@ -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; }
|
||||||
}
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||||
|
|
||||||
|
public class RiskInsightsReportRequest
|
||||||
|
{
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,6 @@ using Bit.Core.AdminConsole.Repositories;
|
|||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models;
|
using Bit.Core.Auth.Models;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Constants;
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.Billing.Models;
|
||||||
@ -29,12 +28,9 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
|||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Tokens;
|
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Core.Vault.Repositories;
|
|
||||||
using Fido2NetLib;
|
using Fido2NetLib;
|
||||||
using Fido2NetLib.Objects;
|
using Fido2NetLib.Objects;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -44,12 +40,11 @@ using JsonSerializer = System.Text.Json.JsonSerializer;
|
|||||||
|
|
||||||
namespace Bit.Core.Services;
|
namespace Bit.Core.Services;
|
||||||
|
|
||||||
public class UserService : UserManager<User>, IUserService, IDisposable
|
public class UserService : UserManager<User>, IUserService
|
||||||
{
|
{
|
||||||
private const string PremiumPlanId = "premium-annually";
|
private const string PremiumPlanId = "premium-annually";
|
||||||
|
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ICipherRepository _cipherRepository;
|
|
||||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
private readonly IOrganizationRepository _organizationRepository;
|
private readonly IOrganizationRepository _organizationRepository;
|
||||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||||
@ -65,17 +60,14 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
private readonly IPaymentService _paymentService;
|
private readonly IPaymentService _paymentService;
|
||||||
private readonly IPolicyRepository _policyRepository;
|
private readonly IPolicyRepository _policyRepository;
|
||||||
private readonly IPolicyService _policyService;
|
private readonly IPolicyService _policyService;
|
||||||
private readonly IDataProtector _organizationServiceDataProtector;
|
|
||||||
private readonly IFido2 _fido2;
|
private readonly IFido2 _fido2;
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IGlobalSettings _globalSettings;
|
private readonly IGlobalSettings _globalSettings;
|
||||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||||
private readonly IProviderUserRepository _providerUserRepository;
|
private readonly IProviderUserRepository _providerUserRepository;
|
||||||
private readonly IStripeSyncService _stripeSyncService;
|
private readonly IStripeSyncService _stripeSyncService;
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IPremiumUserBillingService _premiumUserBillingService;
|
private readonly IPremiumUserBillingService _premiumUserBillingService;
|
||||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
|
||||||
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
|
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
private readonly IDistributedCache _distributedCache;
|
private readonly IDistributedCache _distributedCache;
|
||||||
@ -83,7 +75,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
|
|
||||||
public UserService(
|
public UserService(
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ICipherRepository cipherRepository,
|
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IOrganizationDomainRepository organizationDomainRepository,
|
IOrganizationDomainRepository organizationDomainRepository,
|
||||||
@ -101,7 +92,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
ILicensingService licenseService,
|
ILicensingService licenseService,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IApplicationCacheService applicationCacheService,
|
IApplicationCacheService applicationCacheService,
|
||||||
IDataProtectionProvider dataProtectionProvider,
|
|
||||||
IPaymentService paymentService,
|
IPaymentService paymentService,
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
IPolicyService policyService,
|
IPolicyService policyService,
|
||||||
@ -111,10 +101,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
IStripeSyncService stripeSyncService,
|
IStripeSyncService stripeSyncService,
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
IPremiumUserBillingService premiumUserBillingService,
|
IPremiumUserBillingService premiumUserBillingService,
|
||||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
|
||||||
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand,
|
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IDistributedCache distributedCache,
|
IDistributedCache distributedCache,
|
||||||
@ -131,7 +119,6 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
logger)
|
logger)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_cipherRepository = cipherRepository;
|
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationDomainRepository = organizationDomainRepository;
|
_organizationDomainRepository = organizationDomainRepository;
|
||||||
@ -147,18 +134,14 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
_paymentService = paymentService;
|
_paymentService = paymentService;
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
_policyService = policyService;
|
||||||
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
|
|
||||||
"OrganizationServiceDataProtector");
|
|
||||||
_fido2 = fido2;
|
_fido2 = fido2;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_globalSettings = globalSettings;
|
_globalSettings = globalSettings;
|
||||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||||
_providerUserRepository = providerUserRepository;
|
_providerUserRepository = providerUserRepository;
|
||||||
_stripeSyncService = stripeSyncService;
|
_stripeSyncService = stripeSyncService;
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_premiumUserBillingService = premiumUserBillingService;
|
_premiumUserBillingService = premiumUserBillingService;
|
||||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
|
||||||
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
|
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
_distributedCache = distributedCache;
|
_distributedCache = distributedCache;
|
||||||
|
@ -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
|
||||||
{
|
{
|
||||||
|
@ -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)
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -8,6 +8,16 @@ namespace Bit.Icons.Controllers;
|
|||||||
[Route("")]
|
[Route("")]
|
||||||
public class IconsController : Controller
|
public class IconsController : Controller
|
||||||
{
|
{
|
||||||
|
// Basic bwi-globe icon
|
||||||
|
private static readonly byte[] _notFoundImage = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUg" +
|
||||||
|
"AAABMAAAATCAQAAADYWf5HAAABu0lEQVR42nXSvWuTURTH8R+t0heI9Y04aJycdBLNJNrBFBU7OFgUER3q21I0bXK+JwZ" +
|
||||||
|
"pXISm/QdcRB3EgqBBsNihsUbbgODQQSKCuKSDOApJuuhj8tCYQj/jvYfD795z1MZ+nBKrNKhSwrMxbZTrtRnqlEjZkB/x" +
|
||||||
|
"C/xmhZrlc71qS0Up8yVzTCGucFNKD1JhORVd70SZNU4okNx5d4+U2UXRIpJFWLClsR79YzN88wQvLWNzzPKEeS/wkQGpW" +
|
||||||
|
"VhhqhW8TtDJD3Mm1x/23zLSrZCdpBY8BueTNjHSbc+8wC9HlHgU5Aj5AW5zPdcVdpq0UcknWBSr/pjixO4gfp899Kd23p" +
|
||||||
|
"M2qQCH7LkCnqAqGh73OK/8NPOcaibr90LrW/yWAnaUhqjaOSl9nFR2r5rsqo22ypn1B5IN8VOUMHVgOnNQIX+d62plcz6" +
|
||||||
|
"rg1/jskK8CMb4we4pG6OWHtR/LBJkC2E4a7ZPkuX5ntumAOM2xxveclEhLvGH6XCmLPs735Eetrw63NnOgr9P9q1viC3x" +
|
||||||
|
"lRUGOjImqFDuOBvrYYoaZU9z1uPpYae5NfdvbNVG2ZjDIlXq/oMi46lo++4vjjPBl2Dlg00AAAAASUVORK5CYII=");
|
||||||
|
|
||||||
private readonly IMemoryCache _memoryCache;
|
private readonly IMemoryCache _memoryCache;
|
||||||
private readonly IDomainMappingService _domainMappingService;
|
private readonly IDomainMappingService _domainMappingService;
|
||||||
private readonly IIconFetchingService _iconFetchingService;
|
private readonly IIconFetchingService _iconFetchingService;
|
||||||
@ -89,7 +99,7 @@ public class IconsController : Controller
|
|||||||
|
|
||||||
if (icon == null)
|
if (icon == null)
|
||||||
{
|
{
|
||||||
return new NotFoundResult();
|
return new FileContentResult(_notFoundImage, "image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new FileContentResult(icon.Image, icon.Format);
|
return new FileContentResult(icon.Image, icon.Format);
|
||||||
|
@ -70,6 +70,7 @@ public static class DapperServiceCollectionExtensions
|
|||||||
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
||||||
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
||||||
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
|
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
|
||||||
|
services.AddSingleton<IOrganizationMemberBaseDetailRepository, OrganizationMemberBaseDetailRepository>();
|
||||||
|
|
||||||
if (selfHosted)
|
if (selfHosted)
|
||||||
{
|
{
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
@ -107,6 +108,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
|||||||
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
||||||
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
||||||
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
|
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
|
||||||
|
services.AddSingleton<IOrganizationMemberBaseDetailRepository, OrganizationMemberBaseDetailRepository>();
|
||||||
|
|
||||||
if (selfHosted)
|
if (selfHosted)
|
||||||
{
|
{
|
||||||
|
@ -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;
|
||||||
@ -80,6 +81,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; }
|
||||||
|
|
||||||
@ -112,6 +114,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
|
||||||
|
|
||||||
@ -134,6 +137,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);
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
)
|
@ -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;
|
||||||
|
@ -30,6 +30,7 @@ public class OrganizationUserControllerPutTests
|
|||||||
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
||||||
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
Put_Setup(sutProvider, organizationAbility, organizationUser, savingUserId, currentCollectionAccess: []);
|
Put_Setup(sutProvider, organizationAbility, organizationUser, savingUserId, currentCollectionAccess: []);
|
||||||
|
|
||||||
// Authorize all changes for basic happy path test
|
// Authorize all changes for basic happy path test
|
||||||
@ -41,15 +42,18 @@ public class OrganizationUserControllerPutTests
|
|||||||
// Save these for later - organizationUser object will be mutated
|
// Save these for later - organizationUser object will be mutated
|
||||||
var orgUserId = organizationUser.Id;
|
var orgUserId = organizationUser.Id;
|
||||||
var orgUserEmail = organizationUser.Email;
|
var orgUserEmail = organizationUser.Email;
|
||||||
|
var existingUserType = organizationUser.Type;
|
||||||
|
|
||||||
|
// Act
|
||||||
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
||||||
|
|
||||||
|
// Assert
|
||||||
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
||||||
ou.Type == model.Type &&
|
ou.Type == model.Type &&
|
||||||
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
||||||
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
||||||
ou.Id == orgUserId &&
|
ou.Id == orgUserId &&
|
||||||
ou.Email == orgUserEmail),
|
ou.Email == orgUserEmail), existingUserType,
|
||||||
savingUserId,
|
savingUserId,
|
||||||
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
||||||
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
||||||
@ -77,6 +81,7 @@ public class OrganizationUserControllerPutTests
|
|||||||
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
||||||
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
// Updating self
|
// Updating self
|
||||||
organizationUser.UserId = savingUserId;
|
organizationUser.UserId = savingUserId;
|
||||||
organizationAbility.AllowAdminAccessToAllCollectionItems = false;
|
organizationAbility.AllowAdminAccessToAllCollectionItems = false;
|
||||||
@ -88,15 +93,18 @@ public class OrganizationUserControllerPutTests
|
|||||||
|
|
||||||
var orgUserId = organizationUser.Id;
|
var orgUserId = organizationUser.Id;
|
||||||
var orgUserEmail = organizationUser.Email;
|
var orgUserEmail = organizationUser.Email;
|
||||||
|
var existingUserType = organizationUser.Type;
|
||||||
|
|
||||||
|
// Act
|
||||||
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
||||||
|
|
||||||
|
// Assert
|
||||||
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
||||||
ou.Type == model.Type &&
|
ou.Type == model.Type &&
|
||||||
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
||||||
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
||||||
ou.Id == orgUserId &&
|
ou.Id == orgUserId &&
|
||||||
ou.Email == orgUserEmail),
|
ou.Email == orgUserEmail), existingUserType,
|
||||||
savingUserId,
|
savingUserId,
|
||||||
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
||||||
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
||||||
@ -110,6 +118,7 @@ public class OrganizationUserControllerPutTests
|
|||||||
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
||||||
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
// Updating self
|
// Updating self
|
||||||
organizationUser.UserId = savingUserId;
|
organizationUser.UserId = savingUserId;
|
||||||
organizationAbility.AllowAdminAccessToAllCollectionItems = true;
|
organizationAbility.AllowAdminAccessToAllCollectionItems = true;
|
||||||
@ -121,15 +130,18 @@ public class OrganizationUserControllerPutTests
|
|||||||
|
|
||||||
var orgUserId = organizationUser.Id;
|
var orgUserId = organizationUser.Id;
|
||||||
var orgUserEmail = organizationUser.Email;
|
var orgUserEmail = organizationUser.Email;
|
||||||
|
var existingUserType = organizationUser.Type;
|
||||||
|
|
||||||
|
// Act
|
||||||
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
||||||
|
|
||||||
|
// Assert
|
||||||
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
||||||
ou.Type == model.Type &&
|
ou.Type == model.Type &&
|
||||||
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
||||||
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
||||||
ou.Id == orgUserId &&
|
ou.Id == orgUserId &&
|
||||||
ou.Email == orgUserEmail),
|
ou.Email == orgUserEmail), existingUserType,
|
||||||
savingUserId,
|
savingUserId,
|
||||||
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
||||||
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
cas.All(c => model.Collections.Any(m => m.Id == c.Id))),
|
||||||
@ -142,6 +154,7 @@ public class OrganizationUserControllerPutTests
|
|||||||
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
OrganizationUser organizationUser, OrganizationAbility organizationAbility,
|
||||||
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
SutProvider<OrganizationUsersController> sutProvider, Guid savingUserId)
|
||||||
{
|
{
|
||||||
|
// Arrange
|
||||||
var editedCollectionId = CoreHelpers.GenerateComb();
|
var editedCollectionId = CoreHelpers.GenerateComb();
|
||||||
var readonlyCollectionId1 = CoreHelpers.GenerateComb();
|
var readonlyCollectionId1 = CoreHelpers.GenerateComb();
|
||||||
var readonlyCollectionId2 = CoreHelpers.GenerateComb();
|
var readonlyCollectionId2 = CoreHelpers.GenerateComb();
|
||||||
@ -194,16 +207,19 @@ public class OrganizationUserControllerPutTests
|
|||||||
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Is<Collection>(c => c.Id == readonlyCollectionId1 || c.Id == readonlyCollectionId2),
|
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Is<Collection>(c => c.Id == readonlyCollectionId1 || c.Id == readonlyCollectionId2),
|
||||||
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs => reqs.Contains(BulkCollectionOperations.ModifyUserAccess)))
|
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs => reqs.Contains(BulkCollectionOperations.ModifyUserAccess)))
|
||||||
.Returns(AuthorizationResult.Failed());
|
.Returns(AuthorizationResult.Failed());
|
||||||
|
var existingUserType = organizationUser.Type;
|
||||||
|
|
||||||
|
// Act
|
||||||
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
await sutProvider.Sut.Put(organizationAbility.Id, organizationUser.Id, model);
|
||||||
|
|
||||||
|
// Assert
|
||||||
// Expect all collection access (modified and unmodified) to be saved
|
// Expect all collection access (modified and unmodified) to be saved
|
||||||
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
await sutProvider.GetDependency<IUpdateOrganizationUserCommand>().Received(1).UpdateUserAsync(Arg.Is<OrganizationUser>(ou =>
|
||||||
ou.Type == model.Type &&
|
ou.Type == model.Type &&
|
||||||
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
ou.Permissions == CoreHelpers.ClassToJsonData(model.Permissions) &&
|
||||||
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
ou.AccessSecretsManager == model.AccessSecretsManager &&
|
||||||
ou.Id == orgUserId &&
|
ou.Id == orgUserId &&
|
||||||
ou.Email == orgUserEmail),
|
ou.Email == orgUserEmail), existingUserType,
|
||||||
savingUserId,
|
savingUserId,
|
||||||
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
Arg.Is<List<CollectionAccessSelection>>(cas =>
|
||||||
cas.Select(c => c.Id).SequenceEqual(currentCollectionAccess.Select(c => c.Id)) &&
|
cas.Select(c => c.Id).SequenceEqual(currentCollectionAccess.Select(c => c.Id)) &&
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -4,8 +4,19 @@ using AutoFixture.Kernel;
|
|||||||
|
|
||||||
namespace Bit.Test.Common.AutoFixture;
|
namespace Bit.Test.Common.AutoFixture;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A utility class that encapsulates a system under test (sut) and its dependencies.
|
||||||
|
/// By default, all dependencies are initialized as mocks using the NSubstitute library.
|
||||||
|
/// SutProvider provides an interface for accessing these dependencies in the arrange and assert stages of your tests.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TSut">The concrete implementation of the class being tested.</typeparam>
|
||||||
public class SutProvider<TSut> : ISutProvider
|
public class SutProvider<TSut> : ISutProvider
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A record of the configured dependencies (constructor parameters). The outer Dictionary is keyed by the dependency's
|
||||||
|
/// type, and the inner dictionary is keyed by the parameter name (optionally used to disambiguate parameters with the same type).
|
||||||
|
/// The inner dictionary value is the dependency.
|
||||||
|
/// </summary>
|
||||||
private Dictionary<Type, Dictionary<string, object>> _dependencies;
|
private Dictionary<Type, Dictionary<string, object>> _dependencies;
|
||||||
private readonly IFixture _fixture;
|
private readonly IFixture _fixture;
|
||||||
private readonly ConstructorParameterRelay<TSut> _constructorParameterRelay;
|
private readonly ConstructorParameterRelay<TSut> _constructorParameterRelay;
|
||||||
@ -23,9 +34,21 @@ public class SutProvider<TSut> : ISutProvider
|
|||||||
_fixture.Customizations.Add(_constructorParameterRelay);
|
_fixture.Customizations.Add(_constructorParameterRelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a dependency to be injected when the sut is created. You must call <see cref="Create"/> after
|
||||||
|
/// this method to (re)create the sut with the dependency.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dependency">The dependency to register.</param>
|
||||||
|
/// <param name="parameterName">An optional parameter name to disambiguate the dependency if there are multiple of the same type. You generally don't need this.</param>
|
||||||
|
/// <typeparam name="T">The type to register the dependency under - usually an interface. This should match the type expected by the sut's constructor.</typeparam>
|
||||||
|
/// <returns></returns>
|
||||||
public SutProvider<TSut> SetDependency<T>(T dependency, string parameterName = "")
|
public SutProvider<TSut> SetDependency<T>(T dependency, string parameterName = "")
|
||||||
=> SetDependency(typeof(T), dependency, parameterName);
|
=> SetDependency(typeof(T), dependency, parameterName);
|
||||||
public SutProvider<TSut> SetDependency(Type dependencyType, object dependency, string parameterName = "")
|
|
||||||
|
/// <summary>
|
||||||
|
/// An overload for <see cref="SetDependency{T}"/> which takes a runtime <see cref="Type"/> object rather than a compile-time type.
|
||||||
|
/// </summary>
|
||||||
|
private SutProvider<TSut> SetDependency(Type dependencyType, object dependency, string parameterName = "")
|
||||||
{
|
{
|
||||||
if (_dependencies.TryGetValue(dependencyType, out var dependencyForType))
|
if (_dependencies.TryGetValue(dependencyType, out var dependencyForType))
|
||||||
{
|
{
|
||||||
@ -39,45 +62,69 @@ public class SutProvider<TSut> : ISutProvider
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with
|
||||||
|
/// <see cref="SetDependency{T}"/> or automatically with <see cref="Create"/>.
|
||||||
|
/// As dependencies are initialized with NSubstitute mocks by default, this is often used to retrieve those mocks in order to
|
||||||
|
/// configure them during the arrange stage, or check received calls in the assert stage.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="parameterName">An optional parameter name to disambiguate the dependency if there are multiple of the same type. You generally don't need this.</param>
|
||||||
|
/// <typeparam name="T">The type of the dependency you want to get - usually an interface.</typeparam>
|
||||||
|
/// <returns>The dependency.</returns>
|
||||||
public T GetDependency<T>(string parameterName = "") => (T)GetDependency(typeof(T), parameterName);
|
public T GetDependency<T>(string parameterName = "") => (T)GetDependency(typeof(T), parameterName);
|
||||||
public object GetDependency(Type dependencyType, string parameterName = "")
|
|
||||||
|
/// <summary>
|
||||||
|
/// An overload for <see cref="GetDependency{T}"/> which takes a runtime <see cref="Type"/> object rather than a compile-time type.
|
||||||
|
/// </summary>
|
||||||
|
private object GetDependency(Type dependencyType, string parameterName = "")
|
||||||
{
|
{
|
||||||
if (DependencyIsSet(dependencyType, parameterName))
|
if (DependencyIsSet(dependencyType, parameterName))
|
||||||
{
|
{
|
||||||
return _dependencies[dependencyType][parameterName];
|
return _dependencies[dependencyType][parameterName];
|
||||||
}
|
}
|
||||||
else if (_dependencies.TryGetValue(dependencyType, out var knownDependencies))
|
|
||||||
|
if (_dependencies.TryGetValue(dependencyType, out var knownDependencies))
|
||||||
{
|
{
|
||||||
if (knownDependencies.Values.Count == 1)
|
if (knownDependencies.Values.Count == 1)
|
||||||
{
|
{
|
||||||
return knownDependencies.Values.Single();
|
return knownDependencies.Values.Single();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ",
|
||||||
throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ",
|
$"{parameterName} does not exist. Available dependency names are: ",
|
||||||
$"{parameterName} does not exist. Available dependency names are: ",
|
string.Join(", ", knownDependencies.Keys)));
|
||||||
string.Join(", ", knownDependencies.Keys)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all the dependencies and the sut. This reverts the SutProvider back to a fully uninitialized state.
|
||||||
|
/// </summary>
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
_dependencies = new Dictionary<Type, Dictionary<string, object>>();
|
_dependencies = new Dictionary<Type, Dictionary<string, object>>();
|
||||||
Sut = default;
|
Sut = default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recreate a new sut with all new dependencies. This will reset all dependencies, including mocked return values
|
||||||
|
/// and any dependencies set with <see cref="SetDependency{T}"/>.
|
||||||
|
/// </summary>
|
||||||
public void Recreate()
|
public void Recreate()
|
||||||
{
|
{
|
||||||
_dependencies = new Dictionary<Type, Dictionary<string, object>>();
|
_dependencies = new Dictionary<Type, Dictionary<string, object>>();
|
||||||
Sut = _fixture.Create<TSut>();
|
Sut = _fixture.Create<TSut>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Create()"/>>
|
||||||
ISutProvider ISutProvider.Create() => Create();
|
ISutProvider ISutProvider.Create() => Create();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the sut, injecting any dependencies configured via <see cref="SetDependency{T}"/> and falling back to
|
||||||
|
/// NSubstitute mocks for any dependencies that have not been explicitly configured.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
public SutProvider<TSut> Create()
|
public SutProvider<TSut> Create()
|
||||||
{
|
{
|
||||||
Sut = _fixture.Create<TSut>();
|
Sut = _fixture.Create<TSut>();
|
||||||
@ -89,6 +136,19 @@ public class SutProvider<TSut> : ISutProvider
|
|||||||
|
|
||||||
private object GetDefault(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null;
|
private object GetDefault(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A specimen builder which tells Autofixture to use the dependency registered in <see cref="SutProvider{T}"/>
|
||||||
|
/// when creating test data. If no matching dependency exists in <see cref="SutProvider{TSut}"/>, it creates
|
||||||
|
/// an NSubstitute mock and registers it using <see cref="SutProvider{TSut}.SetDependency{T}"/>
|
||||||
|
/// so it can be retrieved later.
|
||||||
|
/// This is the link between <see cref="SutProvider{T}"/> and Autofixture.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Autofixture knows how to create sample data of simple types (such as an int or string) but not more complex classes.
|
||||||
|
/// We create our own <see cref="ISpecimenBuilder"/> and register it with the <see cref="Fixture"/> in
|
||||||
|
/// <see cref="SutProvider{TSut}"/> to provide that instruction.
|
||||||
|
/// </remarks>
|
||||||
|
/// <typeparam name="T">The type of the sut.</typeparam>
|
||||||
private class ConstructorParameterRelay<T> : ISpecimenBuilder
|
private class ConstructorParameterRelay<T> : ISpecimenBuilder
|
||||||
{
|
{
|
||||||
private readonly SutProvider<T> _sutProvider;
|
private readonly SutProvider<T> _sutProvider;
|
||||||
@ -102,6 +162,7 @@ public class SutProvider<TSut> : ISutProvider
|
|||||||
|
|
||||||
public object Create(object request, ISpecimenContext context)
|
public object Create(object request, ISpecimenContext context)
|
||||||
{
|
{
|
||||||
|
// Basic checks to filter out irrelevant requests from Autofixture
|
||||||
if (context == null)
|
if (context == null)
|
||||||
{
|
{
|
||||||
throw new ArgumentNullException(nameof(context));
|
throw new ArgumentNullException(nameof(context));
|
||||||
@ -116,16 +177,22 @@ public class SutProvider<TSut> : ISutProvider
|
|||||||
return new NoSpecimen();
|
return new NoSpecimen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the dependency set under this parameter name, if any
|
||||||
if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, parameterInfo.Name))
|
if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, parameterInfo.Name))
|
||||||
{
|
{
|
||||||
return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name);
|
return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name);
|
||||||
}
|
}
|
||||||
// Return default type if set
|
|
||||||
else if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, ""))
|
// Use the default dependency set for this type, if any (i.e. no parameter name has been specified)
|
||||||
|
if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, ""))
|
||||||
{
|
{
|
||||||
return _sutProvider.GetDependency(parameterInfo.ParameterType, "");
|
return _sutProvider.GetDependency(parameterInfo.ParameterType, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: pass the request down the chain. This lets another fixture customization populate the value.
|
||||||
|
// If you haven't added any customizations, this should be an NSubstitute mock.
|
||||||
|
// It is registered with SetDependency so you can retrieve it later.
|
||||||
|
|
||||||
// This is the equivalent of _fixture.Create<parameterInfo.ParameterType>, but no overload for
|
// This is the equivalent of _fixture.Create<parameterInfo.ParameterType>, but no overload for
|
||||||
// Create(Type type) exists.
|
// Create(Type type) exists.
|
||||||
var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType,
|
var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType,
|
||||||
|
@ -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);
|
@ -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>>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Models.Business;
|
using Bit.Core.AdminConsole.Models.Business;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||||
|
@ -27,8 +27,10 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
List<CollectionAccessSelection> collections, List<Guid> groups, SutProvider<UpdateOrganizationUserCommand> sutProvider)
|
List<CollectionAccessSelection> collections, List<Guid> groups, SutProvider<UpdateOrganizationUserCommand> sutProvider)
|
||||||
{
|
{
|
||||||
user.Id = default(Guid);
|
user.Id = default(Guid);
|
||||||
|
var existingUserType = OrganizationUserType.User;
|
||||||
|
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.UpdateUserAsync(user, savingUserId, collections, groups));
|
() => sutProvider.Sut.UpdateUserAsync(user, existingUserType, savingUserId, collections, groups));
|
||||||
Assert.Contains("invite the user first", exception.Message.ToLowerInvariant());
|
Assert.Contains("invite the user first", exception.Message.ToLowerInvariant());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,9 +39,10 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
Guid? savingUserId, SutProvider<UpdateOrganizationUserCommand> sutProvider)
|
Guid? savingUserId, SutProvider<UpdateOrganizationUserCommand> sutProvider)
|
||||||
{
|
{
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(user.Id).Returns(originalUser);
|
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(user.Id).Returns(originalUser);
|
||||||
|
var existingUserType = OrganizationUserType.User;
|
||||||
|
|
||||||
await Assert.ThrowsAsync<NotFoundException>(
|
await Assert.ThrowsAsync<NotFoundException>(
|
||||||
() => sutProvider.Sut.UpdateUserAsync(user, savingUserId, null, null));
|
() => sutProvider.Sut.UpdateUserAsync(user, existingUserType, savingUserId, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@ -55,8 +58,10 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
.Returns(callInfo => callInfo.Arg<IEnumerable<Guid>>()
|
.Returns(callInfo => callInfo.Arg<IEnumerable<Guid>>()
|
||||||
.Select(guid => new Collection { Id = guid, OrganizationId = CoreHelpers.GenerateComb() }).ToList());
|
.Select(guid => new Collection { Id = guid, OrganizationId = CoreHelpers.GenerateComb() }).ToList());
|
||||||
|
|
||||||
|
var existingUserType = OrganizationUserType.User;
|
||||||
|
|
||||||
await Assert.ThrowsAsync<NotFoundException>(
|
await Assert.ThrowsAsync<NotFoundException>(
|
||||||
() => sutProvider.Sut.UpdateUserAsync(user, savingUserId, collectionAccess, null));
|
() => sutProvider.Sut.UpdateUserAsync(user, existingUserType, savingUserId, collectionAccess, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@ -76,9 +81,9 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
result.RemoveAt(0);
|
result.RemoveAt(0);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
var existingUserType = OrganizationUserType.User;
|
||||||
await Assert.ThrowsAsync<NotFoundException>(
|
await Assert.ThrowsAsync<NotFoundException>(
|
||||||
() => sutProvider.Sut.UpdateUserAsync(user, savingUserId, collectionAccess, null));
|
() => sutProvider.Sut.UpdateUserAsync(user, existingUserType, savingUserId, collectionAccess, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@ -94,8 +99,10 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
.Returns(callInfo => callInfo.Arg<IEnumerable<Guid>>()
|
.Returns(callInfo => callInfo.Arg<IEnumerable<Guid>>()
|
||||||
.Select(guid => new Group { Id = guid, OrganizationId = CoreHelpers.GenerateComb() }).ToList());
|
.Select(guid => new Group { Id = guid, OrganizationId = CoreHelpers.GenerateComb() }).ToList());
|
||||||
|
|
||||||
|
var existingUserType = OrganizationUserType.User;
|
||||||
|
|
||||||
await Assert.ThrowsAsync<NotFoundException>(
|
await Assert.ThrowsAsync<NotFoundException>(
|
||||||
() => sutProvider.Sut.UpdateUserAsync(user, savingUserId, null, groupAccess));
|
() => sutProvider.Sut.UpdateUserAsync(user, existingUserType, savingUserId, null, groupAccess));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@ -115,9 +122,9 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
result.RemoveAt(0);
|
result.RemoveAt(0);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
var existingUserType = OrganizationUserType.User;
|
||||||
await Assert.ThrowsAsync<NotFoundException>(
|
await Assert.ThrowsAsync<NotFoundException>(
|
||||||
() => sutProvider.Sut.UpdateUserAsync(user, savingUserId, null, groupAccess));
|
() => sutProvider.Sut.UpdateUserAsync(user, existingUserType, savingUserId, null, groupAccess));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
@ -165,7 +172,9 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
.GetCountByFreeOrganizationAdminUserAsync(newUserData.Id)
|
.GetCountByFreeOrganizationAdminUserAsync(newUserData.Id)
|
||||||
.Returns(0);
|
.Returns(0);
|
||||||
|
|
||||||
await sutProvider.Sut.UpdateUserAsync(newUserData, savingUser.UserId, collections, groups);
|
var existingUserType = OrganizationUserType.User;
|
||||||
|
|
||||||
|
await sutProvider.Sut.UpdateUserAsync(newUserData, existingUserType, savingUser.UserId, collections, groups);
|
||||||
|
|
||||||
var organizationService = sutProvider.GetDependency<IOrganizationService>();
|
var organizationService = sutProvider.GetDependency<IOrganizationService>();
|
||||||
await organizationService.Received(1).ValidateOrganizationUserUpdatePermissions(
|
await organizationService.Received(1).ValidateOrganizationUserUpdatePermissions(
|
||||||
@ -184,7 +193,7 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData(OrganizationUserType.Admin)]
|
[BitAutoData(OrganizationUserType.Admin)]
|
||||||
[BitAutoData(OrganizationUserType.Owner)]
|
[BitAutoData(OrganizationUserType.Owner)]
|
||||||
public async Task UpdateUserAsync_WhenUpdatingUserToAdminOrOwner_WithUserAlreadyAdminOfAnotherFreeOrganization_Throws(
|
public async Task UpdateUserAsync_WhenUpdatingUserToAdminOrOwner_AndExistingUserTypeIsNotAdminOrOwner_WithUserAlreadyAdminOfAnotherFreeOrganization_Throws(
|
||||||
OrganizationUserType userType,
|
OrganizationUserType userType,
|
||||||
OrganizationUser oldUserData,
|
OrganizationUser oldUserData,
|
||||||
OrganizationUser newUserData,
|
OrganizationUser newUserData,
|
||||||
@ -199,10 +208,39 @@ public class UpdateOrganizationUserCommandTests
|
|||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetCountByFreeOrganizationAdminUserAsync(newUserData.UserId!.Value)
|
.GetCountByFreeOrganizationAdminUserAsync(newUserData.UserId!.Value)
|
||||||
.Returns(1);
|
.Returns(1);
|
||||||
|
var existingUserType = OrganizationUserType.User;
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
() => sutProvider.Sut.UpdateUserAsync(newUserData, null, null, null));
|
() => sutProvider.Sut.UpdateUserAsync(newUserData, existingUserType, null, null, null));
|
||||||
|
Assert.Contains("User can only be an admin of one free organization.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin, OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Admin, OrganizationUserType.Owner)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner, OrganizationUserType.Admin)]
|
||||||
|
[BitAutoData(OrganizationUserType.Owner, OrganizationUserType.Owner)]
|
||||||
|
public async Task UpdateUserAsync_WhenUpdatingUserToAdminOrOwner_AndExistingUserTypeIsAdminOrOwner_WithUserAlreadyAdminOfAnotherFreeOrganization_Throws(
|
||||||
|
OrganizationUserType newUserType,
|
||||||
|
OrganizationUserType existingUserType,
|
||||||
|
OrganizationUser oldUserData,
|
||||||
|
OrganizationUser newUserData,
|
||||||
|
Organization organization,
|
||||||
|
SutProvider<UpdateOrganizationUserCommand> sutProvider)
|
||||||
|
{
|
||||||
|
organization.PlanType = PlanType.Free;
|
||||||
|
newUserData.Type = newUserType;
|
||||||
|
|
||||||
|
Setup(sutProvider, organization, newUserData, oldUserData);
|
||||||
|
|
||||||
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
|
.GetCountByFreeOrganizationAdminUserAsync(newUserData.UserId!.Value)
|
||||||
|
.Returns(2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||||
|
() => sutProvider.Sut.UpdateUserAsync(newUserData, existingUserType, null, null, null));
|
||||||
Assert.Contains("User can only be an admin of one free organization.", exception.Message);
|
Assert.Contains("User can only be an admin of one free organization.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,16 +60,19 @@ public class TwoFactorAuthenticationPolicyValidatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers(
|
public async Task OnSaveSideEffectsAsync_RevokesOnlyNonCompliantUsers(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
|
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
|
||||||
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
|
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
|
||||||
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
|
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
|
||||||
{
|
{
|
||||||
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
|
// Arrange
|
||||||
|
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||||
|
organization.Id = policyUpdate.OrganizationId;
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||||
|
|
||||||
var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails
|
var nonCompliantUser = new OrganizationUserUserDetails
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Status = OrganizationUserStatusType.Confirmed,
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
@ -80,30 +83,57 @@ public class TwoFactorAuthenticationPolicyValidatorTests
|
|||||||
HasMasterPassword = true
|
HasMasterPassword = true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var compliantUser = new OrganizationUserUserDetails
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Status = OrganizationUserStatusType.Confirmed,
|
||||||
|
Type = OrganizationUserType.User,
|
||||||
|
Email = "user4@test.com",
|
||||||
|
Name = "TEST",
|
||||||
|
UserId = Guid.NewGuid(),
|
||||||
|
HasMasterPassword = true
|
||||||
|
};
|
||||||
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||||
.Returns([orgUserDetailUserWithout2Fa]);
|
.Returns([nonCompliantUser, compliantUser]);
|
||||||
|
|
||||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||||
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
|
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
|
||||||
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
|
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
|
||||||
{
|
{
|
||||||
(orgUserDetailUserWithout2Fa, false)
|
(nonCompliantUser, false),
|
||||||
|
(compliantUser, true)
|
||||||
});
|
});
|
||||||
|
|
||||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
|
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
|
||||||
.Returns(new CommandResult());
|
.Returns(new CommandResult());
|
||||||
|
|
||||||
|
// Act
|
||||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
|
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy);
|
||||||
|
|
||||||
|
// Assert
|
||||||
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
|
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
|
||||||
|
|
||||||
|
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||||
|
.Received(1)
|
||||||
|
.RevokeNonCompliantOrganizationUsersAsync(Arg.Is<RevokeOrganizationUsersRequest>(req =>
|
||||||
|
req.OrganizationId == policyUpdate.OrganizationId &&
|
||||||
|
req.OrganizationUsers.SequenceEqual(new[] { nonCompliantUser })
|
||||||
|
));
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>()
|
await sutProvider.GetDependency<IMailService>()
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
||||||
"user3@test.com");
|
nonCompliantUser.Email);
|
||||||
|
|
||||||
|
// Did not send out an email for compliantUser
|
||||||
|
await sutProvider.GetDependency<IMailService>()
|
||||||
|
.Received(0)
|
||||||
|
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
||||||
|
compliantUser.Email);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -288,7 +288,7 @@ public class SavePolicyCommandTests
|
|||||||
{
|
{
|
||||||
return new SutProvider<SavePolicyCommand>()
|
return new SutProvider<SavePolicyCommand>()
|
||||||
.WithFakeTimeProvider()
|
.WithFakeTimeProvider()
|
||||||
.SetDependency(typeof(IEnumerable<IPolicyValidator>), policyValidators ?? [])
|
.SetDependency(policyValidators ?? [])
|
||||||
.Create();
|
.Create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
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;
|
||||||
|
@ -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;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
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;
|
||||||
|
@ -25,7 +25,8 @@ public class SendGridMailDeliveryServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
Mail =
|
Mail =
|
||||||
{
|
{
|
||||||
SendGridApiKey = "SendGridApiKey"
|
SendGridApiKey = "SendGridApiKey",
|
||||||
|
SendGridApiHost = "https://api.sendgrid.com"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,13 +7,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models;
|
using Bit.Core.Auth.Models;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
|
||||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||||
using Bit.Core.Billing.Services;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
@ -21,22 +18,15 @@ using Bit.Core.Exceptions;
|
|||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
using Bit.Core.Models.Data.Organizations;
|
using Bit.Core.Models.Data.Organizations;
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
|
||||||
using Bit.Core.Platform.Push;
|
|
||||||
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.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Bit.Core.Vault.Repositories;
|
|
||||||
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.Fakes;
|
|
||||||
using Bit.Test.Common.Helpers;
|
using Bit.Test.Common.Helpers;
|
||||||
using Fido2NetLib;
|
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@ -179,9 +169,12 @@ public class UserServiceTests
|
|||||||
[Theory]
|
[Theory]
|
||||||
[BitAutoData(DeviceType.UnknownBrowser, "Unknown Browser")]
|
[BitAutoData(DeviceType.UnknownBrowser, "Unknown Browser")]
|
||||||
[BitAutoData(DeviceType.Android, "Android")]
|
[BitAutoData(DeviceType.Android, "Android")]
|
||||||
public async Task SendNewDeviceVerificationEmailAsync_DeviceMatches(DeviceType deviceType, string deviceTypeName, SutProvider<UserService> sutProvider, User user)
|
public async Task SendNewDeviceVerificationEmailAsync_DeviceMatches(DeviceType deviceType, string deviceTypeName,
|
||||||
|
User user)
|
||||||
{
|
{
|
||||||
SetupFakeTokenProvider(sutProvider, user);
|
var sutProvider = new SutProvider<UserService>()
|
||||||
|
.CreateWithUserServiceCustomizations(user);
|
||||||
|
|
||||||
var context = sutProvider.GetDependency<ICurrentContext>();
|
var context = sutProvider.GetDependency<ICurrentContext>();
|
||||||
context.DeviceType = deviceType;
|
context.DeviceType = deviceType;
|
||||||
context.IpAddress = "1.1.1.1";
|
context.IpAddress = "1.1.1.1";
|
||||||
@ -194,9 +187,11 @@ public class UserServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task SendNewDeviceVerificationEmailAsync_NullDeviceTypeShouldSendUnkownBrowserType(SutProvider<UserService> sutProvider, User user)
|
public async Task SendNewDeviceVerificationEmailAsync_NullDeviceTypeShouldSendUnkownBrowserType(User user)
|
||||||
{
|
{
|
||||||
SetupFakeTokenProvider(sutProvider, user);
|
var sutProvider = new SutProvider<UserService>()
|
||||||
|
.CreateWithUserServiceCustomizations(user);
|
||||||
|
|
||||||
var context = sutProvider.GetDependency<ICurrentContext>();
|
var context = sutProvider.GetDependency<ICurrentContext>();
|
||||||
context.DeviceType = null;
|
context.DeviceType = null;
|
||||||
context.IpAddress = "1.1.1.1";
|
context.IpAddress = "1.1.1.1";
|
||||||
@ -266,76 +261,28 @@ public class UserServiceTests
|
|||||||
[BitAutoData(true, "bad_test_password", false, ShouldCheck.Password | ShouldCheck.OTP)]
|
[BitAutoData(true, "bad_test_password", false, ShouldCheck.Password | ShouldCheck.OTP)]
|
||||||
public async Task VerifySecretAsync_Works(
|
public async Task VerifySecretAsync_Works(
|
||||||
bool shouldHavePassword, string secret, bool expectedIsVerified, ShouldCheck shouldCheck, // inline theory data
|
bool shouldHavePassword, string secret, bool expectedIsVerified, ShouldCheck shouldCheck, // inline theory data
|
||||||
SutProvider<UserService> sutProvider, User user) // AutoFixture injected data
|
User user) // AutoFixture injected data
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var tokenProvider = SetupFakeTokenProvider(sutProvider, user);
|
|
||||||
SetupUserAndDevice(user, shouldHavePassword);
|
SetupUserAndDevice(user, shouldHavePassword);
|
||||||
|
|
||||||
|
var sutProvider = new SutProvider<UserService>()
|
||||||
|
.CreateWithUserServiceCustomizations(user);
|
||||||
|
|
||||||
// Setup the fake password verification
|
// Setup the fake password verification
|
||||||
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
sutProvider.GetDependency<IUserPasswordStore<User>>()
|
||||||
substitutedUserPasswordStore
|
|
||||||
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
||||||
.Returns((ci) =>
|
.Returns(Task.FromResult("hashed_test_password"));
|
||||||
{
|
|
||||||
return Task.FromResult("hashed_test_password");
|
|
||||||
});
|
|
||||||
|
|
||||||
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore, "store");
|
sutProvider.GetDependency<IPasswordHasher<User>>()
|
||||||
|
|
||||||
sutProvider.GetDependency<IPasswordHasher<User>>("passwordHasher")
|
|
||||||
.VerifyHashedPassword(user, "hashed_test_password", "test_password")
|
.VerifyHashedPassword(user, "hashed_test_password", "test_password")
|
||||||
.Returns((ci) =>
|
.Returns(PasswordVerificationResult.Success);
|
||||||
{
|
|
||||||
return PasswordVerificationResult.Success;
|
|
||||||
});
|
|
||||||
|
|
||||||
// HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured
|
var actualIsVerified = await sutProvider.Sut.VerifySecretAsync(user, secret);
|
||||||
var sut = new UserService(
|
|
||||||
sutProvider.GetDependency<IUserRepository>(),
|
|
||||||
sutProvider.GetDependency<ICipherRepository>(),
|
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>(),
|
|
||||||
sutProvider.GetDependency<IOrganizationRepository>(),
|
|
||||||
sutProvider.GetDependency<IOrganizationDomainRepository>(),
|
|
||||||
sutProvider.GetDependency<IMailService>(),
|
|
||||||
sutProvider.GetDependency<IPushNotificationService>(),
|
|
||||||
sutProvider.GetDependency<IUserStore<User>>(),
|
|
||||||
sutProvider.GetDependency<IOptions<IdentityOptions>>(),
|
|
||||||
sutProvider.GetDependency<IPasswordHasher<User>>(),
|
|
||||||
sutProvider.GetDependency<IEnumerable<IUserValidator<User>>>(),
|
|
||||||
sutProvider.GetDependency<IEnumerable<IPasswordValidator<User>>>(),
|
|
||||||
sutProvider.GetDependency<ILookupNormalizer>(),
|
|
||||||
sutProvider.GetDependency<IdentityErrorDescriber>(),
|
|
||||||
sutProvider.GetDependency<IServiceProvider>(),
|
|
||||||
sutProvider.GetDependency<ILogger<UserManager<User>>>(),
|
|
||||||
sutProvider.GetDependency<ILicensingService>(),
|
|
||||||
sutProvider.GetDependency<IEventService>(),
|
|
||||||
sutProvider.GetDependency<IApplicationCacheService>(),
|
|
||||||
sutProvider.GetDependency<IDataProtectionProvider>(),
|
|
||||||
sutProvider.GetDependency<IPaymentService>(),
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>(),
|
|
||||||
sutProvider.GetDependency<IPolicyService>(),
|
|
||||||
sutProvider.GetDependency<IFido2>(),
|
|
||||||
sutProvider.GetDependency<ICurrentContext>(),
|
|
||||||
sutProvider.GetDependency<IGlobalSettings>(),
|
|
||||||
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
|
||||||
sutProvider.GetDependency<IStripeSyncService>(),
|
|
||||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
|
||||||
sutProvider.GetDependency<IFeatureService>(),
|
|
||||||
sutProvider.GetDependency<IPremiumUserBillingService>(),
|
|
||||||
sutProvider.GetDependency<IRemoveOrganizationUserCommand>(),
|
|
||||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>(),
|
|
||||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>(),
|
|
||||||
sutProvider.GetDependency<IDistributedCache>(),
|
|
||||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
|
||||||
);
|
|
||||||
|
|
||||||
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
|
|
||||||
|
|
||||||
Assert.Equal(expectedIsVerified, actualIsVerified);
|
Assert.Equal(expectedIsVerified, actualIsVerified);
|
||||||
|
|
||||||
await tokenProvider
|
await sutProvider.GetDependency<IUserTwoFactorTokenProvider<User>>()
|
||||||
.Received(shouldCheck.HasFlag(ShouldCheck.OTP) ? 1 : 0)
|
.Received(shouldCheck.HasFlag(ShouldCheck.OTP) ? 1 : 0)
|
||||||
.ValidateAsync(Arg.Any<string>(), secret, Arg.Any<UserManager<User>>(), user);
|
.ValidateAsync(Arg.Any<string>(), secret, Arg.Any<UserManager<User>>(), user);
|
||||||
|
|
||||||
@ -661,26 +608,25 @@ public class UserServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory, BitAutoData]
|
[Theory, BitAutoData]
|
||||||
public async Task ResendNewDeviceVerificationEmail_SendsToken_Success(
|
public async Task ResendNewDeviceVerificationEmail_SendsToken_Success(User user)
|
||||||
SutProvider<UserService> sutProvider, User user)
|
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var testPassword = "test_password";
|
var testPassword = "test_password";
|
||||||
var tokenProvider = SetupFakeTokenProvider(sutProvider, user);
|
|
||||||
SetupUserAndDevice(user, true);
|
SetupUserAndDevice(user, true);
|
||||||
|
|
||||||
|
var sutProvider = new SutProvider<UserService>()
|
||||||
|
.CreateWithUserServiceCustomizations(user);
|
||||||
|
|
||||||
// Setup the fake password verification
|
// Setup the fake password verification
|
||||||
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
sutProvider
|
||||||
substitutedUserPasswordStore
|
.GetDependency<IUserPasswordStore<User>>()
|
||||||
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
.GetPasswordHashAsync(user, Arg.Any<CancellationToken>())
|
||||||
.Returns((ci) =>
|
.Returns((ci) =>
|
||||||
{
|
{
|
||||||
return Task.FromResult("hashed_test_password");
|
return Task.FromResult("hashed_test_password");
|
||||||
});
|
});
|
||||||
|
|
||||||
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore, "store");
|
sutProvider.GetDependency<IPasswordHasher<User>>()
|
||||||
|
|
||||||
sutProvider.GetDependency<IPasswordHasher<User>>("passwordHasher")
|
|
||||||
.VerifyHashedPassword(user, "hashed_test_password", testPassword)
|
.VerifyHashedPassword(user, "hashed_test_password", testPassword)
|
||||||
.Returns((ci) =>
|
.Returns((ci) =>
|
||||||
{
|
{
|
||||||
@ -695,10 +641,7 @@ public class UserServiceTests
|
|||||||
context.DeviceType = DeviceType.Android;
|
context.DeviceType = DeviceType.Android;
|
||||||
context.IpAddress = "1.1.1.1";
|
context.IpAddress = "1.1.1.1";
|
||||||
|
|
||||||
// HACK: SutProvider is being weird about not injecting the IPasswordHasher that I configured
|
await sutProvider.Sut.ResendNewDeviceVerificationEmail(user.Email, testPassword);
|
||||||
var sut = RebuildSut(sutProvider);
|
|
||||||
|
|
||||||
await sut.ResendNewDeviceVerificationEmail(user.Email, testPassword);
|
|
||||||
|
|
||||||
await sutProvider.GetDependency<IMailService>()
|
await sutProvider.GetDependency<IMailService>()
|
||||||
.Received(1)
|
.Received(1)
|
||||||
@ -842,8 +785,15 @@ public class UserServiceTests
|
|||||||
user.MasterPassword = null;
|
user.MasterPassword = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IUserTwoFactorTokenProvider<User> SetupFakeTokenProvider(SutProvider<UserService> sutProvider, User user)
|
public static class UserServiceSutProviderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Arranges a fake token provider. Must call as part of a builder pattern that ends in Create(), as it modifies
|
||||||
|
/// the SutProvider build chain.
|
||||||
|
/// </summary>
|
||||||
|
private static SutProvider<UserService> SetFakeTokenProvider(this SutProvider<UserService> sutProvider, User user)
|
||||||
{
|
{
|
||||||
var fakeUserTwoFactorProvider = Substitute.For<IUserTwoFactorTokenProvider<User>>();
|
var fakeUserTwoFactorProvider = Substitute.For<IUserTwoFactorTokenProvider<User>>();
|
||||||
|
|
||||||
@ -859,8 +809,11 @@ public class UserServiceTests
|
|||||||
.ValidateAsync(Arg.Any<string>(), "otp_token", Arg.Any<UserManager<User>>(), user)
|
.ValidateAsync(Arg.Any<string>(), "otp_token", Arg.Any<UserManager<User>>(), user)
|
||||||
.Returns(true);
|
.Returns(true);
|
||||||
|
|
||||||
sutProvider.GetDependency<IOptions<IdentityOptions>>()
|
var fakeIdentityOptions = Substitute.For<IOptions<IdentityOptions>>();
|
||||||
.Value.Returns(new IdentityOptions
|
|
||||||
|
fakeIdentityOptions
|
||||||
|
.Value
|
||||||
|
.Returns(new IdentityOptions
|
||||||
{
|
{
|
||||||
Tokens = new TokenOptions
|
Tokens = new TokenOptions
|
||||||
{
|
{
|
||||||
@ -874,54 +827,54 @@ public class UserServiceTests
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The above arranging of dependencies is used in the constructor of UserManager
|
sutProvider.SetDependency(fakeIdentityOptions);
|
||||||
// ref: https://github.com/dotnet/aspnetcore/blob/bfeb3bf9005c36b081d1e48725531ee0e15a9dfb/src/Identity/Extensions.Core/src/UserManager.cs#L103-L120
|
// Also set the fake provider dependency so that we can retrieve it easily via GetDependency
|
||||||
// since the constructor of the Sut has ran already (when injected) I need to recreate it to get it to run again
|
sutProvider.SetDependency(fakeUserTwoFactorProvider);
|
||||||
sutProvider.Create();
|
|
||||||
|
|
||||||
return fakeUserTwoFactorProvider;
|
return sutProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IUserService RebuildSut(SutProvider<UserService> sutProvider)
|
/// <summary>
|
||||||
|
/// Properly registers IUserPasswordStore as IUserStore so it's injected when the sut is initialized.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sutProvider"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
private static SutProvider<UserService> SetUserPasswordStore(this SutProvider<UserService> sutProvider)
|
||||||
{
|
{
|
||||||
return new UserService(
|
var substitutedUserPasswordStore = Substitute.For<IUserPasswordStore<User>>();
|
||||||
sutProvider.GetDependency<IUserRepository>(),
|
|
||||||
sutProvider.GetDependency<ICipherRepository>(),
|
// IUserPasswordStore must be registered under the IUserStore parameter to be properly injected
|
||||||
sutProvider.GetDependency<IOrganizationUserRepository>(),
|
// because this is what the constructor expects
|
||||||
sutProvider.GetDependency<IOrganizationRepository>(),
|
sutProvider.SetDependency<IUserStore<User>>(substitutedUserPasswordStore);
|
||||||
sutProvider.GetDependency<IOrganizationDomainRepository>(),
|
|
||||||
sutProvider.GetDependency<IMailService>(),
|
// Also store it under its own type for retrieval and configuration
|
||||||
sutProvider.GetDependency<IPushNotificationService>(),
|
sutProvider.SetDependency(substitutedUserPasswordStore);
|
||||||
sutProvider.GetDependency<IUserStore<User>>(),
|
|
||||||
sutProvider.GetDependency<IOptions<IdentityOptions>>(),
|
return sutProvider;
|
||||||
sutProvider.GetDependency<IPasswordHasher<User>>(),
|
|
||||||
sutProvider.GetDependency<IEnumerable<IUserValidator<User>>>(),
|
|
||||||
sutProvider.GetDependency<IEnumerable<IPasswordValidator<User>>>(),
|
|
||||||
sutProvider.GetDependency<ILookupNormalizer>(),
|
|
||||||
sutProvider.GetDependency<IdentityErrorDescriber>(),
|
|
||||||
sutProvider.GetDependency<IServiceProvider>(),
|
|
||||||
sutProvider.GetDependency<ILogger<UserManager<User>>>(),
|
|
||||||
sutProvider.GetDependency<ILicensingService>(),
|
|
||||||
sutProvider.GetDependency<IEventService>(),
|
|
||||||
sutProvider.GetDependency<IApplicationCacheService>(),
|
|
||||||
sutProvider.GetDependency<IDataProtectionProvider>(),
|
|
||||||
sutProvider.GetDependency<IPaymentService>(),
|
|
||||||
sutProvider.GetDependency<IPolicyRepository>(),
|
|
||||||
sutProvider.GetDependency<IPolicyService>(),
|
|
||||||
sutProvider.GetDependency<IFido2>(),
|
|
||||||
sutProvider.GetDependency<ICurrentContext>(),
|
|
||||||
sutProvider.GetDependency<IGlobalSettings>(),
|
|
||||||
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
|
||||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
|
||||||
sutProvider.GetDependency<IStripeSyncService>(),
|
|
||||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
|
||||||
sutProvider.GetDependency<IFeatureService>(),
|
|
||||||
sutProvider.GetDependency<IPremiumUserBillingService>(),
|
|
||||||
sutProvider.GetDependency<IRemoveOrganizationUserCommand>(),
|
|
||||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>(),
|
|
||||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>(),
|
|
||||||
sutProvider.GetDependency<IDistributedCache>(),
|
|
||||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This is a hack: when autofixture initializes the sut in sutProvider, it overwrites the public
|
||||||
|
/// PasswordHasher property with a new substitute, so it loses the configured sutProvider mock.
|
||||||
|
/// This doesn't usually happen because our dependencies are not usually public.
|
||||||
|
/// Call this AFTER SutProvider.Create().
|
||||||
|
/// </summary>
|
||||||
|
private static SutProvider<UserService> FixPasswordHasherBug(this SutProvider<UserService> sutProvider)
|
||||||
|
{
|
||||||
|
// Get the configured sutProvider mock and assign it back to the public property in the base class
|
||||||
|
sutProvider.Sut.PasswordHasher = sutProvider.GetDependency<IPasswordHasher<User>>();
|
||||||
|
return sutProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A helper that combines all SutProvider configuration usually required for UserService.
|
||||||
|
/// Call this instead of SutProvider.Create, after any additional configuration your test needs.
|
||||||
|
/// </summary>
|
||||||
|
public static SutProvider<UserService> CreateWithUserServiceCustomizations(this SutProvider<UserService> sutProvider, User user)
|
||||||
|
=> sutProvider
|
||||||
|
.SetUserPasswordStore()
|
||||||
|
.SetFakeTokenProvider(user)
|
||||||
|
.Create()
|
||||||
|
.FixPasswordHasherBug();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -62,7 +62,9 @@ public class ImportCiphersAsyncCommandTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||||
.GetAsync<PersonalOwnershipPolicyRequirement>(importingUserId)
|
.GetAsync<PersonalOwnershipPolicyRequirement>(importingUserId)
|
||||||
.Returns(new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = false });
|
.Returns(new PersonalOwnershipPolicyRequirement(
|
||||||
|
PersonalOwnershipState.Allowed,
|
||||||
|
[]));
|
||||||
|
|
||||||
sutProvider.GetDependency<IFolderRepository>()
|
sutProvider.GetDependency<IFolderRepository>()
|
||||||
.GetManyByUserIdAsync(importingUserId)
|
.GetManyByUserIdAsync(importingUserId)
|
||||||
@ -116,7 +118,9 @@ public class ImportCiphersAsyncCommandTests
|
|||||||
|
|
||||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||||
.GetAsync<PersonalOwnershipPolicyRequirement>(userId)
|
.GetAsync<PersonalOwnershipPolicyRequirement>(userId)
|
||||||
.Returns(new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = true });
|
.Returns(new PersonalOwnershipPolicyRequirement(
|
||||||
|
PersonalOwnershipState.Restricted,
|
||||||
|
[Guid.NewGuid()]));
|
||||||
|
|
||||||
var folderRelationships = new List<KeyValuePair<int, int>>();
|
var folderRelationships = new List<KeyValuePair<int, int>>();
|
||||||
|
|
||||||
|
@ -10,10 +10,10 @@ using Bit.Core.Tools.Entities;
|
|||||||
using Bit.Core.Tools.Enums;
|
using Bit.Core.Tools.Enums;
|
||||||
using Bit.Core.Tools.Models.Data;
|
using Bit.Core.Tools.Models.Data;
|
||||||
using Bit.Core.Tools.Repositories;
|
using Bit.Core.Tools.Repositories;
|
||||||
using Bit.Core.Tools.SendFeatures;
|
|
||||||
using Bit.Core.Tools.SendFeatures.Commands;
|
using Bit.Core.Tools.SendFeatures.Commands;
|
||||||
using Bit.Core.Tools.Services;
|
using Bit.Core.Tools.Services;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@ -35,6 +35,8 @@ public class NonAnonymousSendCommandTests
|
|||||||
private readonly ISendCoreHelperService _sendCoreHelperService;
|
private readonly ISendCoreHelperService _sendCoreHelperService;
|
||||||
private readonly NonAnonymousSendCommand _nonAnonymousSendCommand;
|
private readonly NonAnonymousSendCommand _nonAnonymousSendCommand;
|
||||||
|
|
||||||
|
private readonly ILogger<NonAnonymousSendCommand> _logger;
|
||||||
|
|
||||||
public NonAnonymousSendCommandTests()
|
public NonAnonymousSendCommandTests()
|
||||||
{
|
{
|
||||||
_sendRepository = Substitute.For<ISendRepository>();
|
_sendRepository = Substitute.For<ISendRepository>();
|
||||||
@ -45,6 +47,7 @@ public class NonAnonymousSendCommandTests
|
|||||||
_sendValidationService = Substitute.For<ISendValidationService>();
|
_sendValidationService = Substitute.For<ISendValidationService>();
|
||||||
_currentContext = Substitute.For<ICurrentContext>();
|
_currentContext = Substitute.For<ICurrentContext>();
|
||||||
_sendCoreHelperService = Substitute.For<ISendCoreHelperService>();
|
_sendCoreHelperService = Substitute.For<ISendCoreHelperService>();
|
||||||
|
_logger = Substitute.For<ILogger<NonAnonymousSendCommand>>();
|
||||||
|
|
||||||
_nonAnonymousSendCommand = new NonAnonymousSendCommand(
|
_nonAnonymousSendCommand = new NonAnonymousSendCommand(
|
||||||
_sendRepository,
|
_sendRepository,
|
||||||
@ -52,7 +55,8 @@ public class NonAnonymousSendCommandTests
|
|||||||
_pushNotificationService,
|
_pushNotificationService,
|
||||||
_sendAuthorizationService,
|
_sendAuthorizationService,
|
||||||
_sendValidationService,
|
_sendValidationService,
|
||||||
_sendCoreHelperService
|
_sendCoreHelperService,
|
||||||
|
_logger
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -652,11 +656,11 @@ public class NonAnonymousSendCommandTests
|
|||||||
UserId = userId
|
UserId = userId
|
||||||
};
|
};
|
||||||
var fileData = new SendFileData();
|
var fileData = new SendFileData();
|
||||||
var fileLength = 15L * 1024L * 1024L * 1024L; // 15GB
|
var fileLength = 15L * 1024L * 1024L; // 15 MB
|
||||||
|
|
||||||
// Configure validation service to return large but insufficient storage (10GB for self-hosted non-premium)
|
// Configure validation service to return insufficient storage
|
||||||
_sendValidationService.StorageRemainingForSendAsync(send)
|
_sendValidationService.StorageRemainingForSendAsync(send)
|
||||||
.Returns(10L * 1024L * 1024L * 1024L); // 10GB remaining (self-hosted default)
|
.Returns(10L * 1024L * 1024L); // 10 MB remaining
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
@ -687,11 +691,40 @@ public class NonAnonymousSendCommandTests
|
|||||||
UserId = userId
|
UserId = userId
|
||||||
};
|
};
|
||||||
var fileData = new SendFileData();
|
var fileData = new SendFileData();
|
||||||
var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB
|
var fileLength = 2L * 1024L * 1024L * 1024L; // 2MB
|
||||||
|
|
||||||
// Configure validation service to return 1GB storage (cloud non-premium default)
|
// Act & Assert
|
||||||
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
|
_nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength));
|
||||||
|
|
||||||
|
Assert.Contains("Max file size is ", exception.Message);
|
||||||
|
|
||||||
|
// Verify no further methods were called
|
||||||
|
await _sendValidationService.DidNotReceive().StorageRemainingForSendAsync(Arg.Any<Send>());
|
||||||
|
await _sendRepository.DidNotReceive().CreateAsync(Arg.Any<Send>());
|
||||||
|
await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any<Send>());
|
||||||
|
await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any<Send>(), Arg.Any<string>());
|
||||||
|
await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any<Send>());
|
||||||
|
await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any<Send>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsNotSelfHosted_NotEnoughSpace_ThrowsBadRequest()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var send = new Send
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = SendType.File,
|
||||||
|
UserId = userId
|
||||||
|
};
|
||||||
|
var fileData = new SendFileData();
|
||||||
|
var fileLength = 2L * 1024L * 1024L; // 2MB
|
||||||
|
|
||||||
|
// Configure validation service to return 1 MB storage remaining
|
||||||
_sendValidationService.StorageRemainingForSendAsync(send)
|
_sendValidationService.StorageRemainingForSendAsync(send)
|
||||||
.Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining (cloud default)
|
.Returns(1L * 1024L * 1024L);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
@ -756,7 +789,7 @@ public class NonAnonymousSendCommandTests
|
|||||||
UserId = null
|
UserId = null
|
||||||
};
|
};
|
||||||
var fileData = new SendFileData();
|
var fileData = new SendFileData();
|
||||||
var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB
|
var fileLength = 2L * 1024L * 1024L; // 2 MB
|
||||||
|
|
||||||
// Configure validation service to throw BadRequest when checking storage for org without storage
|
// Configure validation service to throw BadRequest when checking storage for org without storage
|
||||||
_sendValidationService.StorageRemainingForSendAsync(send)
|
_sendValidationService.StorageRemainingForSendAsync(send)
|
||||||
@ -792,11 +825,10 @@ public class NonAnonymousSendCommandTests
|
|||||||
UserId = null
|
UserId = null
|
||||||
};
|
};
|
||||||
var fileData = new SendFileData();
|
var fileData = new SendFileData();
|
||||||
var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB
|
var fileLength = 2L * 1024L * 1024L; // 2 MB
|
||||||
|
|
||||||
// Configure validation service to return 1GB storage (org's max storage limit)
|
|
||||||
_sendValidationService.StorageRemainingForSendAsync(send)
|
_sendValidationService.StorageRemainingForSendAsync(send)
|
||||||
.Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining
|
.Returns(1L * 1024L * 1024L); // 1 MB remaining
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||||
@ -980,7 +1012,7 @@ public class NonAnonymousSendCommandTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Setup validation to succeed
|
// Setup validation to succeed
|
||||||
_sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size));
|
_sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, Arg.Any<long>(), Arg.Any<long>()).Returns((true, sendFileData.Size));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
||||||
@ -1014,7 +1046,7 @@ public class NonAnonymousSendCommandTests
|
|||||||
Data = JsonSerializer.Serialize(sendFileData)
|
Data = JsonSerializer.Serialize(sendFileData)
|
||||||
};
|
};
|
||||||
|
|
||||||
_sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size));
|
_sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, Arg.Any<long>(), Arg.Any<long>()).Returns((true, sendFileData.Size));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user