From 8064ae1e050925b75bb05ec7f429522ec51e4936 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:37:00 +0100 Subject: [PATCH 1/8] [deps]: Update MarkDig to 0.44.0 (#6390) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Billing/Billing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index e2b7447eb7..fdac4fc3e4 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -11,7 +11,7 @@ - + From f86d1a51dd663379356567e19245afd33646ce26 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:53:38 -0600 Subject: [PATCH 2/8] [PM-25652] Add endpoint to fetch key connector confirmation details (#6635) * Add new endpoint and query for key connector * Add unit tests --- .../AccountsKeyManagementController.cs | 21 ++++- ...nnectorConfirmationDetailsResponseModel.cs | 24 ++++++ ...eyManagementServiceCollectionExtensions.cs | 1 + .../Data/KeyConnectorConfirmationDetails.cs | 6 ++ .../IKeyConnectorConfirmationDetailsQuery.cs | 8 ++ .../KeyConnectorConfirmationDetailsQuery.cs | 35 ++++++++ .../AccountsKeyManagementControllerTests.cs | 68 +++++++++------ .../AccountsKeyManagementControllerTests.cs | 36 ++++++++ ...yConnectorConfirmationDetailsQueryTests.cs | 86 +++++++++++++++++++ 9 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs create mode 100644 src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs create mode 100644 src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs create mode 100644 src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs create mode 100644 test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 5feda856d5..b944cdd052 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -1,8 +1,8 @@ -#nullable enable -using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; @@ -14,6 +14,7 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; @@ -45,11 +46,13 @@ public class AccountsKeyManagementController : Controller private readonly IRotationValidator, IEnumerable> _webauthnKeyValidator; private readonly IRotationValidator, IEnumerable> _deviceValidator; + private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, IOrganizationUserRepository organizationUserRepository, IEmergencyAccessRepository emergencyAccessRepository, + IKeyConnectorConfirmationDetailsQuery keyConnectorConfirmationDetailsQuery, IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand, IRotateUserAccountKeysCommand rotateUserKeyCommandV2, IRotationValidator, IEnumerable> cipherValidator, @@ -75,6 +78,7 @@ public class AccountsKeyManagementController : Controller _organizationUserValidator = organizationUserValidator; _webauthnKeyValidator = webAuthnKeyValidator; _deviceValidator = deviceValidator; + _keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery; } [HttpPost("key-management/regenerate-keys")] @@ -178,4 +182,17 @@ public class AccountsKeyManagementController : Controller throw new BadRequestException(ModelState); } + + [HttpGet("key-connector/confirmation-details/{orgSsoIdentifier}")] + public async Task GetKeyConnectorConfirmationDetailsAsync(string orgSsoIdentifier) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id); + return new KeyConnectorConfirmationDetailsResponseModel(details); + } } diff --git a/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs new file mode 100644 index 0000000000..68d2c689df --- /dev/null +++ b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs @@ -0,0 +1,24 @@ +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Models.Api; + +namespace Bit.Api.KeyManagement.Models.Responses; + +public class KeyConnectorConfirmationDetailsResponseModel : ResponseModel +{ + private const string _objectName = "keyConnectorConfirmationDetails"; + + public KeyConnectorConfirmationDetailsResponseModel(KeyConnectorConfirmationDetails details, + string obj = _objectName) : base(obj) + { + ArgumentNullException.ThrowIfNull(details); + + OrganizationName = details.OrganizationName; + } + + public KeyConnectorConfirmationDetailsResponseModel() : base(_objectName) + { + OrganizationName = string.Empty; + } + + public string OrganizationName { get; set; } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 0e551c5d0e..abaf9406ba 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -26,5 +26,6 @@ public static class KeyManagementServiceCollectionExtensions private static void AddKeyManagementQueries(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs new file mode 100644 index 0000000000..3821831bad --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.KeyManagement.Models.Data; + +public class KeyConnectorConfirmationDetails +{ + public required string OrganizationName { get; set; } +} diff --git a/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..60b78c03f4 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Queries.Interfaces; + +public interface IKeyConnectorConfirmationDetailsQuery +{ + public Task Run(string orgSsoIdentifier, Guid userId); +} diff --git a/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..0c210e2fd1 --- /dev/null +++ b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,35 @@ +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Repositories; + +namespace Bit.Core.KeyManagement.Queries; + +public class KeyConnectorConfirmationDetailsQuery : IKeyConnectorConfirmationDetailsQuery +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public KeyConnectorConfirmationDetailsQuery(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + } + + public async Task Run(string orgSsoIdentifier, Guid userId) + { + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + if (org is not { UseKeyConnector: true }) + { + throw new NotFoundException(); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, userId); + if (orgUser == null) + { + throw new NotFoundException(); + } + + return new KeyConnectorConfirmationDetails { OrganizationName = org.Name, }; + } +} diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1630bc0dc0..1c456df106 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -3,9 +3,11 @@ using System.Net; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; +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.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -286,20 +288,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(); + + Assert.NotNull(result); + Assert.Equal(organization.Name, result.OrganizationName); + } + + private async Task<(string, Organization)> SetupKeyConnectorTestAsync(OrganizationUserStatusType userStatusType, + string organizationSsoIdentifier = "test-sso-identifier") + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + organization.UseKeyConnector = true; + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + var ssoUserEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ssoUserEmail); + await _loginHelper.LoginAsync(ssoUserEmail); + + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUserEmail, + OrganizationUserType.User, userStatusType: userStatusType); + + return (ssoUserEmail, organization); + } } diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index b0afcd9144..a1f3088f52 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -16,6 +16,7 @@ using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; @@ -363,4 +364,39 @@ public class AccountsKeyManagementControllerTests await sutProvider.GetDependency().Received(1) .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); } + + [Theory] + [BitAutoData] + public async Task GetKeyConnectorConfirmationDetailsAsync_NoUser_Throws( + SutProvider sutProvider, string orgSsoIdentifier) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetKeyConnectorConfirmationDetailsAsync(orgSsoIdentifier)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .Run(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetKeyConnectorConfirmationDetailsAsync_Success( + SutProvider sutProvider, User expectedUser, string orgSsoIdentifier) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency().Run(orgSsoIdentifier, expectedUser.Id) + .Returns( + new KeyConnectorConfirmationDetails { OrganizationName = "test" } + ); + + var result = await sutProvider.Sut.GetKeyConnectorConfirmationDetailsAsync(orgSsoIdentifier); + + Assert.NotNull(result); + Assert.Equal("test", result.OrganizationName); + await sutProvider.GetDependency().Received(1) + .Run(orgSsoIdentifier, expectedUser.Id); + } } diff --git a/test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs b/test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs new file mode 100644 index 0000000000..612d63f289 --- /dev/null +++ b/test/Core.Test/KeyManagement/Queries/KeyConnectorConfirmationDetailsQueryTests.cs @@ -0,0 +1,86 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Queries; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Queries; + +[SutProviderCustomize] +public class KeyConnectorConfirmationDetailsQueryTests +{ + [Theory] + [BitAutoData] + public async Task Run_OrganizationNotFound_Throws(SutProvider sutProvider, + Guid userId, string orgSsoIdentifier) + { + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(orgSsoIdentifier, userId)); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task Run_OrganizationNotKeyConnector_Throws( + SutProvider sutProvider, + Guid userId, string orgSsoIdentifier, Organization org) + { + org.Identifier = orgSsoIdentifier; + org.UseKeyConnector = false; + sutProvider.GetDependency().GetByIdentifierAsync(orgSsoIdentifier).Returns(org); + + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(orgSsoIdentifier, userId)); + + await sutProvider.GetDependency() + .ReceivedWithAnyArgs(0) + .GetByOrganizationAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task Run_OrganizationUserNotFound_Throws(SutProvider sutProvider, + Guid userId, string orgSsoIdentifier + , Organization org) + { + org.Identifier = orgSsoIdentifier; + org.UseKeyConnector = true; + sutProvider.GetDependency().GetByIdentifierAsync(orgSsoIdentifier).Returns(org); + sutProvider.GetDependency() + .GetByOrganizationAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(null)); + + await Assert.ThrowsAsync(() => sutProvider.Sut.Run(orgSsoIdentifier, userId)); + + await sutProvider.GetDependency() + .Received(1) + .GetByOrganizationAsync(org.Id, userId); + } + + [Theory] + [BitAutoData] + public async Task Run_Success(SutProvider sutProvider, Guid userId, + string orgSsoIdentifier + , Organization org, OrganizationUser orgUser) + { + org.Identifier = orgSsoIdentifier; + org.UseKeyConnector = true; + orgUser.OrganizationId = org.Id; + orgUser.UserId = userId; + + sutProvider.GetDependency().GetByIdentifierAsync(orgSsoIdentifier).Returns(org); + sutProvider.GetDependency().GetByOrganizationAsync(org.Id, userId) + .Returns(orgUser); + + var result = await sutProvider.Sut.Run(orgSsoIdentifier, userId); + + Assert.Equal(org.Name, result.OrganizationName); + await sutProvider.GetDependency() + .Received(1) + .GetByOrganizationAsync(org.Id, userId); + } +} From 742280c99984d1bcdb780acb4ca4d4e31174d50e Mon Sep 17 00:00:00 2001 From: gitclonebrian <235774926+gitclonebrian@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:47:54 -0500 Subject: [PATCH 3/8] [repository-management.yml] Implement least privilege permissions (#6646) - Add empty permission set at workflow level to remove default GITHUB_TOKEN permissions - Add empty permission set to setup job as it only runs bash commands - Add contents:write to GitHub App tokens in bump_version and cut_branch jobs for git operations - Add empty permission set to move_edd_db_scripts job as called workflow declares its own permissions - Remove secrets:inherit as called workflow accesses Azure secrets directly --- .github/workflows/repository-management.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 92452102cf..74823c34b5 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,9 +22,7 @@ on: required: false type: string -permissions: - pull-requests: write - contents: write +permissions: {} jobs: setup: @@ -32,6 +30,7 @@ jobs: runs-on: ubuntu-24.04 outputs: branch: ${{ steps.set-branch.outputs.branch }} + permissions: {} steps: - name: Set branch id: set-branch @@ -89,6 +88,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -212,6 +212,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -240,10 +241,5 @@ jobs: move_edd_db_scripts: name: Move EDD database scripts needs: cut_branch - permissions: - actions: read - contents: write - id-token: write - pull-requests: write + permissions: {} uses: ./.github/workflows/_move_edd_db_scripts.yml - secrets: inherit From 1aad410128c336ce7e2950882be66ac0698a10f8 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 11 Dec 2025 01:55:16 +0100 Subject: [PATCH 4/8] Remove epic link for bitwarden lite issue template (#6719) Co-authored-by: Daniel James Smith --- .github/ISSUE_TEMPLATE/bw-lite.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bw-lite.yml b/.github/ISSUE_TEMPLATE/bw-lite.yml index cc36164e8f..0c43fa5835 100644 --- a/.github/ISSUE_TEMPLATE/bw-lite.yml +++ b/.github/ISSUE_TEMPLATE/bw-lite.yml @@ -70,15 +70,6 @@ body: mariadb:10 # Postgres Example postgres:14 - - type: textarea - id: epic-label - attributes: - label: Issue-Link - description: Link to our pinned issue, tracking all Bitwarden lite - value: | - https://github.com/bitwarden/server/issues/2480 - validations: - required: true - type: checkboxes id: issue-tracking-info attributes: From 919d0be6d2fcd2f94fb2b538d4e738b52faba8f1 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 11 Dec 2025 12:10:50 +0100 Subject: [PATCH 5/8] Add UpdateAccountCryptographicState repository function (#6669) * Add user repository update function for account cryptographic state * Remove comment * Remove transaction logic * Fix security version * Apply feedback * Update tests * Add support for external actions --- .../Models/Data/UserAccountKeysData.cs | 27 ++++++- src/Core/Repositories/IUserRepository.cs | 13 ++++ .../Repositories/UserRepository.cs | 61 ++++++++++++++- .../Repositories/UserRepository.cs | 75 +++++++++++++++++++ .../User_UpdateAccountCryptographicState.sql | 65 ++++++++++++++++ .../Repositories/UserRepositoryTests.cs | 64 ++++++++++++++++ ...0_User_UpdateAccountCryptographicState.sql | 72 ++++++++++++++++++ 7 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql create mode 100644 util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql diff --git a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs index cabdca59ea..3d552a10de 100644 --- a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs @@ -1,9 +1,34 @@ namespace Bit.Core.KeyManagement.Models.Data; - +/// +/// Represents an expanded account cryptographic state for a user. Expanded here means +/// that it does not only contain the (wrapped) private / signing key, but also the public +/// key / verifying key. The client side only needs a subset of this data to unlock +/// their vault and the public parts can be derived. +/// public class UserAccountKeysData { public required PublicKeyEncryptionKeyPairData PublicKeyEncryptionKeyPairData { get; set; } public SignatureKeyPairData? SignatureKeyPairData { get; set; } public SecurityStateData? SecurityStateData { get; set; } + + /// + /// Checks whether the account cryptographic state is for a V1 encryption user or a V2 encryption user. + /// Throws if the state is invalid + /// + public bool IsV2Encryption() + { + if (PublicKeyEncryptionKeyPairData.SignedPublicKey != null && SignatureKeyPairData != null && SecurityStateData != null) + { + return true; + } + else if (PublicKeyEncryptionKeyPairData.SignedPublicKey == null && SignatureKeyPairData == null && SecurityStateData == null) + { + return false; + } + else + { + throw new InvalidOperationException("Invalid account cryptographic state: V2 encryption fields must be either all present or all absent."); + } + } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 22effb4329..7cdd159224 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -44,5 +45,17 @@ public interface IUserRepository : IRepository IEnumerable updateDataActions); Task UpdateUserKeyAndEncryptedDataV2Async(User user, IEnumerable updateDataActions); + /// + /// Sets the account cryptographic state to a user in a single transaction. The provided + /// MUST be a V2 encryption state. Passing in a V1 encryption state will throw. + /// Extra actions can be passed in case other user data needs to be updated in the same transaction. + /// + Task SetV2AccountCryptographicStateAsync( + Guid userId, + UserAccountKeysData accountKeysData, + IEnumerable? updateUserDataActions = null); Task DeleteManyAsync(IEnumerable users); } + +public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, + Microsoft.Data.SqlClient.SqlTransaction? transaction = null); diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 6b11d64cda..86ab063a5f 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -2,16 +2,16 @@ using System.Text.Json; using Bit.Core; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Utilities; using Dapper; using Microsoft.AspNetCore.DataProtection; using Microsoft.Data.SqlClient; -#nullable enable - namespace Bit.Infrastructure.Dapper.Repositories; public class UserRepository : Repository, IUserRepository @@ -288,6 +288,63 @@ public class UserRepository : Repository, IUserRepository UnprotectData(user); } + public async Task SetV2AccountCryptographicStateAsync( + Guid userId, + UserAccountKeysData accountKeysData, + IEnumerable? updateUserDataActions = null) + { + if (!accountKeysData.IsV2Encryption()) + { + throw new ArgumentException("Provided account keys data is not valid V2 encryption data.", nameof(accountKeysData)); + } + + var timestamp = DateTime.UtcNow; + var signatureKeyPairId = CoreHelpers.GenerateComb(); + + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var transaction = connection.BeginTransaction(); + try + { + await connection.ExecuteAsync( + "[dbo].[User_UpdateAccountCryptographicState]", + new + { + Id = userId, + PublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.PublicKey, + PrivateKey = accountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey, + SignedPublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey, + SecurityState = accountKeysData.SecurityStateData!.SecurityState, + SecurityVersion = accountKeysData.SecurityStateData!.SecurityVersion, + SignatureKeyPairId = signatureKeyPairId, + SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm, + SigningKey = accountKeysData.SignatureKeyPairData!.WrappedSigningKey, + VerifyingKey = accountKeysData.SignatureKeyPairData!.VerifyingKey, + RevisionDate = timestamp, + AccountRevisionDate = timestamp + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + + // Update user data that depends on cryptographic state + if (updateUserDataActions != null) + { + foreach (var action in updateUserDataActions) + { + await action(connection, transaction); + } + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + public async Task> GetManyAsync(IEnumerable ids) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 809704edb7..a43c692be3 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; using Bit.Core.Repositories; @@ -241,6 +242,80 @@ public class UserRepository : Repository, IUserR await transaction.CommitAsync(); } + public async Task SetV2AccountCryptographicStateAsync( + Guid userId, + UserAccountKeysData accountKeysData, + IEnumerable? updateUserDataActions = null) + { + if (!accountKeysData.IsV2Encryption()) + { + throw new ArgumentException("Provided account keys data is not valid V2 encryption data.", nameof(accountKeysData)); + } + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // Update user + var userEntity = await dbContext.Users.FindAsync(userId); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(userId)); + } + + // Update public key encryption key pair + var timestamp = DateTime.UtcNow; + + userEntity.RevisionDate = timestamp; + userEntity.AccountRevisionDate = timestamp; + + // V1 + V2 user crypto changes + userEntity.PublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.PublicKey; + userEntity.PrivateKey = accountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey; + + userEntity.SecurityState = accountKeysData.SecurityStateData!.SecurityState; + userEntity.SecurityVersion = accountKeysData.SecurityStateData.SecurityVersion; + userEntity.SignedPublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey; + + // Replace existing keypair if it exists + var existingKeyPair = await dbContext.UserSignatureKeyPairs + .FirstOrDefaultAsync(x => x.UserId == userId); + if (existingKeyPair != null) + { + existingKeyPair.SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm; + existingKeyPair.SigningKey = accountKeysData.SignatureKeyPairData.WrappedSigningKey; + existingKeyPair.VerifyingKey = accountKeysData.SignatureKeyPairData.VerifyingKey; + existingKeyPair.RevisionDate = timestamp; + } + else + { + var newKeyPair = new UserSignatureKeyPair + { + UserId = userId, + SignatureAlgorithm = accountKeysData.SignatureKeyPairData!.SignatureAlgorithm, + SigningKey = accountKeysData.SignatureKeyPairData.WrappedSigningKey, + VerifyingKey = accountKeysData.SignatureKeyPairData.VerifyingKey, + CreationDate = timestamp, + RevisionDate = timestamp + }; + newKeyPair.SetNewId(); + await dbContext.UserSignatureKeyPairs.AddAsync(newKeyPair); + } + + await dbContext.SaveChangesAsync(); + + // Update additional user data within the same transaction + if (updateUserDataActions != null) + { + foreach (var action in updateUserDataActions) + { + await action(); + } + } + await transaction.CommitAsync(); + } + public async Task> GetManyAsync(IEnumerable ids) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql b/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql new file mode 100644 index 0000000000..8f1fb664ea --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_UpdateAccountCryptographicState.sql @@ -0,0 +1,65 @@ +CREATE PROCEDURE [dbo].[User_UpdateAccountCryptographicState] + @Id UNIQUEIDENTIFIER, + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @SignedPublicKey NVARCHAR(MAX) = NULL, + @SecurityState NVARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignatureKeyPairId UNIQUEIDENTIFIER = NULL, + @SignatureAlgorithm TINYINT = NULL, + @SigningKey VARCHAR(MAX) = NULL, + @VerifyingKey VARCHAR(MAX) = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [SignedPublicKey] = @SignedPublicKey, + [SecurityState] = @SecurityState, + [SecurityVersion] = @SecurityVersion, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id + + IF EXISTS (SELECT 1 FROM [dbo].[UserSignatureKeyPair] WHERE [UserId] = @Id) + BEGIN + UPDATE [dbo].[UserSignatureKeyPair] + SET + [SignatureAlgorithm] = @SignatureAlgorithm, + [SigningKey] = @SigningKey, + [VerifyingKey] = @VerifyingKey, + [RevisionDate] = @RevisionDate + WHERE + [UserId] = @Id + END + ELSE + BEGIN + INSERT INTO [dbo].[UserSignatureKeyPair] + ( + [Id], + [UserId], + [SignatureAlgorithm], + [SigningKey], + [VerifyingKey], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @SignatureKeyPairId, + @Id, + @SignatureAlgorithm, + @SigningKey, + @VerifyingKey, + @RevisionDate, + @RevisionDate + ) + END +END diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs index 151bd47c44..37a3512d76 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs @@ -2,6 +2,8 @@ using Bit.Core.Auth.Entities; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Enums; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Data; using Bit.Core.Test.AutoFixture.Attributes; using Bit.Infrastructure.EFIntegration.Test.AutoFixture; @@ -313,4 +315,66 @@ public class UserRepositoryTests Assert.Equal(sqlUser.MasterPasswordHint, updatedUser.MasterPasswordHint); Assert.Equal(sqlUser.Email, updatedUser.Email); } + + [CiSkippedTheory, EfUserAutoData] + public async Task UpdateAccountCryptographicStateAsync_Works_DataMatches( + User user, + List suts, + SqlRepo.UserRepository sqlUserRepo) + { + // Test for V1 user (no signature key pair or security state) + var accountKeysDataV1 = new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + wrappedPrivateKey: "v1-wrapped-private-key", + publicKey: "v1-public-key" + ) + }; + + foreach (var sut in suts) + { + var createdUser = await sut.CreateAsync(user); + sut.ClearChangeTracking(); + + await sut.SetV2AccountCryptographicStateAsync(createdUser.Id, accountKeysDataV1); + sut.ClearChangeTracking(); + + var updatedUser = await sut.GetByIdAsync(createdUser.Id); + Assert.Equal("v1-public-key", updatedUser.PublicKey); + Assert.Equal("v1-wrapped-private-key", updatedUser.PrivateKey); + Assert.Null(updatedUser.SignedPublicKey); + Assert.Null(updatedUser.SecurityState); + Assert.Null(updatedUser.SecurityVersion); + } + + // Test for V2 user (with signature key pair and security state) + var accountKeysDataV2 = new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + wrappedPrivateKey: "v2-wrapped-private-key", + publicKey: "v2-public-key", + signedPublicKey: "v2-signed-public-key" + ), + SignatureKeyPairData = new SignatureKeyPairData( + signatureAlgorithm: SignatureAlgorithm.Ed25519, + wrappedSigningKey: "v2-wrapped-signing-key", + verifyingKey: "v2-verifying-key" + ), + SecurityStateData = new SecurityStateData + { + SecurityState = "v2-security-state", + SecurityVersion = 2 + } + }; + + var sqlUser = await sqlUserRepo.CreateAsync(user); + await sqlUserRepo.SetV2AccountCryptographicStateAsync(sqlUser.Id, accountKeysDataV2); + + var updatedSqlUser = await sqlUserRepo.GetByIdAsync(sqlUser.Id); + Assert.Equal("v2-public-key", updatedSqlUser.PublicKey); + Assert.Equal("v2-wrapped-private-key", updatedSqlUser.PrivateKey); + Assert.Equal("v2-signed-public-key", updatedSqlUser.SignedPublicKey); + Assert.Equal("v2-security-state", updatedSqlUser.SecurityState); + Assert.Equal(2, updatedSqlUser.SecurityVersion); + } } diff --git a/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql b/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql new file mode 100644 index 0000000000..259a126220 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-08_00_User_UpdateAccountCryptographicState.sql @@ -0,0 +1,72 @@ +IF OBJECT_ID('[dbo].[User_UpdateAccountCryptographicState]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[User_UpdateAccountCryptographicState] +END +GO + +CREATE PROCEDURE [dbo].[User_UpdateAccountCryptographicState] + @Id UNIQUEIDENTIFIER, + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @SignedPublicKey NVARCHAR(MAX) = NULL, + @SecurityState NVARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignatureKeyPairId UNIQUEIDENTIFIER = NULL, + @SignatureAlgorithm TINYINT = NULL, + @SigningKey VARCHAR(MAX) = NULL, + @VerifyingKey VARCHAR(MAX) = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [SignedPublicKey] = @SignedPublicKey, + [SecurityState] = @SecurityState, + [SecurityVersion] = @SecurityVersion, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id + + IF EXISTS (SELECT 1 FROM [dbo].[UserSignatureKeyPair] WHERE [UserId] = @Id) + BEGIN + UPDATE [dbo].[UserSignatureKeyPair] + SET + [SignatureAlgorithm] = @SignatureAlgorithm, + [SigningKey] = @SigningKey, + [VerifyingKey] = @VerifyingKey, + [RevisionDate] = @RevisionDate + WHERE + [UserId] = @Id + END + ELSE + BEGIN + INSERT INTO [dbo].[UserSignatureKeyPair] + ( + [Id], + [UserId], + [SignatureAlgorithm], + [SigningKey], + [VerifyingKey], + [CreationDate], + [RevisionDate] + ) + VALUES + ( + @SignatureKeyPairId, + @Id, + @SignatureAlgorithm, + @SigningKey, + @VerifyingKey, + @RevisionDate, + @RevisionDate + ) + END +END +GO From e3d54060fe4a0f406432415385789c948b1645b6 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:38:19 -0500 Subject: [PATCH 6/8] Add configurable queue name support to AzureQueueHostedService (#6718) --- src/EventsProcessor/AzureQueueHostedService.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/EventsProcessor/AzureQueueHostedService.cs b/src/EventsProcessor/AzureQueueHostedService.cs index c6f5afbfdd..8dc0f12c0c 100644 --- a/src/EventsProcessor/AzureQueueHostedService.cs +++ b/src/EventsProcessor/AzureQueueHostedService.cs @@ -57,14 +57,16 @@ public class AzureQueueHostedService : IHostedService, IDisposable private async Task ExecuteAsync(CancellationToken cancellationToken) { var storageConnectionString = _configuration["azureStorageConnectionString"]; - if (string.IsNullOrWhiteSpace(storageConnectionString)) + var queueName = _configuration["azureQueueServiceQueueName"]; + if (string.IsNullOrWhiteSpace(storageConnectionString) || + string.IsNullOrWhiteSpace(queueName)) { return; } var repo = new Core.Repositories.TableStorage.EventRepository(storageConnectionString); _eventWriteService = new RepositoryEventWriteService(repo); - _queueClient = new QueueClient(storageConnectionString, "event"); + _queueClient = new QueueClient(storageConnectionString, queueName); while (!cancellationToken.IsCancellationRequested) { From 20755f6c2f3cd5d13465661776522d4e72e07468 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:31:12 -0600 Subject: [PATCH 7/8] [PM-25947] Add folders and favorites when sharing a cipher (#6402) * add folders and favorites when sharing a cipher * refactor folders and favorites assignment to consider existing folders/favorite assignments on a cipher * remove unneeded string manipulation * remove comment * add unit test for folder/favorite sharing * add migration for sharing a cipher to org and collect reprompt, favorite and folders * update date timestamp of migration --- .../Vault/Controllers/CiphersController.cs | 2 +- .../Models/Request/CipherRequestModel.cs | 36 ++- .../Vault/Repositories/CipherRepository.cs | 3 + .../Cipher/Cipher_UpdateWithCollections.sql | 9 +- .../Controllers/CiphersControllerTests.cs | 233 ++++++++++++++++++ .../Repositories/CipherRepositoryTests.cs | 54 ++++ ...5-12-09_00_ShareFavoriteFolderReprompt.sql | 62 +++++ 7 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 8c5df96262..6a506cc01f 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -760,7 +760,7 @@ public class CiphersController : Controller ValidateClientVersionForFido2CredentialSupport(cipher); var original = cipher.Clone(); - await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), + await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId), model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); var sharedCipher = await GetByIdAsync(id, user.Id); diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index b0589a62f9..18a1aec559 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -84,7 +84,7 @@ public class CipherRequestModel return existingCipher; } - public Cipher ToCipher(Cipher existingCipher) + public Cipher ToCipher(Cipher existingCipher, Guid? userId = null) { // If Data field is provided, use it directly if (!string.IsNullOrWhiteSpace(Data)) @@ -124,9 +124,12 @@ public class CipherRequestModel } } + var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null; existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; existingCipher.ArchivedDate = ArchivedDate; + existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId); + existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite); var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -291,6 +294,37 @@ public class CipherRequestModel KeyFingerprint = SSHKey.KeyFingerprint, }; } + + /// + /// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair + /// based on the provided userIdKey and newValue. + /// + private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue) + { + if (userIdKey == null) + { + return existingJson; + } + + var jsonDict = string.IsNullOrWhiteSpace(existingJson) + ? new Dictionary() + : JsonSerializer.Deserialize>(existingJson) ?? new Dictionary(); + + var shouldRemove = newValue == null || + (newValue is string strValue && string.IsNullOrWhiteSpace(strValue)) || + (newValue is bool boolValue && !boolValue); + + if (shouldRemove) + { + jsonDict.Remove(userIdKey); + } + else + { + jsonDict[userIdKey] = newValue is string str ? str.ToUpperInvariant() : newValue; + } + + return jsonDict.Count == 0 ? null : JsonSerializer.Serialize(jsonDict); + } } public class CipherWithIdRequestModel : CipherRequestModel diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 3c45afe530..ebe39852f4 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -704,6 +704,9 @@ public class CipherRepository : Repository(() => sutProvider.Sut.PostPurge(model, organizationId)); } + + [Theory, BitAutoData] + public async Task PutShare_WithNullFolderAndFalseFavorite_UpdatesFieldsCorrectly( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid folderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, folderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }) + }; + + // Clears folder and favorite when sharing + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = null, + Favorite = false, + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = null, + Favorite = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Null(result.FolderId); + Assert.False(result.Favorite); + } + + [Theory, BitAutoData] + public async Task PutShare_WithFolderAndFavoriteSet_AddsUserSpecificFields( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid folderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = null, + Favorites = null + }; + + // Sets folder and favorite when sharing + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = folderId.ToString(), + Favorite = true, + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, folderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }), + FolderId = folderId, + Favorite = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Equal(folderId, result.FolderId); + Assert.True(result.Favorite); + } + + [Theory, BitAutoData] + public async Task PutShare_UpdateExistingFolderAndFavorite_UpdatesUserSpecificFields( + Guid cipherId, + Guid userId, + Guid organizationId, + Guid oldFolderId, + Guid newFolderId, + SutProvider sutProvider) + { + var user = new User { Id = userId }; + var userIdKey = userId.ToString().ToUpperInvariant(); + + // Existing cipher with old folder and not favorited + var existingCipher = new Cipher + { + Id = cipherId, + UserId = userId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, oldFolderId.ToString().ToUpperInvariant() } }), + Favorites = null + }; + + var model = new CipherShareRequestModel + { + Cipher = new CipherRequestModel + { + Type = CipherType.Login, + OrganizationId = organizationId.ToString(), + Name = "SharedCipher", + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + FolderId = newFolderId.ToString(), // Update to new folder + Favorite = true, // Add favorite + EncryptedFor = userId + }, + CollectionIds = [Guid.NewGuid().ToString()] + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(existingCipher); + + sutProvider.GetDependency() + .OrganizationUser(organizationId) + .Returns(true); + + var sharedCipher = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = JsonSerializer.Serialize(new { Username = "test", Password = "test" }), + Folders = JsonSerializer.Serialize(new Dictionary { { userIdKey, newFolderId.ToString().ToUpperInvariant() } }), + Favorites = JsonSerializer.Serialize(new Dictionary { { userIdKey, true } }), + FolderId = newFolderId, + Favorite = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherId, userId) + .Returns(sharedCipher); + + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary + { + { organizationId, new OrganizationAbility { Id = organizationId } } + }); + + var result = await sutProvider.Sut.PutShare(cipherId, model); + + Assert.Equal(newFolderId, result.FolderId); + Assert.True(result.Favorite); + } } diff --git a/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs index 689bd5e243..5aceb15124 100644 --- a/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Vault/Repositories/CipherRepositoryTests.cs @@ -225,4 +225,58 @@ public class CipherRepositoryTests Assert.True(savedCipher == null); } } + + [CiSkippedTheory, EfOrganizationCipherCustomize, BitAutoData] + public async Task ReplaceAsync_WithCollections_UpdatesFoldersFavoritesRepromptAndArchivedDateAsync( + Cipher cipher, + User user, + Organization org, + Collection collection, + List suts, + List efUserRepos, + List efOrgRepos, + List efCollectionRepos) + { + foreach (var sut in suts) + { + var i = suts.IndexOf(sut); + + var postEfOrg = await efOrgRepos[i].CreateAsync(org); + efOrgRepos[i].ClearChangeTracking(); + var postEfUser = await efUserRepos[i].CreateAsync(user); + efUserRepos[i].ClearChangeTracking(); + + collection.OrganizationId = postEfOrg.Id; + var postEfCollection = await efCollectionRepos[i].CreateAsync(collection); + efCollectionRepos[i].ClearChangeTracking(); + + cipher.UserId = postEfUser.Id; + cipher.OrganizationId = null; + cipher.Folders = $"{{\"{postEfUser.Id}\":\"some-folder-id\"}}"; + cipher.Favorites = $"{{\"{postEfUser.Id}\":true}}"; + cipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password; + + var createdCipher = await sut.CreateAsync(cipher); + sut.ClearChangeTracking(); + + var updatedCipher = await sut.GetByIdAsync(createdCipher.Id); + updatedCipher.UserId = postEfUser.Id; + updatedCipher.OrganizationId = postEfOrg.Id; + updatedCipher.Folders = $"{{\"{postEfUser.Id}\":\"new-folder-id\"}}"; + updatedCipher.Favorites = $"{{\"{postEfUser.Id}\":true}}"; + updatedCipher.Reprompt = Core.Vault.Enums.CipherRepromptType.Password; + + await sut.ReplaceAsync(updatedCipher, new List { postEfCollection.Id }); + sut.ClearChangeTracking(); + + + var savedCipher = await sut.GetByIdAsync(createdCipher.Id); + Assert.NotNull(savedCipher); + Assert.Null(savedCipher.UserId); + Assert.Equal(postEfOrg.Id, savedCipher.OrganizationId); + Assert.Equal($"{{\"{postEfUser.Id}\":\"new-folder-id\"}}", savedCipher.Folders); + Assert.Equal($"{{\"{postEfUser.Id}\":true}}", savedCipher.Favorites); + Assert.Equal(Core.Vault.Enums.CipherRepromptType.Password, savedCipher.Reprompt); + } + } } diff --git a/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql b/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql new file mode 100644 index 0000000000..6d4ea668a3 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-09_00_ShareFavoriteFolderReprompt.sql @@ -0,0 +1,62 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + BEGIN TRANSACTION Cipher_UpdateWithCollections + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + IF @UpdateCollectionsSuccess < 0 + BEGIN + COMMIT TRANSACTION Cipher_UpdateWithCollections + SELECT -1 -- -1 = Failure + RETURN + END + + UPDATE + [dbo].[Cipher] + SET + [UserId] = NULL, + [OrganizationId] = @OrganizationId, + [Data] = @Data, + [Attachments] = @Attachments, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate, + [Folders] = @Folders, + [Favorites] = @Favorites, + [Reprompt] = @Reprompt + -- No need to update CreationDate or Type since that data will not change + WHERE + [Id] = @Id + + COMMIT TRANSACTION Cipher_UpdateWithCollections + + IF @Attachments IS NOT NULL + BEGIN + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_UpdateStorage] @UserId + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + + SELECT 0 -- 0 = Success +END From 3de2f98681845ef062bdcdd447775e0e6c762336 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Thu, 11 Dec 2025 14:50:25 -0500 Subject: [PATCH 8/8] [PM-28754] add accepted and decline types (#6721) --- src/Core/AdminConsole/Enums/EventType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 09cda7ca0e..916f408fe6 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -81,6 +81,8 @@ public enum EventType : int Organization_CollectionManagement_LimitItemDeletionDisabled = 1615, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, + Organization_ItemOrganization_Accepted = 1618, + Organization_ItemOrganization_Declined = 1619, Policy_Updated = 1700,