diff --git a/.claude/prompts/review-code.md b/.claude/prompts/review-code.md deleted file mode 100644 index 4e5f40b274..0000000000 --- a/.claude/prompts/review-code.md +++ /dev/null @@ -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
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. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 597085d97d..f0c85d98c1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/_move_edd_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml index 7e97fa2a07..742e7b897e 100644 --- a/.github/workflows/_move_edd_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b457b9d56..1afaab0882 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 63079826c7..ae482ef4e6 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -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 }} diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 35e6cfdd40..98f5288ec8 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -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 diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index cdb53109f5..dd3cef9d83 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -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 diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index a939be6fdb..4b137eb221 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2272387d84..6f00d4f85f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75b4df4e5c..887f78f5df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 74823c34b5..a0f7ea73b1 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -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 }} diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 83d492645e..c683400a60 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -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" diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index b0d0c076a1..5ce13b25c6 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36ab8785d5..72dd17d7d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/src/Api/Billing/Controllers/LicensesController.cs b/src/Api/Billing/Controllers/LicensesController.cs new file mode 100644 index 0000000000..29313bd4d8 --- /dev/null +++ b/src/Api/Billing/Controllers/LicensesController.cs @@ -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 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; + } + + /// + /// Used by self-hosted installations to get an updated license file + /// + [HttpGet("organization/{id}")] + public async Task 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; + } +} diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/Dirt/Controllers/EventsController.cs similarity index 99% rename from src/Api/AdminConsole/Controllers/EventsController.cs rename to src/Api/Dirt/Controllers/EventsController.cs index 7e058a7870..1ac83c1316 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/Dirt/Controllers/EventsController.cs @@ -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")] diff --git a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs b/src/Api/Dirt/Models/Response/EventResponseModel.cs similarity index 98% rename from src/Api/AdminConsole/Models/Response/EventResponseModel.cs rename to src/Api/Dirt/Models/Response/EventResponseModel.cs index c259bc3bc4..bfcc50c84e 100644 --- a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs +++ b/src/Api/Dirt/Models/Response/EventResponseModel.cs @@ -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 { diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/Dirt/Public/Controllers/EventsController.cs similarity index 98% rename from src/Api/AdminConsole/Public/Controllers/EventsController.cs rename to src/Api/Dirt/Public/Controllers/EventsController.cs index b92e576ef9..8c76137489 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/Dirt/Public/Controllers/EventsController.cs @@ -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")] diff --git a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs similarity index 97% rename from src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs rename to src/Api/Dirt/Public/Models/EventFilterRequestModel.cs index a007349f26..20984c2cb0 100644 --- a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs +++ b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Exceptions; -namespace Bit.Api.Models.Public.Request; +namespace Bit.Api.Dirt.Public.Models; public class EventFilterRequestModel { diff --git a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs b/src/Api/Dirt/Public/Models/EventResponseModel.cs similarity index 98% rename from src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs rename to src/Api/Dirt/Public/Models/EventResponseModel.cs index 3e1de2747a..77c0b5a275 100644 --- a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs +++ b/src/Api/Dirt/Public/Models/EventResponseModel.cs @@ -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; /// /// An event log. diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index b944cdd052..a124616e30 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller _webauthnKeyValidator; private readonly IRotationValidator, IEnumerable> _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, IReadOnlyList> organizationUserValidator, - IRotationValidator, IEnumerable> webAuthnKeyValidator, - IRotationValidator, IEnumerable> deviceValidator) + IRotationValidator, IEnumerable> + webAuthnKeyValidator, + IRotationValidator, IEnumerable> 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")] diff --git a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index 9f52a97383..6cd13fdf83 100644 --- a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -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 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 + }; + } } diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs index af162fe399..0f467a4c78 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs @@ -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; diff --git a/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs index 9f6a8d2639..af34931181 100644 --- a/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs +++ b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs @@ -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 data, + IEnumerable data, string? continuationToken, DateTime? queryStart = null, DateTime? queryEnd = null) diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs index 7f7be9d1eb..165b8218a9 100644 --- a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -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 logger, IOrganizationRepository organizationRepository, - IProviderRepository providerRepository, + IPricingClient pricingClient, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - IUserRepository userRepository) : IRestartSubscriptionCommand + ISubscriberService subscriberService) : BaseBillingCommand(logger), IRestartSubscriptionCommand { - public async Task> Run( - ISubscriber subscriber) + public Task> Run( + ISubscriber subscriber) => HandleAsync(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(); + + // 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); } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 95ab009722..e1ccbbd9b8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -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"; diff --git a/src/Core/AdminConsole/Entities/Event.cs b/src/Core/Dirt/Entities/Event.cs similarity index 100% rename from src/Core/AdminConsole/Entities/Event.cs rename to src/Core/Dirt/Entities/Event.cs diff --git a/src/Core/AdminConsole/Enums/EventSystemUser.cs b/src/Core/Dirt/Enums/EventSystemUser.cs similarity index 100% rename from src/Core/AdminConsole/Enums/EventSystemUser.cs rename to src/Core/Dirt/Enums/EventSystemUser.cs diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/Dirt/Enums/EventType.cs similarity index 100% rename from src/Core/AdminConsole/Enums/EventType.cs rename to src/Core/Dirt/Enums/EventType.cs diff --git a/src/Core/AdminConsole/Models/Data/EventMessage.cs b/src/Core/Dirt/Models/Data/EventMessage.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/EventMessage.cs rename to src/Core/Dirt/Models/Data/EventMessage.cs diff --git a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs b/src/Core/Dirt/Models/Data/EventTableEntity.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/EventTableEntity.cs rename to src/Core/Dirt/Models/Data/EventTableEntity.cs diff --git a/src/Core/AdminConsole/Models/Data/IEvent.cs b/src/Core/Dirt/Models/Data/IEvent.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/IEvent.cs rename to src/Core/Dirt/Models/Data/IEvent.cs diff --git a/src/Core/AdminConsole/Repositories/IEventRepository.cs b/src/Core/Dirt/Repositories/IEventRepository.cs similarity index 100% rename from src/Core/AdminConsole/Repositories/IEventRepository.cs rename to src/Core/Dirt/Repositories/IEventRepository.cs diff --git a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs b/src/Core/Dirt/Repositories/TableStorage/EventRepository.cs similarity index 100% rename from src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs rename to src/Core/Dirt/Repositories/TableStorage/EventRepository.cs diff --git a/src/Core/AdminConsole/Services/IEventWriteService.cs b/src/Core/Dirt/Services/IEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/IEventWriteService.cs rename to src/Core/Dirt/Services/IEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs b/src/Core/Dirt/Services/Implementations/AzureQueueEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/AzureQueueEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/Dirt/Services/Implementations/EventIntegrationEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/EventIntegrationEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/Dirt/Services/Implementations/EventService.cs similarity index 100% rename from src/Core/AdminConsole/Services/Implementations/EventService.cs rename to src/Core/Dirt/Services/Implementations/EventService.cs diff --git a/src/Core/Services/Implementations/RepositoryEventWriteService.cs b/src/Core/Dirt/Services/Implementations/RepositoryEventWriteService.cs similarity index 100% rename from src/Core/Services/Implementations/RepositoryEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/RepositoryEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs similarity index 100% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventWriteService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopEventWriteService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopEventWriteService.cs diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs new file mode 100644 index 0000000000..7937390a8c --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs @@ -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 +{ + 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; + } +} diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs new file mode 100644 index 0000000000..a8d09a6ac7 --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs @@ -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)); +} diff --git a/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..65f6cddeb5 --- /dev/null +++ b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Commands.Interfaces; + +/// +/// Creates the user key and account cryptographic state for a new user registering +/// with Key Connector SSO configuration. +/// +public interface ISetKeyConnectorKeyCommand +{ + Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData); +} diff --git a/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..a96042de30 --- /dev/null +++ b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs @@ -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); + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index abaf9406ba..96f990c299 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -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(); + } + private static void AddKeyManagementCommands(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddKeyManagementQueries(this IServiceCollection services) diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs new file mode 100644 index 0000000000..5675c6bc96 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs @@ -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; } +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 47ddb86f8e..93316d78bd 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -72,6 +72,8 @@ public interface IUserRepository : IRepository UserAccountKeysData accountKeysData, IEnumerable? updateUserDataActions = null); Task DeleteManyAsync(IEnumerable users); + + UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey); } public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index fade63de51..a531883db1 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -33,6 +33,8 @@ public interface IUserService Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, string key); Task 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 SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); Task ConvertToKeyConnectorAsync(User user); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 8db66211b1..4e65e88767 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -621,6 +621,7 @@ public class UserService : UserManager, IUserService return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 public async Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier) { var identityResult = CheckCanUseKeyConnector(user); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 2085345b16..bb752b471f 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -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 diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index bae1575134..3902522665 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -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: diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 23a0289ee2..babf681c04 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -703,6 +703,7 @@ public abstract class BaseRequestValidator 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); diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 5d8c7d9cef..fbf6f3796d 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -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, 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 saveTask) { if (user == null) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Dirt/Configurations/EventEntityTypeConfiguration.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs rename to src/Infrastructure.EntityFramework/Dirt/Configurations/EventEntityTypeConfiguration.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Event.cs b/src/Infrastructure.EntityFramework/Dirt/Models/Event.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Models/Event.cs rename to src/Infrastructure.EntityFramework/Dirt/Models/Event.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/EventRepository.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/EventRepository.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByCipherIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByCipherIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByCipherIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByCipherIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProjectIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProjectIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProjectIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProjectIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageBySecretIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageBySecretIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageBySecretIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageBySecretIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByUserIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByUserIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByUserIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByUserIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 9bf093e506..56d64094d0 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -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, 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 userIds) { var defaultCollections = (from c in dbContext.Collections diff --git a/src/Sql/dbo/Stored Procedures/Event_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_Create.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_Create.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_Create.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadById.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadById.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadById.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadById.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByCipherId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByCipherId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByCipherId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByCipherId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByUserId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByUserId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByUserId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByUserId.sql diff --git a/src/Sql/dbo/Tables/Event.sql b/src/Sql/dbo/Dirt/Tables/Event.sql similarity index 100% rename from src/Sql/dbo/Tables/Event.sql rename to src/Sql/dbo/Dirt/Tables/Event.sql diff --git a/src/Sql/dbo/Views/EventView.sql b/src/Sql/dbo/Dirt/Views/EventView.sql similarity index 100% rename from src/Sql/dbo/Views/EventView.sql rename to src/Sql/dbo/Dirt/Views/EventView.sql diff --git a/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql new file mode 100644 index 0000000000..7ab20a42af --- /dev/null +++ b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql @@ -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 diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql index decd406280..4fc4681648 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql @@ -128,7 +128,8 @@ BEGIN [UseAdminSponsoredFamilies], [SyncSeats], [UseAutomaticUserConfirmation], - [UsePhishingBlocker] + [UsePhishingBlocker], + [MaxStorageGbIncreased] ) VALUES ( @@ -193,6 +194,7 @@ BEGIN @UseAdminSponsoredFamilies, @SyncSeats, @UseAutomaticUserConfirmation, - @UsePhishingBlocker + @UsePhishingBlocker, + @MaxStorageGb ); END diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql index 9fd1b59460..946cf03e94 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql @@ -128,7 +128,8 @@ BEGIN [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies, [SyncSeats] = @SyncSeats, [UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation, - [UsePhishingBlocker] = @UsePhishingBlocker + [UsePhishingBlocker] = @UsePhishingBlocker, + [MaxStorageGbIncreased] = @MaxStorageGb WHERE [Id] = @Id; END diff --git a/src/Sql/dbo/Stored Procedures/User_Create.sql b/src/Sql/dbo/Stored Procedures/User_Create.sql index 2573bf1a0a..cf0c12d1c5 100644 --- a/src/Sql/dbo/Stored Procedures/User_Create.sql +++ b/src/Sql/dbo/Stored Procedures/User_Create.sql @@ -96,7 +96,8 @@ BEGIN [VerifyDevices], [SecurityState], [SecurityVersion], - [SignedPublicKey] + [SignedPublicKey], + [MaxStorageGbIncreased] ) VALUES ( @@ -145,6 +146,7 @@ BEGIN @VerifyDevices, @SecurityState, @SecurityVersion, - @SignedPublicKey + @SignedPublicKey, + @MaxStorageGb ) END diff --git a/src/Sql/dbo/Stored Procedures/User_Update.sql b/src/Sql/dbo/Stored Procedures/User_Update.sql index 5097bc538e..05e0d4b4de 100644 --- a/src/Sql/dbo/Stored Procedures/User_Update.sql +++ b/src/Sql/dbo/Stored Procedures/User_Update.sql @@ -96,7 +96,8 @@ BEGIN [VerifyDevices] = @VerifyDevices, [SecurityState] = @SecurityState, [SecurityVersion] = @SecurityVersion, - [SignedPublicKey] = @SignedPublicKey + [SignedPublicKey] = @SignedPublicKey, + [MaxStorageGbIncreased] = @MaxStorageGb WHERE [Id] = @Id END diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1c456df106..eddffb6b36 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -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(featureService => + { + featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) + .Returns(true); + }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _userRepository = _factory.GetService(); @@ -78,8 +85,11 @@ public class AccountsKeyManagementControllerTests : IClassFixture(featureService => + { + featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) + .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 sutProvider, SetKeyConnectorKeyRequestModel data) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); await Assert.ThrowsAsync(() => 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 sutProvider, SetKeyConnectorKeyRequestModel data, User expectedUser) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + expectedUser.PublicKey = null; expectedUser.PrivateKey = null; sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) @@ -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 sutProvider, SetKeyConnectorKeyRequestModel data, User expectedUser) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + expectedUser.PublicKey = null; expectedUser.PrivateKey = null; sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) @@ -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 sutProvider) + { + var request = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = "public-key", + UserKeyEncryptedAccountPrivateKey = "encrypted-private-key" + }, + OrgIdentifier = "test-org" + }; + + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request)); + + await sutProvider.GetDependency().DidNotReceive() + .SetKeyConnectorKeyForUserAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_V2_Success( + SutProvider 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().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + + await sutProvider.Sut.PostSetKeyConnectorKeyAsync(request); + + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser), + Arg.Do(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 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().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .When(x => x.SetKeyConnectorKeyForUserAsync(Arg.Any(), Arg.Any())) + .Do(_ => throw new BadRequestException("Command failed")); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request)); + + Assert.Equal("Command failed", exception.Message); + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser), + Arg.Do(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( diff --git a/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs b/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs new file mode 100644 index 0000000000..95ee743d02 --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs @@ -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(() => 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(() => 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(() => 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 Validate(SetKeyConnectorKeyRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs b/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs index ada75b148b..95fa949bc7 100644 --- a/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs +++ b/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs @@ -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(); 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(); oldestEvent.Date.Returns(DateTime.UtcNow.AddDays(-2)); - var events = new List + var events = new List { new (newestEvent), new (middleEvent), diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs index 41f8839eb4..9f34c37b3c 100644 --- a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -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(); - private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly ISubscriberService _subscriberService = Substitute.For(); - private readonly IUserRepository _userRepository = Substitute.For(); private readonly RestartSubscriptionCommand _command; public RestartSubscriptionCommandTests() { _command = new RestartSubscriptionCommand( + Substitute.For>(), _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(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(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 { ["key"] = "value" } + Metadata = new Dictionary { ["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(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 + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = "some-price-id" }, Quantity = 10 } + ] + }, + Metadata = new Dictionary { ["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(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 + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeSeatPlanId }, Quantity = 20 } + ] + }, + Metadata = new Dictionary { ["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(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 + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 10 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } }; var newSubscription = new Subscription @@ -89,30 +247,26 @@ public class RestartSubscriptionCommandTests Id = "sub_new", Items = new StripeList { - Data = - [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } - ] + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] } }; _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).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(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 - { - Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] - }, - Metadata = new Dictionary() - }; - - var newSubscription = new Subscription - { - Id = "sub_new", - Items = new StripeList - { - Data = - [ - new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } - ] - } - }; - - _subscriberService.GetSubscription(provider).Returns(existingSubscription); - _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); - - var result = await _command.Run(provider); - - Assert.True(result.IsT0); - - await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); - - await _providerRepository.Received(1).ReplaceAsync(Arg.Is(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 - { - Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] - }, - Metadata = new Dictionary() - }; - - var newSubscription = new Subscription - { - Id = "sub_new", + CustomerId = "cus_456", Items = new StripeList { 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 { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_new_2", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] } }; - _subscriberService.GetSubscription(user).Returns(existingSubscription); + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).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()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(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(u => - u.Id == userId && - u.GatewaySubscriptionId == "sub_new" && - u.Premium == true && - u.PremiumExpirationDate == currentPeriodEnd)); + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(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 + { + 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 { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_new_3", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(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(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 + { + 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 { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_upgraded", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([oldPlan, newPlan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(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(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 + { + 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 { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_complex", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(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(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 + { + 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 { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_sm", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(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(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; + } + } + } } } diff --git a/test/Core.Test/AdminConsole/Services/AzureQueueEventWriteServiceTests.cs b/test/Core.Test/Dirt/Services/AzureQueueEventWriteServiceTests.cs similarity index 100% rename from test/Core.Test/AdminConsole/Services/AzureQueueEventWriteServiceTests.cs rename to test/Core.Test/Dirt/Services/AzureQueueEventWriteServiceTests.cs diff --git a/test/Core.Test/AdminConsole/Services/EventServiceTests.cs b/test/Core.Test/Dirt/Services/EventServiceTests.cs similarity index 100% rename from test/Core.Test/AdminConsole/Services/EventServiceTests.cs rename to test/Core.Test/Dirt/Services/EventServiceTests.cs diff --git a/test/Core.Test/AdminConsole/Services/RepositoryEventWriteServiceTests.cs b/test/Core.Test/Dirt/Services/RepositoryEventWriteServiceTests.cs similarity index 100% rename from test/Core.Test/AdminConsole/Services/RepositoryEventWriteServiceTests.cs rename to test/Core.Test/Dirt/Services/RepositoryEventWriteServiceTests.cs diff --git a/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs b/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..fb774a78ac --- /dev/null +++ b/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs @@ -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 sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + 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 sutProvider) + { + // Arrange + user.UsesKeyConnector = true; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + 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 sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.Owner } + }; + sutProvider.GetDependency().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 sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.Admin } + }; + sutProvider.GetDependency().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 sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.User } + }; + sutProvider.GetDependency().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 sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + var unsupportedRequirement = new KeyConnectorOperationsRequirement("UnsupportedOperation"); + var context = new AuthorizationHandlerContext([unsupportedRequirement], claimsPrincipal, user); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(context)); + } +} diff --git a/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs b/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs new file mode 100644 index 0000000000..74f76f368b --- /dev/null +++ b/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs @@ -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 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(); + var httpContext = Substitute.For(); + httpContext.User.Returns(new ClaimsPrincipal()); + currentContext.HttpContext.Returns(httpContext); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), user, Arg.Any>()) + .Returns(AuthorizationResult.Success()); + + var userRepository = sutProvider.GetDependency(); + var mockUpdateUserData = Substitute.For(); + 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(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>(actions => + actions.Count() == 1 && actions.First() == mockUpdateUserData)); + + await sutProvider.GetDependency() + .Received(1) + .LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + + await sutProvider.GetDependency() + .Received(1) + .AcceptOrgUserByOrgSsoIdAsync(data.OrgIdentifier, user, sutProvider.GetDependency()); + } + + [Theory, BitAutoData] + public async Task SetKeyConnectorKeyForUserAsync_UserCantUseKeyConnector_ThrowsException( + User user, + KeyConnectorKeysData data, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = true; + var currentContext = sutProvider.GetDependency(); + var httpContext = Substitute.For(); + httpContext.User.Returns(new ClaimsPrincipal()); + currentContext.HttpContext.Returns(httpContext); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), user, Arg.Any>()) + .Returns(AuthorizationResult.Failed()); + + // Act & Assert + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetKeyConnectorKeyForUserAsync(user, data)); + + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetKeyConnectorUserKey(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetV2AccountCryptographicStateAsync(Arg.Any(), Arg.Any(), Arg.Any>()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogUserEventAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AcceptOrgUserByOrgSsoIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index c5eecb8f34..fc84651951 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1215,12 +1215,12 @@ public class CipherServiceTests private static SaveDetailsAsyncDependencies GetSaveDetailsAsyncDependencies( SutProvider 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 { - { cipherDetails.Id, new OrganizationCipherPermission { ViewPassword = viewPassword, Edit = editPermission } } + { + cipherDetails.Id, + new OrganizationCipherPermission + { + ViewPassword = permission, + Edit = permission + } + } }; sutProvider.GetDependency() @@ -1278,7 +1294,7 @@ public class CipherServiceTests [Theory, BitAutoData] public async Task SaveDetailsAsync_PasswordNotChangedWithoutViewPasswordPermission(string _, SutProvider 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 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 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 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() + .Received() + .ReplaceAsync(Arg.Is(c => c.Id == deps.CipherDetails.Id && c.Key == "NewKey")); } [Theory, BitAutoData] - public async Task SaveDetailsAsync_CipherKeyChangedWithoutPermission(string _, SutProvider sutProvider) + public async Task SaveDetailsAsync_CipherKeyNotChangedWithoutPermission(string _, SutProvider 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(() => 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() + .Received() + .ReplaceAsync(Arg.Is(c => c.Id == deps.CipherDetails.Id && c.Key == "OriginalKey")); } [Theory, BitAutoData] public async Task SaveDetailsAsync_TotpChangedWithoutPermission(string _, SutProvider 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 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 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 sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, fields: + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true, fields: [ new CipherFieldData { diff --git a/test/Events.Test/Controllers/CollectControllerTests.cs b/test/Events.Test/Controllers/CollectControllerTests.cs index 325442d50e..b6fa018623 100644 --- a/test/Events.Test/Controllers/CollectControllerTests.cs +++ b/test/Events.Test/Controllers/CollectControllerTests.cs @@ -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(); _cipherRepository = Substitute.For(); _organizationRepository = Substitute.For(); + _organizationUserRepository = Substitute.For(); _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 + { + new EventModel + { + Type = type, + OrganizationId = orgId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogOrganizationUserEventAsync(orgUser, type, eventDate); + } + [Theory] [AutoData] public async Task Post_CipherAutofilled_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 102e3672c8..a4e6c6798e 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -81,6 +81,7 @@ public class IdentityServerTests : IClassFixture 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(); diff --git a/test/Infrastructure.EFIntegration.Test/AdminConsole/Autofixture/EventFixtures.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Autofixture/EventFixtures.cs similarity index 100% rename from test/Infrastructure.EFIntegration.Test/AdminConsole/Autofixture/EventFixtures.cs rename to test/Infrastructure.EFIntegration.Test/Dirt/Autofixture/EventFixtures.cs diff --git a/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/EqualityComparers/EventCompare.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/EqualityComparers/EventCompare.cs similarity index 100% rename from test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/EqualityComparers/EventCompare.cs rename to test/Infrastructure.EFIntegration.Test/Dirt/Repositories/EqualityComparers/EventCompare.cs diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs similarity index 91% rename from test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs rename to test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs index bbbd6d5cdb..98b2613ecb 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs @@ -1,9 +1,11 @@ -using Bit.Core.AdminConsole.Repositories; +using Bit.Core; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Microsoft.Data.SqlClient; using Xunit; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -500,4 +502,54 @@ public class UserRepositoryTests // Assert Assert.Empty(results); } + + [Theory, DatabaseData] + public async Task SetKeyConnectorUserKey_UpdatesUserKey(IUserRepository userRepository, Database database) + { + var user = await userRepository.CreateTestUserAsync(); + + const string keyConnectorWrappedKey = "key-connector-wrapped-user-key"; + + var setKeyConnectorUserKeyDelegate = userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorWrappedKey); + + await RunUpdateUserDataAsync(setKeyConnectorUserKeyDelegate, database); + + var updatedUser = await userRepository.GetByIdAsync(user.Id); + + Assert.NotNull(updatedUser); + Assert.Equal(keyConnectorWrappedKey, updatedUser.Key); + Assert.True(updatedUser.UsesKeyConnector); + Assert.Equal(KdfType.Argon2id, updatedUser.Kdf); + Assert.Equal(AuthConstants.ARGON2_ITERATIONS.Default, updatedUser.KdfIterations); + Assert.Equal(AuthConstants.ARGON2_MEMORY.Default, updatedUser.KdfMemory); + Assert.Equal(AuthConstants.ARGON2_PARALLELISM.Default, updatedUser.KdfParallelism); + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + } + + private static async Task RunUpdateUserDataAsync(UpdateUserData task, Database database) + { + if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf) + { + await using var connection = new SqlConnection(database.ConnectionString); + connection.Open(); + + await using var transaction = connection.BeginTransaction(); + try + { + await task(connection, transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + else + { + await task(); + } + } } diff --git a/util/Migrator/DbScripts/2025-12-12_00_PopulateMaxStorageGbIncreased.sql b/util/Migrator/DbScripts/2025-12-12_00_PopulateMaxStorageGbIncreased.sql new file mode 100644 index 0000000000..8e3031ccf4 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-12_00_PopulateMaxStorageGbIncreased.sql @@ -0,0 +1,121 @@ + -- ======================================== + -- Dependency Validation + -- ======================================== + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('[dbo].[User]') + AND name = 'MaxStorageGbIncreased' + ) + BEGIN + RAISERROR('MaxStorageGbIncreased column does not exist in User table. PM-27603 must be deployed first.', 16, 1); + RETURN; + END; + + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('[dbo].[Organization]') + AND name = 'MaxStorageGbIncreased' + ) + BEGIN + RAISERROR('MaxStorageGbIncreased column does not exist in Organization table. PM-27603 must be deployed first.', 16, 1); + RETURN; + END; + GO + + -- ======================================== + -- User Table Migration + -- ======================================== + + -- Create temporary index for performance + IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.User') + AND name = 'IX_TEMP_User_MaxStorageGb_MaxStorageGbIncreased' + ) + BEGIN + PRINT 'Creating temporary index on User table...'; + CREATE INDEX IX_TEMP_User_MaxStorageGb_MaxStorageGbIncreased + ON [dbo].[User]([MaxStorageGb], [MaxStorageGbIncreased]); + PRINT 'Temporary index created.'; + END + GO + + -- Populate MaxStorageGbIncreased for Users in batches + DECLARE @BatchSize INT = 5000; + DECLARE @RowsAffected INT = 1; + DECLARE @TotalUpdated INT = 0; + + PRINT 'Starting User table update...'; + + WHILE @RowsAffected > 0 + BEGIN + UPDATE TOP (@BatchSize) [dbo].[User] + SET [MaxStorageGbIncreased] = [MaxStorageGb] + 4 + WHERE [MaxStorageGb] IS NOT NULL + AND [MaxStorageGbIncreased] IS NULL; + + SET @RowsAffected = @@ROWCOUNT; + SET @TotalUpdated = @TotalUpdated + @RowsAffected; + + PRINT 'Users updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + + WAITFOR DELAY '00:00:00.100'; -- 100ms delay to reduce contention + END + + PRINT 'User table update complete. Total rows updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + GO + + -- Drop temporary index + DROP INDEX IF EXISTS [dbo].[User].[IX_TEMP_User_MaxStorageGb_MaxStorageGbIncreased]; + PRINT 'Temporary index on User table dropped.'; + GO + + -- ======================================== + -- Organization Table Migration + -- ======================================== + + -- Create temporary index for performance + IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.Organization') + AND name = 'IX_TEMP_Organization_MaxStorageGb_MaxStorageGbIncreased' + ) + BEGIN + PRINT 'Creating temporary index on Organization table...'; + CREATE INDEX IX_TEMP_Organization_MaxStorageGb_MaxStorageGbIncreased + ON [dbo].[Organization]([MaxStorageGb], [MaxStorageGbIncreased]); + PRINT 'Temporary index created.'; + END + GO + + -- Populate MaxStorageGbIncreased for Organizations in batches + DECLARE @BatchSize INT = 5000; + DECLARE @RowsAffected INT = 1; + DECLARE @TotalUpdated INT = 0; + + PRINT 'Starting Organization table update...'; + + WHILE @RowsAffected > 0 + BEGIN + UPDATE TOP (@BatchSize) [dbo].[Organization] + SET [MaxStorageGbIncreased] = [MaxStorageGb] + 4 + WHERE [MaxStorageGb] IS NOT NULL + AND [MaxStorageGbIncreased] IS NULL; + + SET @RowsAffected = @@ROWCOUNT; + SET @TotalUpdated = @TotalUpdated + @RowsAffected; + + PRINT 'Organizations updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + + WAITFOR DELAY '00:00:00.100'; -- 100ms delay to reduce contention + END + + PRINT 'Organization table update complete. Total rows updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + GO + + -- Drop temporary index + DROP INDEX IF EXISTS [dbo].[Organization].[IX_TEMP_Organization_MaxStorageGb_MaxStorageGbIncreased]; + PRINT 'Temporary index on Organization table dropped.'; + GO \ No newline at end of file diff --git a/util/Migrator/DbScripts/2025-12-12_00_UpdateStoredProceduresForMaxStorageGbIncreased.sql b/util/Migrator/DbScripts/2025-12-12_00_UpdateStoredProceduresForMaxStorageGbIncreased.sql new file mode 100644 index 0000000000..b679314e40 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-12_00_UpdateStoredProceduresForMaxStorageGbIncreased.sql @@ -0,0 +1,600 @@ +-- Update stored procedures to set MaxStorageGbIncreased column +-- This ensures that going forward, MaxStorageGbIncreased is kept in sync with MaxStorageGb + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT= null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = NULL, + @LimitCollectionDeletion BIT = NULL, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseOrganizationDomains BIT = 0, + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0, + @UseAutomaticUserConfirmation BIT = 0, + @UsePhishingBlocker BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats], + [UseKeyConnector], + [UseScim], + [UseCustomPermissions], + [UseSecretsManager], + [Status], + [UsePasswordManager], + [SmSeats], + [SmServiceAccounts], + [MaxAutoscaleSmSeats], + [MaxAutoscaleSmServiceAccounts], + [SecretsManagerBeta], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseOrganizationDomains], + [UseAdminSponsoredFamilies], + [SyncSeats], + [UseAutomaticUserConfirmation], + [UsePhishingBlocker], + [MaxStorageGbIncreased] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @UseResetPassword, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @PublicKey, + @PrivateKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats, + @UseKeyConnector, + @UseScim, + @UseCustomPermissions, + @UseSecretsManager, + @Status, + @UsePasswordManager, + @SmSeats, + @SmServiceAccounts, + @MaxAutoscaleSmSeats, + @MaxAutoscaleSmServiceAccounts, + @SecretsManagerBeta, + @LimitCollectionCreation, + @LimitCollectionDeletion, + @AllowAdminAccessToAllCollectionItems, + @UseRiskInsights, + @LimitItemDeletion, + @UseOrganizationDomains, + @UseAdminSponsoredFamilies, + @SyncSeats, + @UseAutomaticUserConfirmation, + @UsePhishingBlocker, + @MaxStorageGb + ); +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseOrganizationDomains BIT = 0, + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0, + @UseAutomaticUserConfirmation BIT = 0, + @UsePhishingBlocker BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Organization] + SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseOrganizationDomains] = @UseOrganizationDomains, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies, + [SyncSeats] = @SyncSeats, + [UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation, + [UsePhishingBlocker] = @UsePhishingBlocker, + [MaxStorageGbIncreased] = @MaxStorageGb + WHERE + [Id] = @Id; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Name NVARCHAR(50), + @Email NVARCHAR(256), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @ApiKey VARCHAR(30), + @ForcePasswordReset BIT = 0, + @UsesKeyConnector BIT = 0, + @FailedLoginCount INT = 0, + @LastFailedLoginDate DATETIME2(7), + @AvatarColor VARCHAR(7) = NULL, + @LastPasswordChangeDate DATETIME2(7) = NULL, + @LastKdfChangeDate DATETIME2(7) = NULL, + @LastKeyRotationDate DATETIME2(7) = NULL, + @LastEmailChangeDate DATETIME2(7) = NULL, + @VerifyDevices BIT = 1, + @SecurityState VARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignedPublicKey VARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[User] + ( + [Id], + [Name], + [Email], + [EmailVerified], + [MasterPassword], + [MasterPasswordHint], + [Culture], + [SecurityStamp], + [TwoFactorProviders], + [TwoFactorRecoveryCode], + [EquivalentDomains], + [ExcludedGlobalEquivalentDomains], + [AccountRevisionDate], + [Key], + [PublicKey], + [PrivateKey], + [Premium], + [PremiumExpirationDate], + [RenewalReminderDate], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [LicenseKey], + [Kdf], + [KdfIterations], + [CreationDate], + [RevisionDate], + [ApiKey], + [ForcePasswordReset], + [UsesKeyConnector], + [FailedLoginCount], + [LastFailedLoginDate], + [AvatarColor], + [KdfMemory], + [KdfParallelism], + [LastPasswordChangeDate], + [LastKdfChangeDate], + [LastKeyRotationDate], + [LastEmailChangeDate], + [VerifyDevices], + [SecurityState], + [SecurityVersion], + [SignedPublicKey], + [MaxStorageGbIncreased] + ) + VALUES + ( + @Id, + @Name, + @Email, + @EmailVerified, + @MasterPassword, + @MasterPasswordHint, + @Culture, + @SecurityStamp, + @TwoFactorProviders, + @TwoFactorRecoveryCode, + @EquivalentDomains, + @ExcludedGlobalEquivalentDomains, + @AccountRevisionDate, + @Key, + @PublicKey, + @PrivateKey, + @Premium, + @PremiumExpirationDate, + @RenewalReminderDate, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @LicenseKey, + @Kdf, + @KdfIterations, + @CreationDate, + @RevisionDate, + @ApiKey, + @ForcePasswordReset, + @UsesKeyConnector, + @FailedLoginCount, + @LastFailedLoginDate, + @AvatarColor, + @KdfMemory, + @KdfParallelism, + @LastPasswordChangeDate, + @LastKdfChangeDate, + @LastKeyRotationDate, + @LastEmailChangeDate, + @VerifyDevices, + @SecurityState, + @SecurityVersion, + @SignedPublicKey, + @MaxStorageGb + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_Update] + @Id UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @Email NVARCHAR(256), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @ApiKey VARCHAR(30), + @ForcePasswordReset BIT = 0, + @UsesKeyConnector BIT = 0, + @FailedLoginCount INT, + @LastFailedLoginDate DATETIME2(7), + @AvatarColor VARCHAR(7), + @LastPasswordChangeDate DATETIME2(7) = NULL, + @LastKdfChangeDate DATETIME2(7) = NULL, + @LastKeyRotationDate DATETIME2(7) = NULL, + @LastEmailChangeDate DATETIME2(7) = NULL, + @VerifyDevices BIT = 1, + @SecurityState VARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignedPublicKey VARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Name] = @Name, + [Email] = @Email, + [EmailVerified] = @EmailVerified, + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Culture] = @Culture, + [SecurityStamp] = @SecurityStamp, + [TwoFactorProviders] = @TwoFactorProviders, + [TwoFactorRecoveryCode] = @TwoFactorRecoveryCode, + [EquivalentDomains] = @EquivalentDomains, + [ExcludedGlobalEquivalentDomains] = @ExcludedGlobalEquivalentDomains, + [AccountRevisionDate] = @AccountRevisionDate, + [Key] = @Key, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [Premium] = @Premium, + [PremiumExpirationDate] = @PremiumExpirationDate, + [RenewalReminderDate] = @RenewalReminderDate, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [LicenseKey] = @LicenseKey, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [ApiKey] = @ApiKey, + [ForcePasswordReset] = @ForcePasswordReset, + [UsesKeyConnector] = @UsesKeyConnector, + [FailedLoginCount] = @FailedLoginCount, + [LastFailedLoginDate] = @LastFailedLoginDate, + [AvatarColor] = @AvatarColor, + [LastPasswordChangeDate] = @LastPasswordChangeDate, + [LastKdfChangeDate] = @LastKdfChangeDate, + [LastKeyRotationDate] = @LastKeyRotationDate, + [LastEmailChangeDate] = @LastEmailChangeDate, + [VerifyDevices] = @VerifyDevices, + [SecurityState] = @SecurityState, + [SecurityVersion] = @SecurityVersion, + [SignedPublicKey] = @SignedPublicKey, + [MaxStorageGbIncreased] = @MaxStorageGb + WHERE + [Id] = @Id +END +GO diff --git a/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql b/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql new file mode 100644 index 0000000000..545bf830f6 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql @@ -0,0 +1,29 @@ +CREATE OR ALTER 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 +GO