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/dev/docker-compose.yml b/dev/docker-compose.yml index 3554306ddb..c82da051b4 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -99,7 +99,7 @@ services: - idp rabbitmq: - image: rabbitmq:4.1.3-management + image: rabbitmq:4.2.0-management ports: - "5672:5672" - "15672:15672" diff --git a/src/Admin/Dockerfile b/src/Admin/Dockerfile index 648ff1be91..84248639cf 100644 --- a/src/Admin/Dockerfile +++ b/src/Admin/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Node.js build stage # ############################################### -FROM node:20-alpine3.21 AS node-build +FROM --platform=$BUILDPLATFORM node:20-alpine3.21 AS node-build WORKDIR /app COPY src/Admin/package*.json ./ diff --git a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs index c12616b4cc..e164f3c4ea 100644 --- a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Data; @@ -13,6 +14,12 @@ namespace Bit.Api.AdminConsole.Public.Models.Response; /// public class GroupResponseModel : GroupBaseModel, IResponseModel { + [JsonConstructor] + public GroupResponseModel() + { + + } + public GroupResponseModel(Group group, IEnumerable collections) { if (group == null) 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/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs index 04ae565a27..9e830aeea8 100644 --- a/src/Api/Models/Public/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Core.Entities; using Bit.Core.Models.Data; @@ -13,6 +14,12 @@ namespace Bit.Api.Models.Public.Response; /// public class CollectionResponseModel : CollectionBaseModel, IResponseModel { + [JsonConstructor] + public CollectionResponseModel() + { + + } + public CollectionResponseModel(Collection collection, IEnumerable groups) { if (collection == null) diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index 8615113906..a567062a5e 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -65,10 +65,11 @@ public class CollectionsController : Controller [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task List() { - var collections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync( - _currentContext.OrganizationId.Value); - // TODO: Get all CollectionGroup associations for the organization and marry them up here for the response. - var collectionResponses = collections.Select(c => new CollectionResponseModel(c, null)); + var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value); + + var collectionResponses = collections.Select(c => + new CollectionResponseModel(c.Item1, c.Item2.Groups)); + var response = new ListResponseModel(collectionResponses); return new JsonResult(response); } 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/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs index 5dce52d907..ebeef44484 100644 --- a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -528,17 +528,21 @@ public static class EventIntegrationsServiceCollectionExtensions /// True if all required RabbitMQ settings are present; otherwise, false. /// /// Requires all the following settings to be configured: - /// - EventLogging.RabbitMq.HostName - /// - EventLogging.RabbitMq.Username - /// - EventLogging.RabbitMq.Password - /// - EventLogging.RabbitMq.EventExchangeName + /// + /// EventLogging.RabbitMq.HostName + /// EventLogging.RabbitMq.Username + /// EventLogging.RabbitMq.Password + /// EventLogging.RabbitMq.EventExchangeName + /// EventLogging.RabbitMq.IntegrationExchangeName + /// /// internal static bool IsRabbitMqEnabled(GlobalSettings settings) { return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) && CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) && CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) && - CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName); + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.IntegrationExchangeName); } /// @@ -547,13 +551,17 @@ public static class EventIntegrationsServiceCollectionExtensions /// The global settings containing Azure Service Bus configuration. /// True if all required Azure Service Bus settings are present; otherwise, false. /// - /// Requires both of the following settings to be configured: - /// - EventLogging.AzureServiceBus.ConnectionString - /// - EventLogging.AzureServiceBus.EventTopicName + /// Requires all of the following settings to be configured: + /// + /// EventLogging.AzureServiceBus.ConnectionString + /// EventLogging.AzureServiceBus.EventTopicName + /// EventLogging.AzureServiceBus.IntegrationTopicName + /// /// internal static bool IsAzureServiceBusEnabled(GlobalSettings settings) { return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) && - CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName); + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName) && + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.IntegrationTopicName); } } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFailureCategory.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFailureCategory.cs new file mode 100644 index 0000000000..544e671d51 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFailureCategory.cs @@ -0,0 +1,37 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +/// +/// Categories of event integration failures used for classification and retry logic. +/// +public enum IntegrationFailureCategory +{ + /// + /// Service is temporarily unavailable (503, upstream outage, maintenance). + /// + ServiceUnavailable, + + /// + /// Authentication failed (401, 403, invalid_auth, token issues). + /// + AuthenticationFailed, + + /// + /// Configuration error (invalid config, channel_not_found, etc.). + /// + ConfigurationError, + + /// + /// Rate limited (429, rate_limited). + /// + RateLimited, + + /// + /// Transient error (timeouts, 500, network errors). + /// + TransientError, + + /// + /// Permanent failure unrelated to authentication/config (e.g., unrecoverable payload/format issue). + /// + PermanentFailure +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs index 8db054561b..375f2489cb 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs @@ -1,16 +1,84 @@ namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +/// +/// Represents the result of an integration handler operation, including success status, +/// failure categorization, and retry metadata. Use the factory method +/// for successful operations or for failures with automatic retry-ability +/// determination based on the failure category. +/// public class IntegrationHandlerResult { - public IntegrationHandlerResult(bool success, IIntegrationMessage message) + /// + /// True if the integration send succeeded, false otherwise. + /// + public bool Success { get; } + + /// + /// The integration message that was processed. + /// + public IIntegrationMessage Message { get; } + + /// + /// Optional UTC date/time indicating when a failed operation should be retried. + /// Will be used by the retry queue to delay re-sending the message. + /// Usually set based on the Retry-After header from rate-limited responses. + /// + public DateTime? DelayUntilDate { get; private init; } + + /// + /// Category of the failure. Null for successful results. + /// + public IntegrationFailureCategory? Category { get; private init; } + + /// + /// Detailed failure reason or error message. Empty for successful results. + /// + public string? FailureReason { get; private init; } + + /// + /// Indicates whether the operation is retryable. + /// Computed from the failure category. + /// + public bool Retryable => Category switch + { + IntegrationFailureCategory.RateLimited => true, + IntegrationFailureCategory.TransientError => true, + IntegrationFailureCategory.ServiceUnavailable => true, + IntegrationFailureCategory.AuthenticationFailed => false, + IntegrationFailureCategory.ConfigurationError => false, + IntegrationFailureCategory.PermanentFailure => false, + null => false, + _ => false + }; + + /// + /// Creates a successful result. + /// + public static IntegrationHandlerResult Succeed(IIntegrationMessage message) + { + return new IntegrationHandlerResult(success: true, message: message); + } + + /// + /// Creates a failed result with a failure category and reason. + /// + public static IntegrationHandlerResult Fail( + IIntegrationMessage message, + IntegrationFailureCategory category, + string failureReason, + DateTime? delayUntil = null) + { + return new IntegrationHandlerResult(success: false, message: message) + { + Category = category, + FailureReason = failureReason, + DelayUntilDate = delayUntil + }; + } + + private IntegrationHandlerResult(bool success, IIntegrationMessage message) { Success = success; Message = message; } - - public bool Success { get; set; } = false; - public bool Retryable { get; set; } = false; - public IIntegrationMessage Message { get; set; } - public DateTime? DelayUntilDate { get; set; } - public string FailureReason { get; set; } = string.Empty; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index c763cc0cc2..50f194b578 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -270,7 +270,9 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand ICollection allOrgUsers, User user) { var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( - new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers, user))) + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, + allOrgUsers.Append(orgUser), + user))) .Match( error => error.Message, _ => string.Empty diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs index 633b84d2b9..e5c980ea24 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -19,7 +19,8 @@ public class AutomaticUserConfirmationPolicyEnforcementValidator( var currentOrganizationUser = request.AllOrganizationUsers .FirstOrDefault(x => x.OrganizationId == request.OrganizationId - && x.UserId == request.User.Id); + // invited users do not have a userId but will have email + && (x.UserId == request.User.Id || x.Email == request.User.Email)); if (currentOrganizationUser is null) { diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs index bb10dc01b9..c36081cb52 100644 --- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs @@ -29,46 +29,87 @@ public abstract class IntegrationHandlerBase : IIntegrationHandler IntegrationMessage message, TimeProvider timeProvider) { - var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); - - if (response.IsSuccessStatusCode) return result; - - switch (response.StatusCode) + if (response.IsSuccessStatusCode) { - case HttpStatusCode.TooManyRequests: - case HttpStatusCode.RequestTimeout: - case HttpStatusCode.InternalServerError: - case HttpStatusCode.BadGateway: - case HttpStatusCode.ServiceUnavailable: - case HttpStatusCode.GatewayTimeout: - result.Retryable = true; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; - - if (response.Headers.TryGetValues("Retry-After", out var values)) - { - var value = values.FirstOrDefault(); - if (int.TryParse(value, out var seconds)) - { - // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. - result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; - } - else if (DateTimeOffset.TryParseExact(value, - "r", // "r" is the round-trip format: RFC1123 - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out var retryDate)) - { - // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. - result.DelayUntilDate = retryDate.UtcDateTime; - } - } - break; - default: - result.Retryable = false; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; - break; + return IntegrationHandlerResult.Succeed(message); } - return result; + var category = ClassifyHttpStatusCode(response.StatusCode); + var failureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; + + if (category is not (IntegrationFailureCategory.RateLimited + or IntegrationFailureCategory.TransientError + or IntegrationFailureCategory.ServiceUnavailable) || + !response.Headers.TryGetValues("Retry-After", out var values) + ) + { + return IntegrationHandlerResult.Fail(message: message, category: category, failureReason: failureReason); + } + + // Handle Retry-After header for rate-limited and retryable errors + DateTime? delayUntil = null; + var value = values.FirstOrDefault(); + if (int.TryParse(value, out var seconds)) + { + // Retry-after was specified in seconds + delayUntil = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; + } + else if (DateTimeOffset.TryParseExact(value, + "r", // "r" is the round-trip format: RFC1123 + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var retryDate)) + { + // Retry-after was specified as a date + delayUntil = retryDate.UtcDateTime; + } + + return IntegrationHandlerResult.Fail( + message, + category, + failureReason, + delayUntil + ); + } + + /// + /// Classifies an as an to drive + /// retry behavior and operator-facing failure reporting. + /// + /// The HTTP status code. + /// The corresponding . + protected static IntegrationFailureCategory ClassifyHttpStatusCode(HttpStatusCode statusCode) + { + var explicitCategory = statusCode switch + { + HttpStatusCode.Unauthorized => IntegrationFailureCategory.AuthenticationFailed, + HttpStatusCode.Forbidden => IntegrationFailureCategory.AuthenticationFailed, + HttpStatusCode.NotFound => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.Gone => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.MovedPermanently => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.TemporaryRedirect => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.PermanentRedirect => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.TooManyRequests => IntegrationFailureCategory.RateLimited, + HttpStatusCode.RequestTimeout => IntegrationFailureCategory.TransientError, + HttpStatusCode.InternalServerError => IntegrationFailureCategory.TransientError, + HttpStatusCode.BadGateway => IntegrationFailureCategory.TransientError, + HttpStatusCode.GatewayTimeout => IntegrationFailureCategory.TransientError, + HttpStatusCode.ServiceUnavailable => IntegrationFailureCategory.ServiceUnavailable, + HttpStatusCode.NotImplemented => IntegrationFailureCategory.PermanentFailure, + _ => (IntegrationFailureCategory?)null + }; + + if (explicitCategory is not null) + { + return explicitCategory.Value; + } + + return (int)statusCode switch + { + >= 300 and <= 399 => IntegrationFailureCategory.ConfigurationError, + >= 400 and <= 499 => IntegrationFailureCategory.ConfigurationError, + >= 500 and <= 599 => IntegrationFailureCategory.ServiceUnavailable, + _ => IntegrationFailureCategory.ServiceUnavailable + }; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs index 633a53296b..c97c5f7efe 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs @@ -85,6 +85,17 @@ public class AzureServiceBusIntegrationListenerService : Backgro { // Non-recoverable failure or exceeded the max number of retries // Return false to indicate this message should be dead-lettered + _logger.LogWarning( + "Integration failure - non-recoverable error or max retries exceeded. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason, + message.RetryCount, + _maxRetries); return false; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs index b426032c92..0762edc040 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs @@ -106,14 +106,32 @@ public class RabbitMqIntegrationListenerService : BackgroundServ { // Exceeded the max number of retries; fail and send to dead letter queue await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); - _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); + _logger.LogWarning( + "Integration failure - max retries exceeded. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason, + message.RetryCount, + _maxRetries); } } else { // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); - _logger.LogWarning("Non-retryable failure. Sent to DLQ."); + _logger.LogWarning( + "Integration failure - non-retryable. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason); } // Message has been sent to retry or dead letter queues. diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 16c756c8c4..e681140afe 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -6,15 +6,6 @@ public class SlackIntegrationHandler( ISlackService slackService) : IntegrationHandlerBase { - private static readonly HashSet _retryableErrors = new(StringComparer.Ordinal) - { - "internal_error", - "message_limit_exceeded", - "rate_limited", - "ratelimited", - "service_unavailable" - }; - public override async Task HandleAsync(IntegrationMessage message) { var slackResponse = await slackService.SendSlackMessageByChannelIdAsync( @@ -25,24 +16,61 @@ public class SlackIntegrationHandler( if (slackResponse is null) { - return new IntegrationHandlerResult(success: false, message: message) - { - FailureReason = "Slack response was null" - }; + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + "Slack response was null" + ); } if (slackResponse.Ok) { - return new IntegrationHandlerResult(success: true, message: message); + return IntegrationHandlerResult.Succeed(message); } - var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error }; + var category = ClassifySlackError(slackResponse.Error); + return IntegrationHandlerResult.Fail( + message, + category, + slackResponse.Error + ); + } - if (_retryableErrors.Contains(slackResponse.Error)) + /// + /// Classifies a Slack API error code string as an to drive + /// retry behavior and operator-facing failure reporting. + /// + /// + /// + /// Slack responses commonly return an error string when ok is false. This method maps + /// known Slack error codes to failure categories. + /// + /// + /// Any unrecognized error codes default to to avoid + /// incorrectly marking new/unknown Slack failures as non-retryable. + /// + /// + /// The Slack error code string (e.g. invalid_auth, rate_limited). + /// The corresponding . + private static IntegrationFailureCategory ClassifySlackError(string error) + { + return error switch { - result.Retryable = true; - } - - return result; + "invalid_auth" => IntegrationFailureCategory.AuthenticationFailed, + "access_denied" => IntegrationFailureCategory.AuthenticationFailed, + "token_expired" => IntegrationFailureCategory.AuthenticationFailed, + "token_revoked" => IntegrationFailureCategory.AuthenticationFailed, + "account_inactive" => IntegrationFailureCategory.AuthenticationFailed, + "not_authed" => IntegrationFailureCategory.AuthenticationFailed, + "channel_not_found" => IntegrationFailureCategory.ConfigurationError, + "is_archived" => IntegrationFailureCategory.ConfigurationError, + "rate_limited" => IntegrationFailureCategory.RateLimited, + "ratelimited" => IntegrationFailureCategory.RateLimited, + "message_limit_exceeded" => IntegrationFailureCategory.RateLimited, + "internal_error" => IntegrationFailureCategory.TransientError, + "service_unavailable" => IntegrationFailureCategory.ServiceUnavailable, + "fatal_error" => IntegrationFailureCategory.ServiceUnavailable, + _ => IntegrationFailureCategory.TransientError + }; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs index 41d60bd69c..9e3645a99f 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Rest; namespace Bit.Core.Services; @@ -18,24 +19,48 @@ public class TeamsIntegrationHandler( channelId: message.Configuration.ChannelId ); - return new IntegrationHandlerResult(success: true, message: message); + return IntegrationHandlerResult.Succeed(message); } catch (HttpOperationException ex) { - var result = new IntegrationHandlerResult(success: false, message: message); - var statusCode = (int)ex.Response.StatusCode; - result.Retryable = statusCode is 429 or >= 500 and < 600; - result.FailureReason = ex.Message; - - return result; + var category = ClassifyHttpStatusCode(ex.Response.StatusCode); + return IntegrationHandlerResult.Fail( + message, + category, + ex.Message + ); + } + catch (ArgumentException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + ex.Message + ); + } + catch (UriFormatException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + ex.Message + ); + } + catch (JsonException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.PermanentFailure, + ex.Message + ); } catch (Exception ex) { - var result = new IntegrationHandlerResult(success: false, message: message); - result.Retryable = false; - result.FailureReason = ex.Message; - - return result; + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + ex.Message + ); } } } 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/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs b/src/Infrastructure.Dapper/Dirt/Repositories/EventRepository.cs similarity index 100% rename from src/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs rename to src/Infrastructure.Dapper/Dirt/Repositories/EventRepository.cs diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index d2e15670e2..5327e89165 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -226,7 +226,6 @@ public class CollectionRepository : Repository, ICollectionRep { obj.SetNewId(); - var objWithGroupsAndUsers = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj))!; objWithGroupsAndUsers.Groups = groups != null ? groups.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); @@ -243,18 +242,52 @@ public class CollectionRepository : Repository, ICollectionRep public async Task ReplaceAsync(Collection obj, IEnumerable? groups, IEnumerable? users) { - var objWithGroupsAndUsers = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj))!; - - objWithGroupsAndUsers.Groups = groups != null ? groups.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); - objWithGroupsAndUsers.Users = users != null ? users.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); - - using (var connection = new SqlConnection(ConnectionString)) + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + try { - var results = await connection.ExecuteAsync( - $"[{Schema}].[Collection_UpdateWithGroupsAndUsers]", - objWithGroupsAndUsers, - commandType: CommandType.StoredProcedure); + if (groups == null && users == null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_Update]", + obj, + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + else if (groups != null && users == null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_UpdateWithGroups]", + new CollectionWithGroups(obj, groups), + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + else if (groups == null && users != null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_UpdateWithUsers]", + new CollectionWithUsers(obj, users), + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + else if (groups != null && users != null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_UpdateWithGroupsAndUsers]", + new CollectionWithGroupsAndUsers(obj, groups, users), + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + + await transaction.CommitAsync(); } + catch + { + await transaction.RollbackAsync(); + throw; + } + } public async Task DeleteManyAsync(IEnumerable collectionIds) @@ -448,9 +481,70 @@ public class CollectionRepository : Repository, ICollectionRep public class CollectionWithGroupsAndUsers : Collection { + public CollectionWithGroupsAndUsers() { } + + public CollectionWithGroupsAndUsers(Collection collection, + IEnumerable groups, + IEnumerable users) + { + Id = collection.Id; + Name = collection.Name; + OrganizationId = collection.OrganizationId; + CreationDate = collection.CreationDate; + RevisionDate = collection.RevisionDate; + Type = collection.Type; + ExternalId = collection.ExternalId; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + Groups = groups.ToArrayTVP(); + Users = users.ToArrayTVP(); + } + [DisallowNull] public DataTable? Groups { get; set; } [DisallowNull] public DataTable? Users { get; set; } } + + public class CollectionWithGroups : Collection + { + public CollectionWithGroups() { } + + public CollectionWithGroups(Collection collection, IEnumerable groups) + { + Id = collection.Id; + Name = collection.Name; + OrganizationId = collection.OrganizationId; + CreationDate = collection.CreationDate; + RevisionDate = collection.RevisionDate; + Type = collection.Type; + ExternalId = collection.ExternalId; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + Groups = groups.ToArrayTVP(); + } + + [DisallowNull] + public DataTable? Groups { get; set; } + } + + public class CollectionWithUsers : Collection + { + public CollectionWithUsers() { } + + public CollectionWithUsers(Collection collection, IEnumerable users) + { + + Id = collection.Id; + Name = collection.Name; + OrganizationId = collection.OrganizationId; + CreationDate = collection.CreationDate; + RevisionDate = collection.RevisionDate; + Type = collection.Type; + ExternalId = collection.ExternalId; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + Users = users.ToArrayTVP(); + } + + [DisallowNull] + public DataTable? Users { get; set; } + } } diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 224351f034..571319e4c7 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Bit.Core; using Bit.Core.Billing.Premium.Models; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -401,6 +402,32 @@ public class UserRepository : Repository, 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/Collection_UpdateWithGroups.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroups.sql new file mode 100644 index 0000000000..7f7fc2e0d7 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroups.sql @@ -0,0 +1,74 @@ +CREATE PROCEDURE [dbo].[Collection_UpdateWithGroups] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Groups + -- Delete groups that are no longer in source + DELETE + cg + FROM + [dbo].[CollectionGroup] cg + LEFT JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE + cg + SET + cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM + [dbo].[CollectionGroup] cg + INNER JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND ( + cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage + ); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM + @Groups g + INNER JOIN + [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN + [dbo].[CollectionGroup] cg ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE + grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END diff --git a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithUsers.sql new file mode 100644 index 0000000000..60fccc51d5 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithUsers.sql @@ -0,0 +1,74 @@ +CREATE PROCEDURE [dbo].[Collection_UpdateWithUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Users + -- Delete users that are no longer in source + DELETE + cu + FROM + [dbo].[CollectionUser] cu + LEFT JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE + cu + SET + cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM + [dbo].[CollectionUser] cu + INNER JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND ( + cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage + ); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM + @Users u + INNER JOIN + [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN + [dbo].[CollectionUser] cu ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE + ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +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/Controllers/Public/CollectionsControllerTests.cs b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs new file mode 100644 index 0000000000..a729abb849 --- /dev/null +++ b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs @@ -0,0 +1,117 @@ +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Public.Request; +using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Xunit; + +namespace Bit.Api.IntegrationTest.Controllers.Public; + +public class CollectionsControllerTests : IClassFixture, IAsyncLifetime +{ + + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private string _ownerEmail = null!; + private Organization _organization = null!; + + public CollectionsControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(_ => { }); + _factory.SubstituteService(_ => { }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task CreateCollectionWithMultipleUsersAndVariedPermissions_Success() + { + // Arrange + _organization.AllowAdminAccessToAllCollectionItems = true; + await _factory.GetService().UpsertAsync(_organization); + + var groupRepository = _factory.GetService(); + var group = await groupRepository.CreateAsync(new Group + { + OrganizationId = _organization.Id, + Name = "CollectionControllerTests.CreateCollectionWithMultipleUsersAndVariedPermissions_Success", + ExternalId = $"CollectionControllerTests.CreateCollectionWithMultipleUsersAndVariedPermissions_Success{Guid.NewGuid()}", + }); + + var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + _organization.Id, + OrganizationUserType.User); + + var collection = await OrganizationTestHelpers.CreateCollectionAsync( + _factory, + _organization.Id, + "Shared Collection with a group", + externalId: "shared-collection-with-group", + groups: + [ + new CollectionAccessSelection { Id = group.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ], + users: + [ + new CollectionAccessSelection { Id = user.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ]); + + var getCollectionsResponse = await _client.GetFromJsonAsync>("public/collections"); + var getCollectionResponse = await _client.GetFromJsonAsync($"public/collections/{collection.Id}"); + + var firstCollection = getCollectionsResponse.Data.First(x => x.ExternalId == "shared-collection-with-group"); + + var update = new CollectionUpdateRequestModel + { + ExternalId = firstCollection.ExternalId, + Groups = firstCollection.Groups?.Select(x => new AssociationWithPermissionsRequestModel + { + Id = x.Id, + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage + }), + }; + + await _client.PutAsJsonAsync($"public/collections/{firstCollection.Id}", update); + + var result = await _factory.GetService() + .GetByIdWithAccessAsync(firstCollection.Id); + + Assert.NotNull(result); + Assert.NotEmpty(result.Item2.Groups); + Assert.NotEmpty(result.Item2.Users); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index bcde370b24..887ef989ce 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -159,14 +159,16 @@ public static class OrganizationTestHelpers Guid organizationId, string name, IEnumerable? users = null, - IEnumerable? groups = null) + IEnumerable? groups = null, + string? externalId = null) { var collectionRepository = factory.GetService(); var collection = new Collection { OrganizationId = organizationId, Name = name, - Type = CollectionType.SharedCollection + Type = CollectionType.SharedCollection, + ExternalId = externalId }; await collectionRepository.CreateAsync(collection, groups, users); 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/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs index 08fcd23969..0ca2d55c78 100644 --- a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs @@ -200,7 +200,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.True(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -214,7 +215,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = null, ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -228,7 +230,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = null, ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -242,21 +245,38 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = null, - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); } [Fact] - public void IsRabbitMqEnabled_MissingExchangeName_ReturnsFalse() + public void IsRabbitMqEnabled_MissingEventExchangeName_ReturnsFalse() { var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = null + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = null, + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); + } + + [Fact] + public void IsRabbitMqEnabled_MissingIntegrationExchangeName_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = null }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -268,7 +288,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); Assert.True(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); @@ -280,19 +301,34 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = null, - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); } [Fact] - public void IsAzureServiceBusEnabled_MissingTopicName_ReturnsFalse() + public void IsAzureServiceBusEnabled_MissingEventTopicName_ReturnsFalse() { var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = null + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = null, + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); + } + + [Fact] + public void IsAzureServiceBusEnabled_MissingIntegrationTopicName_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = null }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); @@ -601,7 +637,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); // Add prerequisites @@ -624,7 +661,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); // Add prerequisites @@ -650,8 +688,10 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration", ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); // Add prerequisites @@ -694,7 +734,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); services.AddEventWriteServices(globalSettings); @@ -712,7 +753,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); services.AddEventWriteServices(globalSettings); @@ -769,10 +811,12 @@ public class EventIntegrationServiceCollectionExtensionsTests { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration", ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); services.AddEventWriteServices(globalSettings); @@ -789,7 +833,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); // Add prerequisites @@ -826,7 +871,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); // Add prerequisites diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResultTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResultTests.cs new file mode 100644 index 0000000000..6925a978eb --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResultTests.cs @@ -0,0 +1,128 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationHandlerResultTests +{ + [Theory, BitAutoData] + public void Succeed_SetsSuccessTrue_CategoryNull(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Succeed(message); + + Assert.True(result.Success); + Assert.Null(result.Category); + Assert.Equal(message, result.Message); + Assert.Null(result.FailureReason); + } + + [Theory, BitAutoData] + public void Fail_WithCategory_SetsSuccessFalse_CategorySet(IntegrationMessage message) + { + var category = IntegrationFailureCategory.AuthenticationFailed; + var failureReason = "Invalid credentials"; + + var result = IntegrationHandlerResult.Fail(message, category, failureReason); + + Assert.False(result.Success); + Assert.Equal(category, result.Category); + Assert.Equal(failureReason, result.FailureReason); + Assert.Equal(message, result.Message); + } + + [Theory, BitAutoData] + public void Fail_WithDelayUntil_SetsDelayUntilDate(IntegrationMessage message) + { + var delayUntil = DateTime.UtcNow.AddMinutes(5); + + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.RateLimited, + "Rate limited", + delayUntil + ); + + Assert.Equal(delayUntil, result.DelayUntilDate); + } + + [Theory, BitAutoData] + public void Retryable_RateLimited_ReturnsTrue(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.RateLimited, + "Rate limited" + ); + + Assert.True(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_TransientError_ReturnsTrue(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + "Temporary network issue" + ); + + Assert.True(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_AuthenticationFailed_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.AuthenticationFailed, + "Invalid token" + ); + + Assert.False(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_ConfigurationError_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + "Channel not found" + ); + + Assert.False(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_ServiceUnavailable_ReturnsTrue(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ServiceUnavailable, + "Service is down" + ); + + Assert.True(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_PermanentFailure_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.PermanentFailure, + "Permanent failure" + ); + + Assert.False(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_SuccessCase_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Succeed(message); + + Assert.False(result.Retryable); + } +} diff --git a/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs b/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs index 23627f3962..9e46a3a99a 100644 --- a/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs @@ -78,8 +78,10 @@ public class AzureServiceBusIntegrationListenerServiceTests var sutProvider = GetSutProvider(); message.RetryCount = 0; - var result = new IntegrationHandlerResult(false, message); - result.Retryable = false; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.AuthenticationFailed, // NOT retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -89,6 +91,12 @@ public class AzureServiceBusIntegrationListenerServiceTests await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson())); await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any()); + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - non-recoverable error or max retries exceeded.")), + Arg.Any(), + Arg.Any>()); } [Theory, BitAutoData] @@ -96,9 +104,10 @@ public class AzureServiceBusIntegrationListenerServiceTests { var sutProvider = GetSutProvider(); message.RetryCount = _config.MaxRetries; - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; - + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -108,6 +117,12 @@ public class AzureServiceBusIntegrationListenerServiceTests await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson())); await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any()); + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - non-recoverable error or max retries exceeded.")), + Arg.Any(), + Arg.Any>()); } [Theory, BitAutoData] @@ -116,8 +131,10 @@ public class AzureServiceBusIntegrationListenerServiceTests var sutProvider = GetSutProvider(); message.RetryCount = 0; - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -133,7 +150,7 @@ public class AzureServiceBusIntegrationListenerServiceTests public async Task HandleMessageAsync_SuccessfulResult_Succeeds(IntegrationMessage message) { var sutProvider = GetSutProvider(); - var result = new IntegrationHandlerResult(true, message); + var result = IntegrationHandlerResult.Succeed(message); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -156,7 +173,7 @@ public class AzureServiceBusIntegrationListenerServiceTests _logger.Received(1).Log( LogLevel.Error, Arg.Any(), - Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains("Unhandled error processing ASB message")), Arg.Any(), Arg.Any>()); diff --git a/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs index 5f0a9915bf..9cb21f012a 100644 --- a/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs @@ -51,7 +51,7 @@ public class DatadogIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); - Assert.Empty(result.FailureReason); + Assert.Null(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(DatadogIntegrationHandler.HttpClientName)) diff --git a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs index f6f587cfd7..b3bbcb7ef2 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using System.Net; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Bit.Core.Services; using Xunit; @@ -7,7 +8,6 @@ namespace Bit.Core.Test.Services; public class IntegrationHandlerTests { - [Fact] public async Task HandleAsync_ConvertsJsonToTypedIntegrationMessage() { @@ -33,13 +33,113 @@ public class IntegrationHandlerTests Assert.Equal(expected.IntegrationType, typedResult.IntegrationType); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + public void ClassifyHttpStatusCode_AuthenticationFailed(HttpStatusCode code) + { + Assert.Equal( + IntegrationFailureCategory.AuthenticationFailed, + TestIntegrationHandler.Classify(code)); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Gone)] + [InlineData(HttpStatusCode.MovedPermanently)] + [InlineData(HttpStatusCode.TemporaryRedirect)] + [InlineData(HttpStatusCode.PermanentRedirect)] + public void ClassifyHttpStatusCode_ConfigurationError(HttpStatusCode code) + { + Assert.Equal( + IntegrationFailureCategory.ConfigurationError, + TestIntegrationHandler.Classify(code)); + } + + [Fact] + public void ClassifyHttpStatusCode_TooManyRequests_IsRateLimited() + { + Assert.Equal( + IntegrationFailureCategory.RateLimited, + TestIntegrationHandler.Classify(HttpStatusCode.TooManyRequests)); + } + + [Fact] + public void ClassifyHttpStatusCode_RequestTimeout_IsTransient() + { + Assert.Equal( + IntegrationFailureCategory.TransientError, + TestIntegrationHandler.Classify(HttpStatusCode.RequestTimeout)); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.GatewayTimeout)] + public void ClassifyHttpStatusCode_Common5xx_AreTransient(HttpStatusCode code) + { + Assert.Equal( + IntegrationFailureCategory.TransientError, + TestIntegrationHandler.Classify(code)); + } + + [Fact] + public void ClassifyHttpStatusCode_ServiceUnavailable_IsServiceUnavailable() + { + Assert.Equal( + IntegrationFailureCategory.ServiceUnavailable, + TestIntegrationHandler.Classify(HttpStatusCode.ServiceUnavailable)); + } + + [Fact] + public void ClassifyHttpStatusCode_NotImplemented_IsPermanentFailure() + { + Assert.Equal( + IntegrationFailureCategory.PermanentFailure, + TestIntegrationHandler.Classify(HttpStatusCode.NotImplemented)); + } + + [Fact] + public void FClassifyHttpStatusCode_Unhandled3xx_IsConfigurationError() + { + Assert.Equal( + IntegrationFailureCategory.ConfigurationError, + TestIntegrationHandler.Classify(HttpStatusCode.Found)); + } + + [Fact] + public void ClassifyHttpStatusCode_Unhandled4xx_IsConfigurationError() + { + Assert.Equal( + IntegrationFailureCategory.ConfigurationError, + TestIntegrationHandler.Classify(HttpStatusCode.BadRequest)); + } + + [Fact] + public void ClassifyHttpStatusCode_Unhandled5xx_IsServiceUnavailable() + { + Assert.Equal( + IntegrationFailureCategory.ServiceUnavailable, + TestIntegrationHandler.Classify(HttpStatusCode.HttpVersionNotSupported)); + } + + [Fact] + public void ClassifyHttpStatusCode_UnknownCode_DefaultsToServiceUnavailable() + { + // cast an out-of-range value to ensure default path is stable + Assert.Equal( + IntegrationFailureCategory.ServiceUnavailable, + TestIntegrationHandler.Classify((HttpStatusCode)799)); + } + private class TestIntegrationHandler : IntegrationHandlerBase { public override Task HandleAsync( IntegrationMessage message) { - var result = new IntegrationHandlerResult(success: true, message: message); - return Task.FromResult(result); + return Task.FromResult(IntegrationHandlerResult.Succeed(message: message)); } + + public static IntegrationFailureCategory Classify(HttpStatusCode code) => ClassifyHttpStatusCode(code); } } diff --git a/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs b/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs index 5fcd121252..71985889f8 100644 --- a/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs @@ -86,8 +86,10 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(false, message); - result.Retryable = false; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.AuthenticationFailed, // NOT retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -105,7 +107,7 @@ public class RabbitMqIntegrationListenerServiceTests _logger.Received().Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => (o.ToString() ?? "").Contains("Non-retryable failure")), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - non-retryable.")), Arg.Any(), Arg.Any>()); @@ -133,8 +135,10 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -151,7 +155,7 @@ public class RabbitMqIntegrationListenerServiceTests _logger.Received().Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => (o.ToString() ?? "").Contains("Max retry attempts reached")), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - max retries exceeded.")), Arg.Any(), Arg.Any>()); @@ -179,9 +183,10 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; - result.DelayUntilDate = _now.AddMinutes(1); + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -220,7 +225,7 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(true, message); + var result = IntegrationHandlerResult.Succeed(message); _handler.HandleAsync(Arg.Any()).Returns(result); await sutProvider.Sut.ProcessReceivedMessageAsync(eventArgs, cancellationToken); diff --git a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs index e2e459ceb3..e455100995 100644 --- a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs @@ -110,7 +110,7 @@ public class SlackIntegrationHandlerTests } [Fact] - public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure() + public async Task HandleAsync_NullResponse_ReturnsRetryableFailure() { var sutProvider = GetSutProvider(); var message = new IntegrationMessage() @@ -126,7 +126,7 @@ public class SlackIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); - Assert.False(result.Retryable); + Assert.True(result.Retryable); // Null response is classified as TransientError (retryable) Assert.Equal("Slack response was null", result.FailureReason); Assert.Equal(result.Message, message); diff --git a/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs index b744a6aa69..11056ec2cc 100644 --- a/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -42,9 +43,77 @@ public class TeamsIntegrationHandlerTests ); } + [Theory, BitAutoData] + public async Task HandleAsync_ArgumentException_ReturnsConfigurationError(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new ArgumentException("argument error")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.ConfigurationError, result.Category); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } [Theory, BitAutoData] - public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage message) + public async Task HandleAsync_JsonException_ReturnsPermanentFailure(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new JsonException("JSON error")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.PermanentFailure, result.Category); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_UriFormatException_ReturnsConfigurationError(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new UriFormatException("Bad URI")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.ConfigurationError, result.Category); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_HttpExceptionForbidden_ReturnsAuthenticationFailed(IntegrationMessage message) { var sutProvider = GetSutProvider(); message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); @@ -62,6 +131,7 @@ public class TeamsIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.AuthenticationFailed, result.Category); Assert.False(result.Retryable); Assert.Equal(result.Message, message); @@ -73,7 +143,7 @@ public class TeamsIntegrationHandlerTests } [Theory, BitAutoData] - public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage message) + public async Task HandleAsync_HttpExceptionTooManyRequests_ReturnsRateLimited(IntegrationMessage message) { var sutProvider = GetSutProvider(); message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); @@ -92,6 +162,7 @@ public class TeamsIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.RateLimited, result.Category); Assert.True(result.Retryable); Assert.Equal(result.Message, message); @@ -103,7 +174,7 @@ public class TeamsIntegrationHandlerTests } [Theory, BitAutoData] - public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage message) + public async Task HandleAsync_UnknownException_ReturnsTransientError(IntegrationMessage message) { var sutProvider = GetSutProvider(); message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); @@ -114,7 +185,8 @@ public class TeamsIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); - Assert.False(result.Retryable); + Assert.Equal(IntegrationFailureCategory.TransientError, result.Category); + Assert.True(result.Retryable); Assert.Equal(result.Message, message); await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( diff --git a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs index 53a3598d47..05aa46681a 100644 --- a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs @@ -51,7 +51,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); - Assert.Empty(result.FailureReason); + Assert.Null(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) @@ -79,7 +79,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); - Assert.Empty(result.FailureReason); + Assert.Null(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) 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/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/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs index df01276493..de4fd53a68 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs @@ -144,4 +144,69 @@ public class CollectionRepositoryReplaceTests await userRepository.DeleteAsync(user); await organizationRepository.DeleteAsync(organization); } + + [Theory, DatabaseData] + public async Task ReplaceAsync_WhenNotPassingGroupsOrUsers_DoesNotDeleteAccess( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IGroupRepository groupRepository, + ICollectionRepository collectionRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var user1 = await userRepository.CreateTestUserAsync(); + var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); + + var user2 = await userRepository.CreateTestUserAsync(); + var orgUser2 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user2); + + var group1 = await groupRepository.CreateTestGroupAsync(organization); + var group2 = await groupRepository.CreateTestGroupAsync(organization); + + var collection = new Collection + { + Name = "Test Collection Name", + OrganizationId = organization.Id, + }; + + await collectionRepository.CreateAsync(collection, + [ + new CollectionAccessSelection { Id = group1.Id, Manage = true, HidePasswords = true, ReadOnly = false, }, + new CollectionAccessSelection { Id = group2.Id, Manage = false, HidePasswords = false, ReadOnly = true, }, + ], + [ + new CollectionAccessSelection { Id = orgUser1.Id, Manage = true, HidePasswords = false, ReadOnly = true }, + new CollectionAccessSelection { Id = orgUser2.Id, Manage = false, HidePasswords = true, ReadOnly = false }, + ] + ); + + // Act + collection.Name = "Updated Collection Name"; + + await collectionRepository.ReplaceAsync(collection, null, null); + + // Assert + var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + + Assert.NotNull(actualCollection); + Assert.Equal("Updated Collection Name", actualCollection.Name); + + var groups = actualAccess.Groups.ToArray(); + Assert.Equal(2, groups.Length); + Assert.Single(groups, g => g.Id == group1.Id && g.Manage && g.HidePasswords && !g.ReadOnly); + Assert.Single(groups, g => g.Id == group2.Id && !g.Manage && !g.HidePasswords && g.ReadOnly); + + var users = actualAccess.Users.ToArray(); + + Assert.Equal(2, users.Length); + Assert.Single(users, u => u.Id == orgUser1.Id && u.Manage && !u.HidePasswords && u.ReadOnly); + Assert.Single(users, u => u.Id == orgUser2.Id && !u.Manage && u.HidePasswords && !u.ReadOnly); + + // Clean up data + await userRepository.DeleteAsync(user1); + await userRepository.DeleteAsync(user2); + await organizationRepository.DeleteAsync(organization); + } } 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-10_00_AddGroupAndUserCollectionUpdates.sql b/util/Migrator/DbScripts/2025-12-10_00_AddGroupAndUserCollectionUpdates.sql new file mode 100644 index 0000000000..162be5a7b2 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-10_00_AddGroupAndUserCollectionUpdates.sql @@ -0,0 +1,151 @@ +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Users + -- Delete users that are no longer in source + DELETE + cu + FROM + [dbo].[CollectionUser] cu + LEFT JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE + cu + SET + cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM + [dbo].[CollectionUser] cu + INNER JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND ( + cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage + ); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM + @Users u + INNER JOIN + [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN + [dbo].[CollectionUser] cu ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE + ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Groups + -- Delete groups that are no longer in source + DELETE + cg + FROM + [dbo].[CollectionGroup] cg + LEFT JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE + cg + SET + cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM + [dbo].[CollectionGroup] cg + INNER JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND ( + cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage + ); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM + @Groups g + INNER JOIN + [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN + [dbo].[CollectionGroup] cg ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE + grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO 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