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,