mirror of
https://github.com/bitwarden/server
synced 2026-01-01 16:13:33 +00:00
Merge branch 'main' into auth/pm-22975/client-version-validator
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
Please review this pull request with a focus on:
|
||||
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Security implications
|
||||
- Performance considerations
|
||||
|
||||
Note: The PR branch is already checked out in the current working directory.
|
||||
|
||||
Provide a comprehensive review including:
|
||||
|
||||
- Summary of changes since last review
|
||||
- Critical issues found (be thorough)
|
||||
- Suggested improvements (be thorough)
|
||||
- Good practices observed (be concise - list only the most notable items without elaboration)
|
||||
- Action items for the author
|
||||
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
|
||||
|
||||
When reviewing subsequent commits:
|
||||
|
||||
- Track status of previously identified issues (fixed/unfixed/reopened)
|
||||
- Identify NEW problems introduced since last review
|
||||
- Note if fixes introduced new issues
|
||||
|
||||
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
|
||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -53,6 +53,11 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||
|
||||
# Dirt (Data Insights & Reporting) team
|
||||
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/Events @bitwarden/team-data-insights-and-reporting-dev
|
||||
src/EventsProcessor @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Events.IntegrationTest @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/Events.Test @bitwarden/team-data-insights-and-reporting-dev
|
||||
test/EventsProcessor.Test @bitwarden/team-data-insights-and-reporting-dev
|
||||
|
||||
# Vault team
|
||||
**/Vault @bitwarden/team-vault-dev
|
||||
@@ -63,8 +68,6 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
|
||||
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
|
||||
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
|
||||
src/Events @bitwarden/team-admin-console-dev
|
||||
src/EventsProcessor @bitwarden/team-admin-console-dev
|
||||
|
||||
# Billing team
|
||||
**/*billing* @bitwarden/team-billing-dev
|
||||
|
||||
4
.github/workflows/_move_edd_db_scripts.yml
vendored
4
.github/workflows/_move_edd_db_scripts.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
persist-credentials: false
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
actions: read
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
- win-x64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/cleanup-rc-branch.yml
vendored
2
.github/workflows/cleanup-rc-branch.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
||||
2
.github/workflows/code-references.yml
vendored
2
.github/workflows/code-references.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/load-test.yml
vendored
2
.github/workflows/load-test.yml
vendored
@@ -87,7 +87,7 @@ jobs:
|
||||
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/protect-files.yml
vendored
2
.github/workflows/protect-files.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
label: "DB-migrations-changed"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -106,7 +106,7 @@ jobs:
|
||||
echo "Github Release Option: $RELEASE_OPTION"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/repository-management.yml
vendored
4
.github/workflows/repository-management.yml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
stale-issue-label: "needs-reply"
|
||||
stale-pr-label: "needs-changes"
|
||||
|
||||
6
.github/workflows/test-database.yml
vendored
6
.github/workflows/test-database.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
checks: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
91
src/Api/Billing/Controllers/LicensesController.cs
Normal file
91
src/Api/Billing/Controllers/LicensesController.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Api.OrganizationLicenses;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("licenses")]
|
||||
[Authorize("Licensing")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class LicensesController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery;
|
||||
private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public LicensesController(
|
||||
IUserRepository userRepository,
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
|
||||
IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery;
|
||||
_validateBillingSyncKeyCommand = validateBillingSyncKeyCommand;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
[HttpGet("user/{id}")]
|
||||
public async Task<UserLicense> GetUser(string id, [FromQuery] string key)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(new Guid(id));
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
else if (!user.LicenseKey.Equals(key))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
var license = await _userService.GenerateLicenseAsync(user, null);
|
||||
return license;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used by self-hosted installations to get an updated license file
|
||||
/// </summary>
|
||||
[HttpGet("organization/{id}")]
|
||||
public async Task<OrganizationLicense> OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(new Guid(id));
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException("Organization not found.");
|
||||
}
|
||||
|
||||
if (!organization.LicenseKey.Equals(model.LicenseKey))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey))
|
||||
{
|
||||
throw new BadRequestException("Invalid Billing Sync Key");
|
||||
}
|
||||
|
||||
var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value);
|
||||
return license;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Api.Utilities.DiagnosticTools;
|
||||
@@ -17,7 +18,7 @@ using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("events")]
|
||||
[Authorize("Application")]
|
||||
@@ -2,7 +2,7 @@
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Models.Response;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class EventResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
using System.Net;
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using System.Net;
|
||||
using Bit.Api.Dirt.Public.Models;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Api.Utilities.DiagnosticTools;
|
||||
using Bit.Core.Context;
|
||||
@@ -12,7 +11,7 @@ using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Public.Controllers;
|
||||
namespace Bit.Api.Dirt.Public.Controllers;
|
||||
|
||||
[Route("public/events")]
|
||||
[Authorize("Organization")]
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Api.Models.Public.Request;
|
||||
namespace Bit.Api.Dirt.Public.Models;
|
||||
|
||||
public class EventFilterRequestModel
|
||||
{
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Models.Public.Response;
|
||||
namespace Bit.Api.Dirt.Public.Models;
|
||||
|
||||
/// <summary>
|
||||
/// An event log.
|
||||
@@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_webauthnKeyValidator;
|
||||
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
|
||||
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
|
||||
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;
|
||||
|
||||
public AccountsKeyManagementController(IUserService userService,
|
||||
IFeatureService featureService,
|
||||
@@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller
|
||||
emergencyAccessValidator,
|
||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||
organizationUserValidator,
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
|
||||
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||
webAuthnKeyValidator,
|
||||
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator,
|
||||
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand)
|
||||
{
|
||||
_userService = userService;
|
||||
_featureService = featureService;
|
||||
@@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||
_deviceValidator = deviceValidator;
|
||||
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
|
||||
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
|
||||
}
|
||||
|
||||
[HttpPost("key-management/regenerate-keys")]
|
||||
@@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
||||
if (result.Succeeded)
|
||||
if (model.IsV2Request())
|
||||
{
|
||||
return;
|
||||
// V2 account registration
|
||||
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData());
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
// V1 account registration
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("convert-to-key-connector")]
|
||||
|
||||
@@ -1,36 +1,112 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
public class SetKeyConnectorKeyRequestModel
|
||||
public class SetKeyConnectorKeyRequestModel : IValidatableObject
|
||||
{
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
[Required]
|
||||
public KeysRequestModel Keys { get; set; }
|
||||
[Required]
|
||||
public KdfType Kdf { get; set; }
|
||||
[Required]
|
||||
public int KdfIterations { get; set; }
|
||||
public int? KdfMemory { get; set; }
|
||||
public int? KdfParallelism { get; set; }
|
||||
[Required]
|
||||
public string OrgIdentifier { get; set; }
|
||||
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
[Obsolete("Use KeyConnectorKeyWrappedUserKey instead")]
|
||||
public string? Key { get; set; }
|
||||
|
||||
[Obsolete("Use AccountKeys instead")]
|
||||
public KeysRequestModel? Keys { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public KdfType? Kdf { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfIterations { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfMemory { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfParallelism { get; set; }
|
||||
|
||||
[EncryptedString]
|
||||
public string? KeyConnectorKeyWrappedUserKey { get; set; }
|
||||
public AccountKeysRequestModel? AccountKeys { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string OrgIdentifier { get; init; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (IsV2Request())
|
||||
{
|
||||
// V2 registration
|
||||
yield break;
|
||||
}
|
||||
|
||||
// V1 registration
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
if (string.IsNullOrEmpty(Key))
|
||||
{
|
||||
yield return new ValidationResult("Key must be supplied.");
|
||||
}
|
||||
|
||||
if (Keys == null)
|
||||
{
|
||||
yield return new ValidationResult("Keys must be supplied.");
|
||||
}
|
||||
|
||||
if (Kdf == null)
|
||||
{
|
||||
yield return new ValidationResult("Kdf must be supplied.");
|
||||
}
|
||||
|
||||
if (KdfIterations == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfIterations must be supplied.");
|
||||
}
|
||||
|
||||
if (Kdf == KdfType.Argon2id)
|
||||
{
|
||||
if (KdfMemory == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
|
||||
if (KdfParallelism == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsV2Request()
|
||||
{
|
||||
return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null;
|
||||
}
|
||||
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
public User ToUser(User existingUser)
|
||||
{
|
||||
existingUser.Kdf = Kdf;
|
||||
existingUser.KdfIterations = KdfIterations;
|
||||
existingUser.Kdf = Kdf!.Value;
|
||||
existingUser.KdfIterations = KdfIterations!.Value;
|
||||
existingUser.KdfMemory = KdfMemory;
|
||||
existingUser.KdfParallelism = KdfParallelism;
|
||||
existingUser.Key = Key;
|
||||
Keys.ToUser(existingUser);
|
||||
Keys!.ToUser(existingUser);
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
public KeyConnectorKeysData ToKeyConnectorKeysData()
|
||||
{
|
||||
// TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null)
|
||||
{
|
||||
throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.");
|
||||
}
|
||||
|
||||
return new KeyConnectorKeysData
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey,
|
||||
AccountKeys = AccountKeys,
|
||||
OrgIdentifier = OrgIdentifier
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using Bit.Api.Dirt.Public.Models;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Services;
|
||||
@@ -49,7 +49,7 @@ public static class EventDiagnosticLogger
|
||||
this ILogger logger,
|
||||
IFeatureService featureService,
|
||||
Guid organizationId,
|
||||
IEnumerable<Bit.Api.Models.Response.EventResponseModel> data,
|
||||
IEnumerable<Dirt.Models.Response.EventResponseModel> data,
|
||||
string? continuationToken,
|
||||
DateTime? queryStart = null,
|
||||
DateTime? queryEnd = null)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
using Stripe;
|
||||
|
||||
@@ -21,14 +22,14 @@ public interface IRestartSubscriptionCommand
|
||||
}
|
||||
|
||||
public class RestartSubscriptionCommand(
|
||||
ILogger<RestartSubscriptionCommand> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
IUserRepository userRepository) : IRestartSubscriptionCommand
|
||||
ISubscriberService subscriberService) : BaseBillingCommand<RestartSubscriptionCommand>(logger), IRestartSubscriptionCommand
|
||||
{
|
||||
public async Task<BillingCommandResult<None>> Run(
|
||||
ISubscriber subscriber)
|
||||
public Task<BillingCommandResult<None>> Run(
|
||||
ISubscriber subscriber) => HandleAsync<None>(async () =>
|
||||
{
|
||||
var existingSubscription = await subscriberService.GetSubscription(subscriber);
|
||||
|
||||
@@ -37,56 +38,147 @@ public class RestartSubscriptionCommand(
|
||||
return new BadRequest("Cannot restart a subscription that is not canceled.");
|
||||
}
|
||||
|
||||
await RestartSubscriptionAsync(subscriber, existingSubscription);
|
||||
|
||||
return new None();
|
||||
});
|
||||
|
||||
private Task RestartSubscriptionAsync(
|
||||
ISubscriber subscriber,
|
||||
Subscription canceledSubscription) => subscriber switch
|
||||
{
|
||||
Organization organization => RestartOrganizationSubscriptionAsync(organization, canceledSubscription),
|
||||
_ => throw new NotSupportedException("Only organization subscriptions can be restarted")
|
||||
};
|
||||
|
||||
private async Task RestartOrganizationSubscriptionAsync(
|
||||
Organization organization,
|
||||
Subscription canceledSubscription)
|
||||
{
|
||||
var plans = await pricingClient.ListPlans();
|
||||
|
||||
var oldPlan = plans.FirstOrDefault(plan => plan.Type == organization.PlanType);
|
||||
|
||||
if (oldPlan == null)
|
||||
{
|
||||
throw new ConflictException("Could not find plan for organization's plan type");
|
||||
}
|
||||
|
||||
var newPlan = oldPlan.Disabled
|
||||
? plans.FirstOrDefault(plan =>
|
||||
plan.ProductTier == oldPlan.ProductTier &&
|
||||
plan.IsAnnual == oldPlan.IsAnnual &&
|
||||
!plan.Disabled)
|
||||
: oldPlan;
|
||||
|
||||
if (newPlan == null)
|
||||
{
|
||||
throw new ConflictException("Could not find the current, enabled plan for organization's tier and cadence");
|
||||
}
|
||||
|
||||
if (newPlan.Type != oldPlan.Type)
|
||||
{
|
||||
organization.PlanType = newPlan.Type;
|
||||
organization.Plan = newPlan.Name;
|
||||
organization.SelfHost = newPlan.HasSelfHost;
|
||||
organization.UsePolicies = newPlan.HasPolicies;
|
||||
organization.UseGroups = newPlan.HasGroups;
|
||||
organization.UseDirectory = newPlan.HasDirectory;
|
||||
organization.UseEvents = newPlan.HasEvents;
|
||||
organization.UseTotp = newPlan.HasTotp;
|
||||
organization.Use2fa = newPlan.Has2fa;
|
||||
organization.UseApi = newPlan.HasApi;
|
||||
organization.UseSso = newPlan.HasSso;
|
||||
organization.UseOrganizationDomains = newPlan.HasOrganizationDomains;
|
||||
organization.UseKeyConnector = newPlan.HasKeyConnector;
|
||||
organization.UseScim = newPlan.HasScim;
|
||||
organization.UseResetPassword = newPlan.HasResetPassword;
|
||||
organization.UsersGetPremium = newPlan.UsersGetPremium;
|
||||
organization.UseCustomPermissions = newPlan.HasCustomPermissions;
|
||||
}
|
||||
|
||||
var items = new List<SubscriptionItemOptions>();
|
||||
|
||||
// Password Manager
|
||||
var passwordManagerItem = canceledSubscription.Items.FirstOrDefault(item =>
|
||||
item.Price.Id == (oldPlan.HasNonSeatBasedPasswordManagerPlan()
|
||||
? oldPlan.PasswordManager.StripePlanId
|
||||
: oldPlan.PasswordManager.StripeSeatPlanId));
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
throw new ConflictException("Organization's subscription does not have a Password Manager subscription item.");
|
||||
}
|
||||
|
||||
items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = newPlan.HasNonSeatBasedPasswordManagerPlan() ? newPlan.PasswordManager.StripePlanId : newPlan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = passwordManagerItem.Quantity
|
||||
});
|
||||
|
||||
// Storage
|
||||
var storageItem = canceledSubscription.Items.FirstOrDefault(
|
||||
item => item.Price.Id == oldPlan.PasswordManager.StripeStoragePlanId);
|
||||
|
||||
if (storageItem != null)
|
||||
{
|
||||
items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = newPlan.PasswordManager.StripeStoragePlanId,
|
||||
Quantity = storageItem.Quantity
|
||||
});
|
||||
}
|
||||
|
||||
// Secrets Manager & Service Accounts
|
||||
var secretsManagerItem = oldPlan.SecretsManager != null
|
||||
? canceledSubscription.Items.FirstOrDefault(item =>
|
||||
item.Price.Id == oldPlan.SecretsManager.StripeSeatPlanId)
|
||||
: null;
|
||||
|
||||
var serviceAccountsItem = oldPlan.SecretsManager != null
|
||||
? canceledSubscription.Items.FirstOrDefault(item =>
|
||||
item.Price.Id == oldPlan.SecretsManager.StripeServiceAccountPlanId)
|
||||
: null;
|
||||
|
||||
if (newPlan.SecretsManager != null)
|
||||
{
|
||||
if (secretsManagerItem != null)
|
||||
{
|
||||
items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = newPlan.SecretsManager.StripeSeatPlanId,
|
||||
Quantity = secretsManagerItem.Quantity
|
||||
});
|
||||
}
|
||||
|
||||
if (serviceAccountsItem != null)
|
||||
{
|
||||
items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = newPlan.SecretsManager.StripeServiceAccountPlanId,
|
||||
Quantity = serviceAccountsItem.Quantity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var options = new SubscriptionCreateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
||||
CollectionMethod = CollectionMethod.ChargeAutomatically,
|
||||
Customer = existingSubscription.CustomerId,
|
||||
Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions
|
||||
{
|
||||
Price = subscriptionItem.Price.Id,
|
||||
Quantity = subscriptionItem.Quantity
|
||||
}).ToList(),
|
||||
Metadata = existingSubscription.Metadata,
|
||||
Customer = canceledSubscription.CustomerId,
|
||||
Items = items,
|
||||
Metadata = canceledSubscription.Metadata,
|
||||
OffSession = true,
|
||||
TrialPeriodDays = 0
|
||||
};
|
||||
|
||||
var subscription = await stripeAdapter.CreateSubscriptionAsync(options);
|
||||
await EnableAsync(subscriber, subscription);
|
||||
return new None();
|
||||
}
|
||||
|
||||
private async Task EnableAsync(ISubscriber subscriber, Subscription subscription)
|
||||
{
|
||||
switch (subscriber)
|
||||
{
|
||||
case Organization organization:
|
||||
{
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
organization.Enabled = true;
|
||||
organization.ExpirationDate = subscription.GetCurrentPeriodEnd();
|
||||
organization.RevisionDate = DateTime.UtcNow;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
break;
|
||||
}
|
||||
case Provider provider:
|
||||
{
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
provider.Enabled = true;
|
||||
provider.RevisionDate = DateTime.UtcNow;
|
||||
await providerRepository.ReplaceAsync(provider);
|
||||
break;
|
||||
}
|
||||
case User user:
|
||||
{
|
||||
user.GatewaySubscriptionId = subscription.Id;
|
||||
user.Premium = true;
|
||||
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
|
||||
user.RevisionDate = DateTime.UtcNow;
|
||||
await userRepository.ReplaceAsync(user);
|
||||
break;
|
||||
}
|
||||
}
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
organization.Enabled = true;
|
||||
organization.ExpirationDate = subscription.GetCurrentPeriodEnd();
|
||||
organization.RevisionDate = DateTime.UtcNow;
|
||||
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ public static class FeatureFlagKeys
|
||||
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
|
||||
public const string InlineMenuTotp = "inline-menu-totp";
|
||||
public const string WindowsDesktopAutotype = "windows-desktop-autotype";
|
||||
public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga";
|
||||
|
||||
/* Billing Team */
|
||||
public const string TrialPayment = "PM-8163-trial-payment";
|
||||
@@ -191,7 +192,6 @@ public static class FeatureFlagKeys
|
||||
public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog";
|
||||
public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button";
|
||||
public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog";
|
||||
public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page";
|
||||
public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service";
|
||||
public const string PM23341_Milestone_2 = "pm-23341-milestone-2";
|
||||
public const string PM26462_Milestone_3 = "pm-26462-milestone-3";
|
||||
@@ -212,6 +212,7 @@ public static class FeatureFlagKeys
|
||||
public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component";
|
||||
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
|
||||
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
|
||||
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
|
||||
|
||||
/* Mobile Team */
|
||||
public const string AndroidImportLoginsFlow = "import-logins-flow";
|
||||
@@ -238,6 +239,7 @@ public static class FeatureFlagKeys
|
||||
public const string UseChromiumImporter = "pm-23982-chromium-importer";
|
||||
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
|
||||
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
|
||||
public const string SendEmailOTP = "pm-19051-send-email-verification";
|
||||
|
||||
/* Vault Team */
|
||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Authorization;
|
||||
|
||||
public class KeyConnectorAuthorizationHandler : AuthorizationHandler<KeyConnectorOperationsRequirement, User>
|
||||
{
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public KeyConnectorAuthorizationHandler(ICurrentContext currentContext)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
|
||||
KeyConnectorOperationsRequirement requirement,
|
||||
User user)
|
||||
{
|
||||
var authorized = requirement switch
|
||||
{
|
||||
not null when requirement == KeyConnectorOperations.Use => CanUse(user),
|
||||
_ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
|
||||
};
|
||||
|
||||
if (authorized)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool CanUse(User user)
|
||||
{
|
||||
// User cannot use Key Connector if they already use it
|
||||
if (user.UsesKeyConnector)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// User cannot use Key Connector if they are an owner or admin of any organization
|
||||
if (_currentContext.Organizations.Any(u =>
|
||||
u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Authorization;
|
||||
|
||||
public class KeyConnectorOperationsRequirement : OperationAuthorizationRequirement
|
||||
{
|
||||
public KeyConnectorOperationsRequirement(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
public static class KeyConnectorOperations
|
||||
{
|
||||
public static readonly KeyConnectorOperationsRequirement Use = new(nameof(Use));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Commands.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the user key and account cryptographic state for a new user registering
|
||||
/// with Key Connector SSO configuration.
|
||||
/// </summary>
|
||||
public interface ISetKeyConnectorKeyCommand
|
||||
{
|
||||
Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData);
|
||||
}
|
||||
60
src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs
Normal file
60
src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Authorization;
|
||||
using Bit.Core.KeyManagement.Commands.Interfaces;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Commands;
|
||||
|
||||
public class SetKeyConnectorKeyCommand : ISetKeyConnectorKeyCommand
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public SetKeyConnectorKeyCommand(
|
||||
IAuthorizationService authorizationService,
|
||||
ICurrentContext currentContext,
|
||||
IEventService eventService,
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IUserService userService,
|
||||
IUserRepository userRepository)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_currentContext = currentContext;
|
||||
_eventService = eventService;
|
||||
_acceptOrgUserCommand = acceptOrgUserCommand;
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData)
|
||||
{
|
||||
var authorizationResult = await _authorizationService.AuthorizeAsync(_currentContext.HttpContext.User, user,
|
||||
KeyConnectorOperations.Use);
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
throw new BadRequestException("Cannot use Key Connector");
|
||||
}
|
||||
|
||||
var setKeyConnectorUserKeyTask =
|
||||
_userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorKeysData.KeyConnectorKeyWrappedUserKey);
|
||||
|
||||
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id,
|
||||
keyConnectorKeysData.AccountKeys.ToAccountKeysData(), [setKeyConnectorUserKeyTask]);
|
||||
|
||||
await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
|
||||
|
||||
await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(keyConnectorKeysData.OrgIdentifier, user,
|
||||
_userService);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using Bit.Core.KeyManagement.Commands;
|
||||
using Bit.Core.KeyManagement.Authorization;
|
||||
using Bit.Core.KeyManagement.Commands;
|
||||
using Bit.Core.KeyManagement.Commands.Interfaces;
|
||||
using Bit.Core.KeyManagement.Kdf;
|
||||
using Bit.Core.KeyManagement.Kdf.Implementations;
|
||||
using Bit.Core.KeyManagement.Queries;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.KeyManagement;
|
||||
@@ -12,15 +14,22 @@ public static class KeyManagementServiceCollectionExtensions
|
||||
{
|
||||
public static void AddKeyManagementServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddKeyManagementAuthorizationHandlers();
|
||||
services.AddKeyManagementCommands();
|
||||
services.AddKeyManagementQueries();
|
||||
services.AddSendPasswordServices();
|
||||
}
|
||||
|
||||
private static void AddKeyManagementAuthorizationHandlers(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IAuthorizationHandler, KeyConnectorAuthorizationHandler>();
|
||||
}
|
||||
|
||||
private static void AddKeyManagementCommands(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IRegenerateUserAsymmetricKeysCommand, RegenerateUserAsymmetricKeysCommand>();
|
||||
services.AddScoped<IChangeKdfCommand, ChangeKdfCommand>();
|
||||
services.AddScoped<ISetKeyConnectorKeyCommand, SetKeyConnectorKeyCommand>();
|
||||
}
|
||||
|
||||
private static void AddKeyManagementQueries(this IServiceCollection services)
|
||||
|
||||
12
src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs
Normal file
12
src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
public class KeyConnectorKeysData
|
||||
{
|
||||
public required string KeyConnectorKeyWrappedUserKey { get; set; }
|
||||
|
||||
public required AccountKeysRequestModel AccountKeys { get; set; }
|
||||
|
||||
public required string OrgIdentifier { get; init; }
|
||||
}
|
||||
@@ -72,6 +72,8 @@ public interface IUserRepository : IRepository<User, Guid>
|
||||
UserAccountKeysData accountKeysData,
|
||||
IEnumerable<UpdateUserData>? updateUserDataActions = null);
|
||||
Task DeleteManyAsync(IEnumerable<User> users);
|
||||
|
||||
UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey);
|
||||
}
|
||||
|
||||
public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null,
|
||||
|
||||
@@ -33,6 +33,8 @@ public interface IUserService
|
||||
Task<IdentityResult> ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword,
|
||||
string token, string key);
|
||||
Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key);
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
[Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")]
|
||||
Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier);
|
||||
Task<IdentityResult> ConvertToKeyConnectorAsync(User user);
|
||||
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);
|
||||
|
||||
@@ -621,6 +621,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
||||
}
|
||||
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
public async Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier)
|
||||
{
|
||||
var identityResult = CheckCanUseKeyConnector(user);
|
||||
|
||||
@@ -1029,11 +1029,8 @@ public class CipherService : ICipherService
|
||||
var existingCipherData = DeserializeCipherData(existingCipher);
|
||||
var newCipherData = DeserializeCipherData(cipher);
|
||||
|
||||
// "hidden password" users may not add cipher key encryption
|
||||
if (existingCipher.Key == null && cipher.Key != null)
|
||||
{
|
||||
throw new BadRequestException("You do not have permission to add cipher key encryption.");
|
||||
}
|
||||
// For hidden-password users, never allow Key to change at all.
|
||||
cipher.Key = existingCipher.Key;
|
||||
// Keep only non-hidden fileds from the new cipher
|
||||
var nonHiddenFields = newCipherData.Fields?.Where(f => f.Type != FieldType.Hidden) ?? [];
|
||||
// Get hidden fields from the existing cipher
|
||||
|
||||
@@ -21,17 +21,21 @@ public class CollectController : Controller
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ICipherRepository _cipherRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
|
||||
public CollectController(
|
||||
ICurrentContext currentContext,
|
||||
IEventService eventService,
|
||||
ICipherRepository cipherRepository,
|
||||
IOrganizationRepository organizationRepository)
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository
|
||||
)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_eventService = eventService;
|
||||
_cipherRepository = cipherRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -54,6 +58,24 @@ public class CollectController : Controller
|
||||
await _eventService.LogUserEventAsync(_currentContext.UserId.Value, eventModel.Type, eventModel.Date);
|
||||
break;
|
||||
|
||||
case EventType.Organization_ItemOrganization_Accepted:
|
||||
case EventType.Organization_ItemOrganization_Declined:
|
||||
if (!eventModel.OrganizationId.HasValue || !_currentContext.UserId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(eventModel.OrganizationId.Value, _currentContext.UserId.Value);
|
||||
|
||||
if (orgUser == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser, eventModel.Type, eventModel.Date);
|
||||
|
||||
continue;
|
||||
|
||||
// Cipher events
|
||||
case EventType.Cipher_ClientAutofilled:
|
||||
case EventType.Cipher_ClientCopiedHiddenField:
|
||||
|
||||
@@ -703,6 +703,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
|
||||
customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
|
||||
customResponse.Add("ForcePasswordReset", user.ForcePasswordReset);
|
||||
customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword));
|
||||
customResponse.Add("Kdf", (byte)user.Kdf);
|
||||
customResponse.Add("KdfIterations", user.KdfIterations);
|
||||
customResponse.Add("KdfMemory", user.KdfMemory);
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.IdentityServer;
|
||||
using Bit.Core.Auth.Models.Api.Response;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
@@ -153,7 +154,23 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
{
|
||||
// KeyConnectorUrl is configured in the CLI client, we just need to tell the client to use it
|
||||
context.Result.CustomResponse["ApiUseKeyConnector"] = true;
|
||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Key connector data should have already been set in the decryption options
|
||||
// for backwards compatibility we set them this way too. We can eventually get rid of this once we clean up
|
||||
// ResetMasterPassword
|
||||
if (!context.Result.CustomResponse.TryGetValue("UserDecryptionOptions", out var userDecryptionOptionsObj) ||
|
||||
userDecryptionOptionsObj is not UserDecryptionOptions userDecryptionOptions)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (userDecryptionOptions is { KeyConnectorOption: { } })
|
||||
{
|
||||
context.Result.CustomResponse["ResetMasterPassword"] = false;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Premium.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
using Bit.Core.Models.Data;
|
||||
@@ -401,6 +402,32 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
|
||||
return result.SingleOrDefault();
|
||||
}
|
||||
|
||||
public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey)
|
||||
{
|
||||
return async (connection, transaction) =>
|
||||
{
|
||||
var timestamp = DateTime.UtcNow;
|
||||
|
||||
await connection!.ExecuteAsync(
|
||||
"[dbo].[User_UpdateKeyConnectorUserKey]",
|
||||
new
|
||||
{
|
||||
Id = userId,
|
||||
Key = keyConnectorWrappedUserKey,
|
||||
// Key Connector does not use KDF, so we set some defaults
|
||||
Kdf = KdfType.Argon2id,
|
||||
KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default,
|
||||
KdfMemory = AuthConstants.ARGON2_MEMORY.Default,
|
||||
KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default,
|
||||
UsesKeyConnector = true,
|
||||
RevisionDate = timestamp,
|
||||
AccountRevisionDate = timestamp
|
||||
},
|
||||
transaction: transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ProtectDataAndSaveAsync(User user, Func<Task> saveTask)
|
||||
{
|
||||
if (user == null)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Premium.Models;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.UserKey;
|
||||
using Bit.Core.Models.Data;
|
||||
@@ -479,6 +481,35 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
|
||||
}
|
||||
}
|
||||
|
||||
public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey)
|
||||
{
|
||||
return async (_, _) =>
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var userEntity = await dbContext.Users.FindAsync(userId);
|
||||
if (userEntity == null)
|
||||
{
|
||||
throw new ArgumentException("User not found", nameof(userId));
|
||||
}
|
||||
|
||||
var timestamp = DateTime.UtcNow;
|
||||
|
||||
userEntity.Key = keyConnectorWrappedUserKey;
|
||||
// Key Connector does not use KDF, so we set some defaults
|
||||
userEntity.Kdf = KdfType.Argon2id;
|
||||
userEntity.KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default;
|
||||
userEntity.KdfMemory = AuthConstants.ARGON2_MEMORY.Default;
|
||||
userEntity.KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default;
|
||||
userEntity.UsesKeyConnector = true;
|
||||
userEntity.RevisionDate = timestamp;
|
||||
userEntity.AccountRevisionDate = timestamp;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
};
|
||||
}
|
||||
|
||||
private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable<Guid> userIds)
|
||||
{
|
||||
var defaultCollections = (from c in dbContext.Collections
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE PROCEDURE [dbo].[User_UpdateKeyConnectorUserKey]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@Key VARCHAR(MAX),
|
||||
@Kdf TINYINT,
|
||||
@KdfIterations INT,
|
||||
@KdfMemory INT,
|
||||
@KdfParallelism INT,
|
||||
@UsesKeyConnector BIT,
|
||||
@RevisionDate DATETIME2(7),
|
||||
@AccountRevisionDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
UPDATE
|
||||
[dbo].[User]
|
||||
SET
|
||||
[Key] = @Key,
|
||||
[Kdf] = @Kdf,
|
||||
[KdfIterations] = @KdfIterations,
|
||||
[KdfMemory] = @KdfMemory,
|
||||
[KdfParallelism] = @KdfParallelism,
|
||||
[UsesKeyConnector] = @UsesKeyConnector,
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[AccountRevisionDate] = @AccountRevisionDate
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
@@ -128,7 +128,8 @@ BEGIN
|
||||
[UseAdminSponsoredFamilies],
|
||||
[SyncSeats],
|
||||
[UseAutomaticUserConfirmation],
|
||||
[UsePhishingBlocker]
|
||||
[UsePhishingBlocker],
|
||||
[MaxStorageGbIncreased]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@@ -193,6 +194,7 @@ BEGIN
|
||||
@UseAdminSponsoredFamilies,
|
||||
@SyncSeats,
|
||||
@UseAutomaticUserConfirmation,
|
||||
@UsePhishingBlocker
|
||||
@UsePhishingBlocker,
|
||||
@MaxStorageGb
|
||||
);
|
||||
END
|
||||
|
||||
@@ -128,7 +128,8 @@ BEGIN
|
||||
[UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies,
|
||||
[SyncSeats] = @SyncSeats,
|
||||
[UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation,
|
||||
[UsePhishingBlocker] = @UsePhishingBlocker
|
||||
[UsePhishingBlocker] = @UsePhishingBlocker,
|
||||
[MaxStorageGbIncreased] = @MaxStorageGb
|
||||
WHERE
|
||||
[Id] = @Id;
|
||||
END
|
||||
|
||||
@@ -96,7 +96,8 @@ BEGIN
|
||||
[VerifyDevices],
|
||||
[SecurityState],
|
||||
[SecurityVersion],
|
||||
[SignedPublicKey]
|
||||
[SignedPublicKey],
|
||||
[MaxStorageGbIncreased]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@@ -145,6 +146,7 @@ BEGIN
|
||||
@VerifyDevices,
|
||||
@SecurityState,
|
||||
@SecurityVersion,
|
||||
@SignedPublicKey
|
||||
@SignedPublicKey,
|
||||
@MaxStorageGb
|
||||
)
|
||||
END
|
||||
|
||||
@@ -96,7 +96,8 @@ BEGIN
|
||||
[VerifyDevices] = @VerifyDevices,
|
||||
[SecurityState] = @SecurityState,
|
||||
[SecurityVersion] = @SecurityVersion,
|
||||
[SignedPublicKey] = @SignedPublicKey
|
||||
[SignedPublicKey] = @SignedPublicKey,
|
||||
[MaxStorageGbIncreased] = @MaxStorageGb
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
|
||||
@@ -7,6 +7,7 @@ using Bit.Api.KeyManagement.Models.Responses;
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Vault.Models;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
@@ -19,9 +20,11 @@ using Bit.Core.KeyManagement.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Core.KeyManagement.Repositories;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Vault.Enums;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.KeyManagement.Controllers;
|
||||
@@ -31,6 +34,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
private static readonly string _mockEncryptedString =
|
||||
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
|
||||
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
|
||||
private static readonly string _mockEncryptedType7WrappedSigningKey = "7.DRv74Kg1RSlFSam1MNFlGD==";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
|
||||
@@ -47,8 +51,11 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
public AccountsKeyManagementControllerTests(ApiApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.UpdateConfiguration("globalSettings:launchDarkly:flagValues:pm-12241-private-key-regeneration",
|
||||
"true");
|
||||
_factory.SubstituteService<IFeatureService>(featureService =>
|
||||
{
|
||||
featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any<bool>())
|
||||
.Returns(true);
|
||||
});
|
||||
_client = factory.CreateClient();
|
||||
_loginHelper = new LoginHelper(_factory, _client);
|
||||
_userRepository = _factory.GetService<IUserRepository>();
|
||||
@@ -78,8 +85,11 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
{
|
||||
// Localize factory to inject a false value for the feature flag.
|
||||
var localFactory = new ApiApplicationFactory();
|
||||
localFactory.UpdateConfiguration("globalSettings:launchDarkly:flagValues:pm-12241-private-key-regeneration",
|
||||
"false");
|
||||
localFactory.SubstituteService<IFeatureService>(featureService =>
|
||||
{
|
||||
featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any<bool>())
|
||||
.Returns(false);
|
||||
});
|
||||
var localClient = localFactory.CreateClient();
|
||||
var localEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
|
||||
var localLoginHelper = new LoginHelper(localFactory, localClient);
|
||||
@@ -285,21 +295,21 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_Success(string organizationSsoIdentifier,
|
||||
SetKeyConnectorKeyRequestModel request)
|
||||
public async Task PostSetKeyConnectorKeyAsync_Success(string organizationSsoIdentifier)
|
||||
{
|
||||
var (ssoUserEmail, organization) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Invited, organizationSsoIdentifier);
|
||||
|
||||
var ssoUser = await _userRepository.GetByEmailAsync(ssoUserEmail);
|
||||
Assert.NotNull(ssoUser);
|
||||
|
||||
request.Keys = new KeysRequestModel
|
||||
var request = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
PublicKey = ssoUser.PublicKey,
|
||||
EncryptedPrivateKey = ssoUser.PrivateKey
|
||||
Key = _mockEncryptedString,
|
||||
Keys = new KeysRequestModel { PublicKey = ssoUser.PublicKey, EncryptedPrivateKey = ssoUser.PrivateKey },
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
OrgIdentifier = organizationSsoIdentifier
|
||||
};
|
||||
request.Key = _mockEncryptedString;
|
||||
request.OrgIdentifier = organizationSsoIdentifier;
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/accounts/set-key-connector-key", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -310,12 +320,95 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
|
||||
Assert.True(user.UsesKeyConnector);
|
||||
Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1));
|
||||
Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1));
|
||||
var ssoOrganizationUser = await _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||
Assert.NotNull(ssoOrganizationUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Accepted, ssoOrganizationUser.Status);
|
||||
Assert.Equal(user.Id, ssoOrganizationUser.UserId);
|
||||
Assert.Null(ssoOrganizationUser.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSetKeyConnectorKeyAsync_V2_NotLoggedIn_Unauthorized()
|
||||
{
|
||||
var request = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = _mockEncryptedString,
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = "publicKey",
|
||||
UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String
|
||||
},
|
||||
OrgIdentifier = "test-org"
|
||||
};
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/accounts/set-key-connector-key", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_V2_Success(string organizationSsoIdentifier)
|
||||
{
|
||||
var (ssoUserEmail, organization) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Invited, organizationSsoIdentifier);
|
||||
|
||||
var request = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = _mockEncryptedString,
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = "publicKey",
|
||||
UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String,
|
||||
PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel
|
||||
{
|
||||
PublicKey = "publicKey",
|
||||
WrappedPrivateKey = _mockEncryptedType7String,
|
||||
SignedPublicKey = "signedPublicKey"
|
||||
},
|
||||
SignatureKeyPair = new SignatureKeyPairRequestModel
|
||||
{
|
||||
SignatureAlgorithm = "ed25519",
|
||||
WrappedSigningKey = _mockEncryptedType7WrappedSigningKey,
|
||||
VerifyingKey = "verifyingKey"
|
||||
},
|
||||
SecurityState = new SecurityStateModel
|
||||
{
|
||||
SecurityVersion = 2,
|
||||
SecurityState = "v2"
|
||||
}
|
||||
},
|
||||
OrgIdentifier = organizationSsoIdentifier
|
||||
};
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/accounts/set-key-connector-key", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var user = await _userRepository.GetByEmailAsync(ssoUserEmail);
|
||||
Assert.NotNull(user);
|
||||
Assert.Equal(request.KeyConnectorKeyWrappedUserKey, user.Key);
|
||||
Assert.True(user.UsesKeyConnector);
|
||||
Assert.Equal(KdfType.Argon2id, user.Kdf);
|
||||
Assert.Equal(AuthConstants.ARGON2_ITERATIONS.Default, user.KdfIterations);
|
||||
Assert.Equal(AuthConstants.ARGON2_MEMORY.Default, user.KdfMemory);
|
||||
Assert.Equal(AuthConstants.ARGON2_PARALLELISM.Default, user.KdfParallelism);
|
||||
Assert.Equal(request.AccountKeys.PublicKeyEncryptionKeyPair!.SignedPublicKey, user.SignedPublicKey);
|
||||
Assert.Equal(request.AccountKeys.SecurityState!.SecurityState, user.SecurityState);
|
||||
Assert.Equal(request.AccountKeys.SecurityState.SecurityVersion, user.SecurityVersion);
|
||||
Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1));
|
||||
Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1));
|
||||
|
||||
var ssoOrganizationUser =
|
||||
await _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||
Assert.NotNull(ssoOrganizationUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Accepted, ssoOrganizationUser.Status);
|
||||
Assert.Equal(user.Id, ssoOrganizationUser.UserId);
|
||||
Assert.Null(ssoOrganizationUser.Email);
|
||||
|
||||
var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(user.Id);
|
||||
Assert.NotNull(signatureKeyPair);
|
||||
Assert.Equal(SignatureAlgorithm.Ed25519, signatureKeyPair.SignatureAlgorithm);
|
||||
Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey);
|
||||
Assert.Equal("verifyingKey", signatureKeyPair.VerifyingKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -238,10 +238,13 @@ public class AccountsKeyManagementControllerTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_UserNull_Throws(
|
||||
public async Task PostSetKeyConnectorKeyAsync_V1_UserNull_Throws(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider,
|
||||
SetKeyConnectorKeyRequestModel data)
|
||||
{
|
||||
data.KeyConnectorKeyWrappedUserKey = null;
|
||||
data.AccountKeys = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsNull();
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data));
|
||||
@@ -252,10 +255,13 @@ public class AccountsKeyManagementControllerTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse(
|
||||
public async Task PostSetKeyConnectorKeyAsync_V1_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider,
|
||||
SetKeyConnectorKeyRequestModel data, User expectedUser)
|
||||
{
|
||||
data.KeyConnectorKeyWrappedUserKey = null;
|
||||
data.AccountKeys = null;
|
||||
|
||||
expectedUser.PublicKey = null;
|
||||
expectedUser.PrivateKey = null;
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
@@ -278,17 +284,20 @@ public class AccountsKeyManagementControllerTests
|
||||
Assert.Equal(data.KdfIterations, user.KdfIterations);
|
||||
Assert.Equal(data.KdfMemory, user.KdfMemory);
|
||||
Assert.Equal(data.KdfParallelism, user.KdfParallelism);
|
||||
Assert.Equal(data.Keys.PublicKey, user.PublicKey);
|
||||
Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey);
|
||||
Assert.Equal(data.Keys!.PublicKey, user.PublicKey);
|
||||
Assert.Equal(data.Keys!.EncryptedPrivateKey, user.PrivateKey);
|
||||
}), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeySucceeds_OkResponse(
|
||||
public async Task PostSetKeyConnectorKeyAsync_V1_SetKeyConnectorKeySucceeds_OkResponse(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider,
|
||||
SetKeyConnectorKeyRequestModel data, User expectedUser)
|
||||
{
|
||||
data.KeyConnectorKeyWrappedUserKey = null;
|
||||
data.AccountKeys = null;
|
||||
|
||||
expectedUser.PublicKey = null;
|
||||
expectedUser.PrivateKey = null;
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
@@ -308,11 +317,108 @@ public class AccountsKeyManagementControllerTests
|
||||
Assert.Equal(data.KdfIterations, user.KdfIterations);
|
||||
Assert.Equal(data.KdfMemory, user.KdfMemory);
|
||||
Assert.Equal(data.KdfParallelism, user.KdfParallelism);
|
||||
Assert.Equal(data.Keys.PublicKey, user.PublicKey);
|
||||
Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey);
|
||||
Assert.Equal(data.Keys!.PublicKey, user.PublicKey);
|
||||
Assert.Equal(data.Keys!.EncryptedPrivateKey, user.PrivateKey);
|
||||
}), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_V2_UserNull_Throws(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider)
|
||||
{
|
||||
var request = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = "wrapped-user-key",
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = "public-key",
|
||||
UserKeyEncryptedAccountPrivateKey = "encrypted-private-key"
|
||||
},
|
||||
OrgIdentifier = "test-org"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsNull();
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request));
|
||||
|
||||
await sutProvider.GetDependency<ISetKeyConnectorKeyCommand>().DidNotReceive()
|
||||
.SetKeyConnectorKeyForUserAsync(Arg.Any<User>(), Arg.Any<KeyConnectorKeysData>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_V2_Success(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider,
|
||||
User expectedUser)
|
||||
{
|
||||
var request = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = "wrapped-user-key",
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = "public-key",
|
||||
UserKeyEncryptedAccountPrivateKey = "encrypted-private-key"
|
||||
},
|
||||
OrgIdentifier = "test-org"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(expectedUser);
|
||||
|
||||
await sutProvider.Sut.PostSetKeyConnectorKeyAsync(request);
|
||||
|
||||
await sutProvider.GetDependency<ISetKeyConnectorKeyCommand>().Received(1)
|
||||
.SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser),
|
||||
Arg.Do<KeyConnectorKeysData>(data =>
|
||||
{
|
||||
Assert.Equal(request.KeyConnectorKeyWrappedUserKey, data.KeyConnectorKeyWrappedUserKey);
|
||||
Assert.Equal(request.AccountKeys.AccountPublicKey, data.AccountKeys.AccountPublicKey);
|
||||
Assert.Equal(request.AccountKeys.UserKeyEncryptedAccountPrivateKey,
|
||||
data.AccountKeys.UserKeyEncryptedAccountPrivateKey);
|
||||
Assert.Equal(request.OrgIdentifier, data.OrgIdentifier);
|
||||
}));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetKeyConnectorKeyAsync_V2_CommandThrows_PropagatesException(
|
||||
SutProvider<AccountsKeyManagementController> sutProvider,
|
||||
User expectedUser)
|
||||
{
|
||||
var request = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = "wrapped-user-key",
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = "public-key",
|
||||
UserKeyEncryptedAccountPrivateKey = "encrypted-private-key"
|
||||
},
|
||||
OrgIdentifier = "test-org"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
|
||||
.Returns(expectedUser);
|
||||
sutProvider.GetDependency<ISetKeyConnectorKeyCommand>()
|
||||
.When(x => x.SetKeyConnectorKeyForUserAsync(Arg.Any<User>(), Arg.Any<KeyConnectorKeysData>()))
|
||||
.Do(_ => throw new BadRequestException("Command failed"));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request));
|
||||
|
||||
Assert.Equal("Command failed", exception.Message);
|
||||
await sutProvider.GetDependency<ISetKeyConnectorKeyCommand>().Received(1)
|
||||
.SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser),
|
||||
Arg.Do<KeyConnectorKeysData>(data =>
|
||||
{
|
||||
Assert.Equal(request.KeyConnectorKeyWrappedUserKey, data.KeyConnectorKeyWrappedUserKey);
|
||||
Assert.Equal(request.AccountKeys.AccountPublicKey, data.AccountKeys.AccountPublicKey);
|
||||
Assert.Equal(request.AccountKeys.UserKeyEncryptedAccountPrivateKey,
|
||||
data.AccountKeys.UserKeyEncryptedAccountPrivateKey);
|
||||
Assert.Equal(request.OrgIdentifier, data.OrgIdentifier);
|
||||
}));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostConvertToKeyConnectorAsync_UserNull_Throws(
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.KeyManagement.Models.Requests;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.KeyManagement.Models.Request;
|
||||
|
||||
public class SetKeyConnectorKeyRequestModelTests
|
||||
{
|
||||
private const string _wrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
|
||||
private const string _publicKey = "public-key";
|
||||
private const string _privateKey = "private-key";
|
||||
private const string _userKey = "user-key";
|
||||
private const string _orgIdentifier = "org-identifier";
|
||||
|
||||
[Fact]
|
||||
public void Validate_V2Registration_Valid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = _wrappedUserKey,
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = _publicKey,
|
||||
UserKeyEncryptedAccountPrivateKey = _privateKey
|
||||
},
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V2Registration_WrappedUserKeyNotEncryptedString_Invalid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = "not-encrypted-string",
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = _publicKey,
|
||||
UserKeyEncryptedAccountPrivateKey = _privateKey
|
||||
},
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Contains(results,
|
||||
r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey is not a valid encrypted string.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V1Registration_Valid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
Key = _userKey,
|
||||
Keys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = _publicKey,
|
||||
EncryptedPrivateKey = _privateKey
|
||||
},
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V1Registration_MissingKey_Invalid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
Key = null,
|
||||
Keys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = _publicKey,
|
||||
EncryptedPrivateKey = _privateKey
|
||||
},
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Contains(results, r => r.ErrorMessage == "Key must be supplied.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V1Registration_MissingKeys_Invalid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
Key = _userKey,
|
||||
Keys = null,
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Contains(results, r => r.ErrorMessage == "Keys must be supplied.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V1Registration_MissingKdf_Invalid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
Key = _userKey,
|
||||
Keys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = _publicKey,
|
||||
EncryptedPrivateKey = _privateKey
|
||||
},
|
||||
Kdf = null,
|
||||
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Contains(results, r => r.ErrorMessage == "Kdf must be supplied.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V1Registration_MissingKdfIterations_Invalid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
Key = _userKey,
|
||||
Keys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = _publicKey,
|
||||
EncryptedPrivateKey = _privateKey
|
||||
},
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = null,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Contains(results, r => r.ErrorMessage == "KdfIterations must be supplied.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V1Registration_Argon2id_MissingKdfMemory_Invalid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
Key = _userKey,
|
||||
Keys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = _publicKey,
|
||||
EncryptedPrivateKey = _privateKey
|
||||
},
|
||||
Kdf = KdfType.Argon2id,
|
||||
KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default,
|
||||
KdfMemory = null,
|
||||
KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Contains(results, r => r.ErrorMessage == "KdfMemory must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_V1Registration_Argon2id_MissingKdfParallelism_Invalid()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
Key = _userKey,
|
||||
Keys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = _publicKey,
|
||||
EncryptedPrivateKey = _privateKey
|
||||
},
|
||||
Kdf = KdfType.Argon2id,
|
||||
KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default,
|
||||
KdfMemory = AuthConstants.ARGON2_MEMORY.Default,
|
||||
KdfParallelism = null,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Validate(model);
|
||||
|
||||
// Assert
|
||||
Assert.Single(results);
|
||||
Assert.Contains(results, r => r.ErrorMessage == "KdfParallelism must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToKeyConnectorKeysData_EmptyKeyConnectorKeyWrappedUserKey_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = "",
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = _publicKey,
|
||||
UserKeyEncryptedAccountPrivateKey = _privateKey
|
||||
},
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var exception = Assert.Throws<BadRequestException>(() => model.ToKeyConnectorKeysData());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToKeyConnectorKeysData_NullKeyConnectorKeyWrappedUserKey_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = null,
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = _publicKey,
|
||||
UserKeyEncryptedAccountPrivateKey = _privateKey
|
||||
},
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var exception = Assert.Throws<BadRequestException>(() => model.ToKeyConnectorKeysData());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToKeyConnectorKeysData_NullAccountKeys_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = _wrappedUserKey,
|
||||
AccountKeys = null,
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var exception = Assert.Throws<BadRequestException>(() => model.ToKeyConnectorKeysData());
|
||||
|
||||
// Assert
|
||||
Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToKeyConnectorKeysData_Valid_Success()
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetKeyConnectorKeyRequestModel
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = _wrappedUserKey,
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
AccountPublicKey = _publicKey,
|
||||
UserKeyEncryptedAccountPrivateKey = _privateKey
|
||||
},
|
||||
OrgIdentifier = _orgIdentifier
|
||||
};
|
||||
|
||||
// Act
|
||||
var data = model.ToKeyConnectorKeysData();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(_wrappedUserKey, data.KeyConnectorKeyWrappedUserKey);
|
||||
Assert.Equal(_publicKey, data.AccountKeys.AccountPublicKey);
|
||||
Assert.Equal(_privateKey, data.AccountKeys.UserKeyEncryptedAccountPrivateKey);
|
||||
Assert.Equal(_orgIdentifier, data.OrgIdentifier);
|
||||
}
|
||||
|
||||
private static List<ValidationResult> Validate(SetKeyConnectorKeyRequestModel model)
|
||||
{
|
||||
var results = new List<ValidationResult>();
|
||||
Validator.TryValidateObject(model, new ValidationContext(model), results, true);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using Bit.Api.Dirt.Public.Models;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Api.Utilities.DiagnosticTools;
|
||||
using Bit.Core;
|
||||
@@ -155,7 +155,7 @@ public class EventDiagnosticLoggerTests
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true);
|
||||
|
||||
Bit.Api.Models.Response.EventResponseModel[] emptyEvents = [];
|
||||
Api.Dirt.Models.Response.EventResponseModel[] emptyEvents = [];
|
||||
|
||||
// Act
|
||||
logger.LogAggregateData(featureService, organizationId, emptyEvents, null, null, null);
|
||||
@@ -188,7 +188,7 @@ public class EventDiagnosticLoggerTests
|
||||
var oldestEvent = Substitute.For<IEvent>();
|
||||
oldestEvent.Date.Returns(DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var events = new List<Bit.Api.Models.Response.EventResponseModel>
|
||||
var events = new List<Api.Dirt.Models.Response.EventResponseModel>
|
||||
{
|
||||
new (newestEvent),
|
||||
new (middleEvent),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Test.Billing.Mocks;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
@@ -17,20 +20,19 @@ using static StripeConstants;
|
||||
public class RestartSubscriptionCommandTests
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
|
||||
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
|
||||
private readonly RestartSubscriptionCommand _command;
|
||||
|
||||
public RestartSubscriptionCommandTests()
|
||||
{
|
||||
_command = new RestartSubscriptionCommand(
|
||||
Substitute.For<Microsoft.Extensions.Logging.ILogger<RestartSubscriptionCommand>>(),
|
||||
_organizationRepository,
|
||||
_providerRepository,
|
||||
_pricingClient,
|
||||
_stripeAdapter,
|
||||
_subscriberService,
|
||||
_userRepository);
|
||||
_subscriberService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -63,11 +65,56 @@ public class RestartSubscriptionCommandTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_Success_ReturnsNone()
|
||||
public async Task Run_Provider_ReturnsUnhandledWithNotSupportedException()
|
||||
{
|
||||
var provider = new Provider { Id = Guid.NewGuid() };
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123"
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(provider).Returns(existingSubscription);
|
||||
|
||||
var result = await _command.Run(provider);
|
||||
|
||||
Assert.True(result.IsT3);
|
||||
var unhandled = result.AsT3;
|
||||
Assert.IsType<NotSupportedException>(unhandled.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_User_ReturnsUnhandledWithNotSupportedException()
|
||||
{
|
||||
var user = new User { Id = Guid.NewGuid() };
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123"
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(user).Returns(existingSubscription);
|
||||
|
||||
var result = await _command.Run(user);
|
||||
|
||||
Assert.True(result.IsT3);
|
||||
var unhandled = result.AsT3;
|
||||
Assert.IsType<NotSupportedException>(unhandled.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_MissingPasswordManagerItem_ReturnsUnhandledWithConflictException()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organization = new Organization { Id = organizationId };
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
var plan = MockPlans.Get(PlanType.EnterpriseAnnually);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
@@ -77,11 +124,122 @@ public class RestartSubscriptionCommandTests
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 },
|
||||
new SubscriptionItem { Price = new Price { Id = "price_2" }, Quantity = 2 }
|
||||
new SubscriptionItem { Price = new Price { Id = "some-other-price-id" }, Quantity = 10 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["key"] = "value" }
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_pricingClient.ListPlans().Returns([plan]);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT3);
|
||||
var unhandled = result.AsT3;
|
||||
Assert.IsType<ConflictException>(unhandled.Exception);
|
||||
Assert.Equal("Organization's subscription does not have a Password Manager subscription item.", unhandled.Exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_PlanNotFound_ReturnsUnhandledWithConflictException()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = "some-price-id" }, Quantity = 10 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
// Return a plan list that doesn't contain the organization's plan type
|
||||
_pricingClient.ListPlans().Returns([MockPlans.Get(PlanType.TeamsAnnually)]);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT3);
|
||||
var unhandled = result.AsT3;
|
||||
Assert.IsType<ConflictException>(unhandled.Exception);
|
||||
Assert.Equal("Could not find plan for organization's plan type", unhandled.Exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_DisabledPlanWithNoEnabledReplacement_ReturnsUnhandledWithConflictException()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.EnterpriseAnnually2023
|
||||
};
|
||||
|
||||
var oldPlan = new DisabledEnterprisePlan2023(true);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_old",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeSeatPlanId }, Quantity = 20 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
// Return only the disabled plan, with no enabled replacement
|
||||
_pricingClient.ListPlans().Returns([oldPlan]);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT3);
|
||||
var unhandled = result.AsT3;
|
||||
Assert.IsType<ConflictException>(unhandled.Exception);
|
||||
Assert.Equal("Could not find the current, enabled plan for organization's tier and cadence", unhandled.Exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_WithNonDisabledPlan_PasswordManagerOnly_Success()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
var plan = MockPlans.Get(PlanType.EnterpriseAnnually);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 10 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
@@ -89,30 +247,26 @@ public class RestartSubscriptionCommandTests
|
||||
Id = "sub_new",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_pricingClient.ListPlans().Returns([plan]);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is((SubscriptionCreateOptions options) =>
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.CollectionMethod == CollectionMethod.ChargeAutomatically &&
|
||||
options.Customer == "cus_123" &&
|
||||
options.Items.Count == 2 &&
|
||||
options.Items[0].Price == "price_1" &&
|
||||
options.Items[0].Quantity == 1 &&
|
||||
options.Items[1].Price == "price_2" &&
|
||||
options.Items[1].Quantity == 2 &&
|
||||
options.Metadata["key"] == "value" &&
|
||||
options.Items.Count == 1 &&
|
||||
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items[0].Quantity == 10 &&
|
||||
options.Metadata["organizationId"] == organizationId.ToString() &&
|
||||
options.OffSession == true &&
|
||||
options.TrialPeriodDays == 0));
|
||||
|
||||
@@ -120,96 +274,417 @@ public class RestartSubscriptionCommandTests
|
||||
org.Id == organizationId &&
|
||||
org.GatewaySubscriptionId == "sub_new" &&
|
||||
org.Enabled == true &&
|
||||
org.ExpirationDate == currentPeriodEnd));
|
||||
org.ExpirationDate == currentPeriodEnd &&
|
||||
org.PlanType == PlanType.EnterpriseAnnually));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Provider_Success_ReturnsNone()
|
||||
public async Task Run_Organization_WithNonDisabledPlan_WithStorage_Success()
|
||||
{
|
||||
var providerId = Guid.NewGuid();
|
||||
var provider = new Provider { Id = providerId };
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(provider).Returns(existingSubscription);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(provider);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
await _providerRepository.Received(1).ReplaceAsync(Arg.Is<Provider>(prov =>
|
||||
prov.Id == providerId &&
|
||||
prov.GatewaySubscriptionId == "sub_new" &&
|
||||
prov.Enabled == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_User_Success_ReturnsNone()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new User { Id = userId };
|
||||
var organizationId = Guid.NewGuid();
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.TeamsAnnually
|
||||
};
|
||||
|
||||
var plan = MockPlans.Get(PlanType.TeamsAnnually);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
CustomerId = "cus_456",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 5 },
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 3 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new_2",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(user).Returns(existingSubscription);
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_pricingClient.ListPlans().Returns([plan]);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(user);
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Items.Count == 2 &&
|
||||
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items[0].Quantity == 5 &&
|
||||
options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId &&
|
||||
options.Items[1].Quantity == 3));
|
||||
|
||||
await _userRepository.Received(1).ReplaceAsync(Arg.Is<User>(u =>
|
||||
u.Id == userId &&
|
||||
u.GatewaySubscriptionId == "sub_new" &&
|
||||
u.Premium == true &&
|
||||
u.PremiumExpirationDate == currentPeriodEnd));
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
|
||||
org.Id == organizationId &&
|
||||
org.GatewaySubscriptionId == "sub_new_2" &&
|
||||
org.Enabled == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_WithSecretsManager_Success()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.EnterpriseMonthly
|
||||
};
|
||||
|
||||
var plan = MockPlans.Get(PlanType.EnterpriseMonthly);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_789",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 15 },
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 2 },
|
||||
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 10 },
|
||||
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeServiceAccountPlanId }, Quantity = 100 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new_3",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_pricingClient.ListPlans().Returns([plan]);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Items.Count == 4 &&
|
||||
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items[0].Quantity == 15 &&
|
||||
options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId &&
|
||||
options.Items[1].Quantity == 2 &&
|
||||
options.Items[2].Price == plan.SecretsManager.StripeSeatPlanId &&
|
||||
options.Items[2].Quantity == 10 &&
|
||||
options.Items[3].Price == plan.SecretsManager.StripeServiceAccountPlanId &&
|
||||
options.Items[3].Quantity == 100));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
|
||||
org.Id == organizationId &&
|
||||
org.GatewaySubscriptionId == "sub_new_3" &&
|
||||
org.Enabled == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_WithDisabledPlan_UpgradesToNewPlan_Success()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.EnterpriseAnnually2023
|
||||
};
|
||||
|
||||
var oldPlan = new DisabledEnterprisePlan2023(true);
|
||||
var newPlan = MockPlans.Get(PlanType.EnterpriseAnnually);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_old",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeSeatPlanId }, Quantity = 20 },
|
||||
new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeStoragePlanId }, Quantity = 5 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_upgraded",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_pricingClient.ListPlans().Returns([oldPlan, newPlan]);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Items.Count == 2 &&
|
||||
options.Items[0].Price == newPlan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items[0].Quantity == 20 &&
|
||||
options.Items[1].Price == newPlan.PasswordManager.StripeStoragePlanId &&
|
||||
options.Items[1].Quantity == 5));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
|
||||
org.Id == organizationId &&
|
||||
org.GatewaySubscriptionId == "sub_upgraded" &&
|
||||
org.Enabled == true &&
|
||||
org.PlanType == PlanType.EnterpriseAnnually &&
|
||||
org.Plan == newPlan.Name &&
|
||||
org.SelfHost == newPlan.HasSelfHost &&
|
||||
org.UsePolicies == newPlan.HasPolicies &&
|
||||
org.UseGroups == newPlan.HasGroups &&
|
||||
org.UseDirectory == newPlan.HasDirectory &&
|
||||
org.UseEvents == newPlan.HasEvents &&
|
||||
org.UseTotp == newPlan.HasTotp &&
|
||||
org.Use2fa == newPlan.Has2fa &&
|
||||
org.UseApi == newPlan.HasApi &&
|
||||
org.UseSso == newPlan.HasSso &&
|
||||
org.UseOrganizationDomains == newPlan.HasOrganizationDomains &&
|
||||
org.UseKeyConnector == newPlan.HasKeyConnector &&
|
||||
org.UseScim == newPlan.HasScim &&
|
||||
org.UseResetPassword == newPlan.HasResetPassword &&
|
||||
org.UsersGetPremium == newPlan.UsersGetPremium &&
|
||||
org.UseCustomPermissions == newPlan.HasCustomPermissions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_WithStorageAndSecretManagerButNoServiceAccounts_Success()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.TeamsAnnually
|
||||
};
|
||||
|
||||
var plan = MockPlans.Get(PlanType.TeamsAnnually);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_complex",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 12 },
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 8 },
|
||||
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 6 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_complex",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_pricingClient.ListPlans().Returns([plan]);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Items.Count == 3 &&
|
||||
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items[0].Quantity == 12 &&
|
||||
options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId &&
|
||||
options.Items[1].Quantity == 8 &&
|
||||
options.Items[2].Price == plan.SecretsManager.StripeSeatPlanId &&
|
||||
options.Items[2].Quantity == 6));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
|
||||
org.Id == organizationId &&
|
||||
org.GatewaySubscriptionId == "sub_complex" &&
|
||||
org.Enabled == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_WithSecretsManagerOnly_NoServiceAccounts_Success()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
PlanType = PlanType.TeamsMonthly
|
||||
};
|
||||
|
||||
var plan = MockPlans.Get(PlanType.TeamsMonthly);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_sm",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 8 },
|
||||
new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 5 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() }
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_sm",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_pricingClient.ListPlans().Returns([plan]);
|
||||
_stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Items.Count == 2 &&
|
||||
options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.Items[0].Quantity == 8 &&
|
||||
options.Items[1].Price == plan.SecretsManager.StripeSeatPlanId &&
|
||||
options.Items[1].Quantity == 5));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
|
||||
org.Id == organizationId &&
|
||||
org.GatewaySubscriptionId == "sub_sm" &&
|
||||
org.Enabled == true));
|
||||
}
|
||||
|
||||
private record DisabledEnterprisePlan2023 : Bit.Core.Models.StaticStore.Plan
|
||||
{
|
||||
public DisabledEnterprisePlan2023(bool isAnnual)
|
||||
{
|
||||
Type = PlanType.EnterpriseAnnually2023;
|
||||
ProductTier = ProductTierType.Enterprise;
|
||||
Name = "Enterprise (Annually) 2023";
|
||||
IsAnnual = isAnnual;
|
||||
NameLocalizationKey = "planNameEnterprise";
|
||||
DescriptionLocalizationKey = "planDescEnterprise";
|
||||
CanBeUsedByBusiness = true;
|
||||
TrialPeriodDays = 7;
|
||||
HasPolicies = true;
|
||||
HasSelfHost = true;
|
||||
HasGroups = true;
|
||||
HasDirectory = true;
|
||||
HasEvents = true;
|
||||
HasTotp = true;
|
||||
Has2fa = true;
|
||||
HasApi = true;
|
||||
HasSso = true;
|
||||
HasOrganizationDomains = true;
|
||||
HasKeyConnector = true;
|
||||
HasScim = true;
|
||||
HasResetPassword = true;
|
||||
UsersGetPremium = true;
|
||||
HasCustomPermissions = true;
|
||||
UpgradeSortOrder = 4;
|
||||
DisplaySortOrder = 4;
|
||||
LegacyYear = 2024;
|
||||
Disabled = true;
|
||||
|
||||
PasswordManager = new PasswordManagerFeatures(isAnnual);
|
||||
SecretsManager = new SecretsManagerFeatures(isAnnual);
|
||||
}
|
||||
|
||||
private record SecretsManagerFeatures : SecretsManagerPlanFeatures
|
||||
{
|
||||
public SecretsManagerFeatures(bool isAnnual)
|
||||
{
|
||||
BaseSeats = 0;
|
||||
BasePrice = 0;
|
||||
BaseServiceAccount = 200;
|
||||
HasAdditionalSeatsOption = true;
|
||||
HasAdditionalServiceAccountOption = true;
|
||||
AllowSeatAutoscale = true;
|
||||
AllowServiceAccountsAutoscale = true;
|
||||
|
||||
if (isAnnual)
|
||||
{
|
||||
StripeSeatPlanId = "secrets-manager-enterprise-seat-annually-2023";
|
||||
StripeServiceAccountPlanId = "secrets-manager-service-account-2023-annually";
|
||||
SeatPrice = 144;
|
||||
AdditionalPricePerServiceAccount = 12;
|
||||
}
|
||||
else
|
||||
{
|
||||
StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly-2023";
|
||||
StripeServiceAccountPlanId = "secrets-manager-service-account-2023-monthly";
|
||||
SeatPrice = 13;
|
||||
AdditionalPricePerServiceAccount = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record PasswordManagerFeatures : PasswordManagerPlanFeatures
|
||||
{
|
||||
public PasswordManagerFeatures(bool isAnnual)
|
||||
{
|
||||
BaseSeats = 0;
|
||||
BaseStorageGb = 1;
|
||||
HasAdditionalStorageOption = true;
|
||||
HasAdditionalSeatsOption = true;
|
||||
AllowSeatAutoscale = true;
|
||||
|
||||
if (isAnnual)
|
||||
{
|
||||
AdditionalStoragePricePerGb = 4;
|
||||
StripeStoragePlanId = "storage-gb-annually";
|
||||
StripeSeatPlanId = "2023-enterprise-org-seat-annually-old";
|
||||
SeatPrice = 72;
|
||||
}
|
||||
else
|
||||
{
|
||||
StripeSeatPlanId = "2023-enterprise-seat-monthly-old";
|
||||
StripeStoragePlanId = "storage-gb-monthly";
|
||||
SeatPrice = 7;
|
||||
AdditionalStoragePricePerGb = 0.5M;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Authorization;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.KeyManagement.Authorization;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class KeyConnectorAuthorizationHandlerTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleRequirementAsync_UserCanUseKeyConnector_Success(
|
||||
User user,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
SutProvider<KeyConnectorAuthorizationHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.UsesKeyConnector = false;
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations
|
||||
.Returns(new List<CurrentContextOrganization>());
|
||||
|
||||
var requirement = KeyConnectorOperations.Use;
|
||||
var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleRequirementAsync_UserAlreadyUsesKeyConnector_Fails(
|
||||
User user,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
SutProvider<KeyConnectorAuthorizationHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.UsesKeyConnector = true;
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations
|
||||
.Returns(new List<CurrentContextOrganization>());
|
||||
|
||||
var requirement = KeyConnectorOperations.Use;
|
||||
var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleRequirementAsync_UserIsOwner_Fails(
|
||||
User user,
|
||||
Guid organizationId,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
SutProvider<KeyConnectorAuthorizationHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.UsesKeyConnector = false;
|
||||
var organizations = new List<CurrentContextOrganization>
|
||||
{
|
||||
new() { Id = organizationId, Type = OrganizationUserType.Owner }
|
||||
};
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(organizations);
|
||||
|
||||
var requirement = KeyConnectorOperations.Use;
|
||||
var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleRequirementAsync_UserIsAdmin_Fails(
|
||||
User user,
|
||||
Guid organizationId,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
SutProvider<KeyConnectorAuthorizationHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.UsesKeyConnector = false;
|
||||
var organizations = new List<CurrentContextOrganization>
|
||||
{
|
||||
new() { Id = organizationId, Type = OrganizationUserType.Admin }
|
||||
};
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(organizations);
|
||||
|
||||
var requirement = KeyConnectorOperations.Use;
|
||||
var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleRequirementAsync_UserIsRegularMember_Success(
|
||||
User user,
|
||||
Guid organizationId,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
SutProvider<KeyConnectorAuthorizationHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.UsesKeyConnector = false;
|
||||
var organizations = new List<CurrentContextOrganization>
|
||||
{
|
||||
new() { Id = organizationId, Type = OrganizationUserType.User }
|
||||
};
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(organizations);
|
||||
|
||||
var requirement = KeyConnectorOperations.Use;
|
||||
var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.HandleAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleRequirementAsync_UnsupportedRequirement_ThrowsArgumentException(
|
||||
User user,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
SutProvider<KeyConnectorAuthorizationHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.UsesKeyConnector = false;
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations
|
||||
.Returns(new List<CurrentContextOrganization>());
|
||||
|
||||
var unsupportedRequirement = new KeyConnectorOperationsRequirement("UnsupportedOperation");
|
||||
var context = new AuthorizationHandlerContext([unsupportedRequirement], claimsPrincipal, user);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => sutProvider.Sut.HandleAsync(context));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Commands;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.KeyManagement.Commands;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SetKeyConnectorKeyCommandTests
|
||||
{
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetKeyConnectorKeyForUserAsync_Success_SetsAccountKeys(
|
||||
User user,
|
||||
KeyConnectorKeysData data,
|
||||
SutProvider<SetKeyConnectorKeyCommand> sutProvider)
|
||||
{
|
||||
// Set up valid V2 encryption data
|
||||
if (data.AccountKeys!.SignatureKeyPair != null)
|
||||
{
|
||||
data.AccountKeys.SignatureKeyPair.SignatureAlgorithm = "ed25519";
|
||||
}
|
||||
|
||||
var expectedAccountKeysData = data.AccountKeys.ToAccountKeysData();
|
||||
|
||||
// Arrange
|
||||
user.UsesKeyConnector = false;
|
||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||
var httpContext = Substitute.For<HttpContext>();
|
||||
httpContext.User.Returns(new ClaimsPrincipal());
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
sutProvider.GetDependency<IAuthorizationService>()
|
||||
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), user, Arg.Any<IEnumerable<IAuthorizationRequirement>>())
|
||||
.Returns(AuthorizationResult.Success());
|
||||
|
||||
var userRepository = sutProvider.GetDependency<IUserRepository>();
|
||||
var mockUpdateUserData = Substitute.For<UpdateUserData>();
|
||||
userRepository.SetKeyConnectorUserKey(user.Id, data.KeyConnectorKeyWrappedUserKey!)
|
||||
.Returns(mockUpdateUserData);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SetKeyConnectorKeyForUserAsync(user, data);
|
||||
|
||||
// Assert
|
||||
|
||||
userRepository
|
||||
.Received(1)
|
||||
.SetKeyConnectorUserKey(user.Id, data.KeyConnectorKeyWrappedUserKey);
|
||||
|
||||
await userRepository
|
||||
.Received(1)
|
||||
.SetV2AccountCryptographicStateAsync(
|
||||
user.Id,
|
||||
Arg.Is<UserAccountKeysData>(data =>
|
||||
data.PublicKeyEncryptionKeyPairData.PublicKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.PublicKey &&
|
||||
data.PublicKeyEncryptionKeyPairData.WrappedPrivateKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey &&
|
||||
data.PublicKeyEncryptionKeyPairData.SignedPublicKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey &&
|
||||
data.SignatureKeyPairData!.SignatureAlgorithm == expectedAccountKeysData.SignatureKeyPairData!.SignatureAlgorithm &&
|
||||
data.SignatureKeyPairData.WrappedSigningKey == expectedAccountKeysData.SignatureKeyPairData.WrappedSigningKey &&
|
||||
data.SignatureKeyPairData.VerifyingKey == expectedAccountKeysData.SignatureKeyPairData.VerifyingKey &&
|
||||
data.SecurityStateData!.SecurityState == expectedAccountKeysData.SecurityStateData!.SecurityState &&
|
||||
data.SecurityStateData.SecurityVersion == expectedAccountKeysData.SecurityStateData.SecurityVersion),
|
||||
Arg.Is<IEnumerable<UpdateUserData>>(actions =>
|
||||
actions.Count() == 1 && actions.First() == mockUpdateUserData));
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
|
||||
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>()
|
||||
.Received(1)
|
||||
.AcceptOrgUserByOrgSsoIdAsync(data.OrgIdentifier, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetKeyConnectorKeyForUserAsync_UserCantUseKeyConnector_ThrowsException(
|
||||
User user,
|
||||
KeyConnectorKeysData data,
|
||||
SutProvider<SetKeyConnectorKeyCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
user.UsesKeyConnector = true;
|
||||
var currentContext = sutProvider.GetDependency<ICurrentContext>();
|
||||
var httpContext = Substitute.For<HttpContext>();
|
||||
httpContext.User.Returns(new ClaimsPrincipal());
|
||||
currentContext.HttpContext.Returns(httpContext);
|
||||
|
||||
sutProvider.GetDependency<IAuthorizationService>()
|
||||
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), user, Arg.Any<IEnumerable<IAuthorizationRequirement>>())
|
||||
.Returns(AuthorizationResult.Failed());
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SetKeyConnectorKeyForUserAsync(user, data));
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SetKeyConnectorUserKey(Arg.Any<Guid>(), Arg.Any<string>());
|
||||
|
||||
await sutProvider.GetDependency<IUserRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SetV2AccountCryptographicStateAsync(Arg.Any<Guid>(), Arg.Any<UserAccountKeysData>(), Arg.Any<IEnumerable<UpdateUserData>>());
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.LogUserEventAsync(Arg.Any<Guid>(), Arg.Any<EventType>());
|
||||
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.AcceptOrgUserByOrgSsoIdAsync(Arg.Any<string>(), Arg.Any<User>(), Arg.Any<IUserService>());
|
||||
}
|
||||
}
|
||||
@@ -1215,12 +1215,12 @@ public class CipherServiceTests
|
||||
private static SaveDetailsAsyncDependencies GetSaveDetailsAsyncDependencies(
|
||||
SutProvider<CipherService> sutProvider,
|
||||
string newPassword,
|
||||
bool viewPassword,
|
||||
bool editPermission,
|
||||
bool permission,
|
||||
string? key = null,
|
||||
string? totp = null,
|
||||
CipherLoginFido2CredentialData[]? passkeys = null,
|
||||
CipherFieldData[]? fields = null
|
||||
CipherFieldData[]? fields = null,
|
||||
string? existingKey = "OriginalKey"
|
||||
)
|
||||
{
|
||||
var cipherDetails = new CipherDetails
|
||||
@@ -1233,13 +1233,22 @@ public class CipherServiceTests
|
||||
Key = key,
|
||||
};
|
||||
|
||||
var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys, Fields = fields };
|
||||
var newLoginData = new CipherLoginData
|
||||
{
|
||||
Username = "user",
|
||||
Password = newPassword,
|
||||
Totp = totp,
|
||||
Fido2Credentials = passkeys,
|
||||
Fields = fields
|
||||
};
|
||||
|
||||
cipherDetails.Data = JsonSerializer.Serialize(newLoginData);
|
||||
|
||||
var existingCipher = new Cipher
|
||||
{
|
||||
Id = cipherDetails.Id,
|
||||
Type = CipherType.Login,
|
||||
Key = existingKey,
|
||||
Data = JsonSerializer.Serialize(
|
||||
new CipherLoginData
|
||||
{
|
||||
@@ -1261,7 +1270,14 @@ public class CipherServiceTests
|
||||
|
||||
var permissions = new Dictionary<Guid, OrganizationCipherPermission>
|
||||
{
|
||||
{ cipherDetails.Id, new OrganizationCipherPermission { ViewPassword = viewPassword, Edit = editPermission } }
|
||||
{
|
||||
cipherDetails.Id,
|
||||
new OrganizationCipherPermission
|
||||
{
|
||||
ViewPassword = permission,
|
||||
Edit = permission
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGetCipherPermissionsForUserQuery>()
|
||||
@@ -1278,7 +1294,7 @@ public class CipherServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveDetailsAsync_PasswordNotChangedWithoutViewPasswordPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: true);
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false);
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1294,7 +1310,7 @@ public class CipherServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveDetailsAsync_PasswordNotChangedWithoutEditPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false);
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false);
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1310,7 +1326,7 @@ public class CipherServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveDetailsAsync_PasswordChangedWithPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true);
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true);
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1326,7 +1342,11 @@ public class CipherServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveDetailsAsync_CipherKeyChangedWithPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, "NewKey");
|
||||
var deps = GetSaveDetailsAsyncDependencies(
|
||||
sutProvider,
|
||||
newPassword: "NewPassword",
|
||||
permission: true,
|
||||
key: "NewKey");
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1336,27 +1356,40 @@ public class CipherServiceTests
|
||||
true);
|
||||
|
||||
Assert.Equal("NewKey", deps.CipherDetails.Key);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received()
|
||||
.ReplaceAsync(Arg.Is<CipherDetails>(c => c.Id == deps.CipherDetails.Id && c.Key == "NewKey"));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveDetailsAsync_CipherKeyChangedWithoutPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
public async Task SaveDetailsAsync_CipherKeyNotChangedWithoutPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, "NewKey");
|
||||
var deps = GetSaveDetailsAsyncDependencies(
|
||||
sutProvider,
|
||||
newPassword: "NewPassword",
|
||||
permission: false,
|
||||
key: "NewKey"
|
||||
);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
deps.CipherDetails.UserId.Value,
|
||||
deps.CipherDetails.RevisionDate,
|
||||
null,
|
||||
true));
|
||||
true);
|
||||
|
||||
Assert.Contains("do not have permission", exception.Message);
|
||||
Assert.Equal("OriginalKey", deps.CipherDetails.Key);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received()
|
||||
.ReplaceAsync(Arg.Is<CipherDetails>(c => c.Id == deps.CipherDetails.Id && c.Key == "OriginalKey"));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveDetailsAsync_TotpChangedWithoutPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, totp: "NewTotp");
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false, totp: "NewTotp");
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1372,7 +1405,7 @@ public class CipherServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveDetailsAsync_TotpChangedWithPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, totp: "NewTotp");
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true, totp: "NewTotp");
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1397,7 +1430,7 @@ public class CipherServiceTests
|
||||
}
|
||||
};
|
||||
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, passkeys: passkeys);
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false, passkeys: passkeys);
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1422,7 +1455,7 @@ public class CipherServiceTests
|
||||
}
|
||||
};
|
||||
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, passkeys: passkeys);
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true, passkeys: passkeys);
|
||||
|
||||
await deps.SutProvider.Sut.SaveDetailsAsync(
|
||||
deps.CipherDetails,
|
||||
@@ -1439,7 +1472,7 @@ public class CipherServiceTests
|
||||
[BitAutoData]
|
||||
public async Task SaveDetailsAsync_HiddenFieldsChangedWithoutPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: false, fields:
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false, fields:
|
||||
[
|
||||
new CipherFieldData
|
||||
{
|
||||
@@ -1464,7 +1497,7 @@ public class CipherServiceTests
|
||||
[BitAutoData]
|
||||
public async Task SaveDetailsAsync_HiddenFieldsChangedWithPermission(string _, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, fields:
|
||||
var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true, fields:
|
||||
[
|
||||
new CipherFieldData
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using AutoFixture.Xunit2;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -9,6 +10,7 @@ using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Events.Controllers;
|
||||
using Bit.Events.Models;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
|
||||
@@ -21,6 +23,7 @@ public class CollectControllerTests
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ICipherRepository _cipherRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
|
||||
public CollectControllerTests()
|
||||
{
|
||||
@@ -28,12 +31,14 @@ public class CollectControllerTests
|
||||
_eventService = Substitute.For<IEventService>();
|
||||
_cipherRepository = Substitute.For<ICipherRepository>();
|
||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
|
||||
_sut = new CollectController(
|
||||
_currentContext,
|
||||
_eventService,
|
||||
_cipherRepository,
|
||||
_organizationRepository
|
||||
_organizationRepository,
|
||||
_organizationUserRepository
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +79,32 @@ public class CollectControllerTests
|
||||
await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, eventDate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(EventType.Organization_ItemOrganization_Accepted)]
|
||||
[BitAutoData(EventType.Organization_ItemOrganization_Declined)]
|
||||
public async Task Post_Organization_ItemOrganization_LogsOrganizationUserEvent(
|
||||
EventType type, Guid userId, Guid orgId, OrganizationUser orgUser)
|
||||
{
|
||||
_currentContext.UserId.Returns(userId);
|
||||
orgUser.OrganizationId = orgId;
|
||||
_organizationUserRepository.GetByOrganizationAsync(orgId, userId).Returns(orgUser);
|
||||
var eventDate = DateTime.UtcNow;
|
||||
var events = new List<EventModel>
|
||||
{
|
||||
new EventModel
|
||||
{
|
||||
Type = type,
|
||||
OrganizationId = orgId,
|
||||
Date = eventDate
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _sut.Post(events);
|
||||
|
||||
Assert.IsType<OkResult>(result);
|
||||
await _eventService.Received(1).LogOrganizationUserEventAsync(orgUser, type, eventDate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[AutoData]
|
||||
public async Task Post_CipherAutofilled_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
|
||||
|
||||
@@ -81,6 +81,7 @@ public class IdentityServerTests : IClassFixture<IdentityApplicationFactory>
|
||||
var root = body.RootElement;
|
||||
AssertRefreshTokenExists(root);
|
||||
AssertHelper.AssertJsonProperty(root, "ForcePasswordReset", JsonValueKind.False);
|
||||
AssertHelper.AssertJsonProperty(root, "ResetMasterPassword", JsonValueKind.False);
|
||||
var kdf = AssertHelper.AssertJsonProperty(root, "Kdf", JsonValueKind.Number).GetInt32();
|
||||
Assert.Equal(0, kdf);
|
||||
var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user