From 866ba6609deeea507414b06699da9508001534b3 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:23:45 -0500 Subject: [PATCH 01/12] PM-31106: Dev container improvements (#6651) * Add rust feature * Give the community create command the non-interactive treatment * Add ability to load custom root CA * Update .devcontainer/community_dev/postCreateCommand.sh Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Update .devcontainer/community_dev/postCreateCommand.sh Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .devcontainer/community_dev/devcontainer.json | 3 +- .../community_dev/postCreateCommand.sh | 55 ++++++++++++++----- .devcontainer/internal_dev/devcontainer.json | 4 +- .devcontainer/internal_dev/onCreateCommand.sh | 12 ++++ dev/.gitignore | 1 + 5 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 .devcontainer/internal_dev/onCreateCommand.sh diff --git a/.devcontainer/community_dev/devcontainer.json b/.devcontainer/community_dev/devcontainer.json index ce3b8a21c6..db69efe9dc 100644 --- a/.devcontainer/community_dev/devcontainer.json +++ b/.devcontainer/community_dev/devcontainer.json @@ -6,7 +6,8 @@ "features": { "ghcr.io/devcontainers/features/node:1": { "version": "16" - } + }, + "ghcr.io/devcontainers/features/rust:1": {} }, "mounts": [ { diff --git a/.devcontainer/community_dev/postCreateCommand.sh b/.devcontainer/community_dev/postCreateCommand.sh index 8f1813ed78..8ae3854168 100755 --- a/.devcontainer/community_dev/postCreateCommand.sh +++ b/.devcontainer/community_dev/postCreateCommand.sh @@ -3,11 +3,46 @@ export DEV_DIR=/workspace/dev export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev git config --global --add safe.directory /workspace +if [[ -z "${CODESPACES}" ]]; then + allow_interactive=1 +else + echo "Doing non-interactive setup" + allow_interactive=0 +fi + +get_option() { + # Helper function for reading the value of an environment variable + # primarily but then falling back to an interactive question if allowed + # and lastly falling back to a default value input when either other + # option is available. + name_of_var="$1" + question_text="$2" + default_value="$3" + is_secret="$4" + + if [[ -n "${!name_of_var}" ]]; then + # If the env variable they gave us has a value, then use that value + echo "${!name_of_var}" + elif [[ "$allow_interactive" == 1 ]]; then + # If we can be interactive, then use the text they gave us to request input + if [[ "$is_secret" == 1 ]]; then + read -r -s -p "$question_text" response + echo "$response" + else + read -r -p "$question_text" response + echo "$response" + fi + else + # If no environment variable and not interactive, then just give back default value + echo "$default_value" + fi +} + get_installation_id_and_key() { pushd ./dev >/dev/null || exit echo "Please enter your installation id and key from https://bitwarden.com/host:" - read -r -p "Installation id: " INSTALLATION_ID - read -r -p "Installation key: " INSTALLATION_KEY + INSTALLATION_ID="$(get_option "INSTALLATION_ID" "Installation id: " "00000000-0000-0000-0000-000000000001")" + INSTALLATION_KEY="$(get_option "INSTALLATION_KEY" "Installation key: " "" 1)" jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" | .globalSettings.installation.key = \"$INSTALLATION_KEY\"" \ secrets.json.example >secrets.json # create/overwrite secrets.json @@ -30,11 +65,10 @@ configure_other_vars() { } one_time_setup() { - read -r -p \ - "Would you like to configure your secrets and certificates for the first time? + do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like to configure your secrets and certificates for the first time? WARNING: This will overwrite any existing secrets.json and certificate files. -Proceed? [y/N] " response - if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then +Proceed? [y/N] " "n")" + if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then echo "Running one-time setup script..." sleep 1 get_installation_id_and_key @@ -50,11 +84,4 @@ Proceed? [y/N] " response fi } -# main -if [[ -z "${CODESPACES}" ]]; then - one_time_setup -else - # Ignore interactive elements when running in codespaces since they are not supported there - # TODO Write codespaces specific instructions and link here - echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup" -fi +one_time_setup diff --git a/.devcontainer/internal_dev/devcontainer.json b/.devcontainer/internal_dev/devcontainer.json index 862b9297c4..a37e3146d3 100644 --- a/.devcontainer/internal_dev/devcontainer.json +++ b/.devcontainer/internal_dev/devcontainer.json @@ -9,7 +9,8 @@ "features": { "ghcr.io/devcontainers/features/node:1": { "version": "16" - } + }, + "ghcr.io/devcontainers/features/rust:1": {} }, "mounts": [ { @@ -24,6 +25,7 @@ "extensions": ["ms-dotnettools.csdevkit"] } }, + "onCreateCommand": "bash .devcontainer/internal_dev/onCreateCommand.sh", "postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh", "forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002], "portsAttributes": { diff --git a/.devcontainer/internal_dev/onCreateCommand.sh b/.devcontainer/internal_dev/onCreateCommand.sh new file mode 100644 index 0000000000..71d466aae9 --- /dev/null +++ b/.devcontainer/internal_dev/onCreateCommand.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +export REPO_ROOT="$(git rev-parse --show-toplevel)" + +file="$REPO_ROOT/dev/custom-root-ca.crt" + +if [ -e "$file" ]; then + echo "Adding custom root CA" + sudo cp "$file" /usr/local/share/ca-certificates/ + sudo update-ca-certificates +else + echo "No custom root CA found, skipping..." +fi diff --git a/dev/.gitignore b/dev/.gitignore index 39b657f453..034b002f7c 100644 --- a/dev/.gitignore +++ b/dev/.gitignore @@ -18,3 +18,4 @@ signingkey.jwk # Reverse Proxy Conifg reverse-proxy.conf +*.crt From d8379d34746e5f662151b6e578015878ce1a200f Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:43:32 -0500 Subject: [PATCH 02/12] Updated devcontainer configs, fixed migration variable bug, added DEV_CERT_CONTENTS to .env.example (#6891) --- .devcontainer/community_dev/devcontainer.json | 27 ++++++- .devcontainer/internal_dev/devcontainer.json | 79 ++++++++++++++++++- .../internal_dev/postCreateCommand.sh | 2 +- dev/.env.example | 1 + 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/.devcontainer/community_dev/devcontainer.json b/.devcontainer/community_dev/devcontainer.json index db69efe9dc..c59ad3b839 100644 --- a/.devcontainer/community_dev/devcontainer.json +++ b/.devcontainer/community_dev/devcontainer.json @@ -3,9 +3,10 @@ "dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml", "service": "bitwarden_server", "workspaceFolder": "/workspace", + "initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "16" + "version": "22" }, "ghcr.io/devcontainers/features/rust:1": {} }, @@ -22,5 +23,27 @@ "extensions": ["ms-dotnettools.csdevkit"] } }, - "postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh" + "postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh", + "forwardPorts": [1080, 1433, 3306, 5432], + "portsAttributes": { + "default": { + "onAutoForward": "ignore" + }, + "1080": { + "label": "Mail Catcher", + "onAutoForward": "notify" + }, + "1433": { + "label": "SQL Server", + "onAutoForward": "notify" + }, + "3306": { + "label": "MySQL", + "onAutoForward": "notify" + }, + "5432": { + "label": "PostgreSQL", + "onAutoForward": "notify" + } + } } diff --git a/.devcontainer/internal_dev/devcontainer.json b/.devcontainer/internal_dev/devcontainer.json index a37e3146d3..99e3057024 100644 --- a/.devcontainer/internal_dev/devcontainer.json +++ b/.devcontainer/internal_dev/devcontainer.json @@ -6,9 +6,10 @@ ], "service": "bitwarden_server", "workspaceFolder": "/workspace", + "initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "16" + "version": "22" }, "ghcr.io/devcontainers/features/rust:1": {} }, @@ -27,8 +28,16 @@ }, "onCreateCommand": "bash .devcontainer/internal_dev/onCreateCommand.sh", "postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh", - "forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002], + "forwardPorts": [ + 1080, 1433, 3306, 5432, 10000, 10001, 10002, + 4000, 4001, 33656, 33657, 44519, 44559, + 46273, 46274, 50024, 51822, 51823, + 54103, 61840, 61841, 62911, 62912 + ], "portsAttributes": { + "default": { + "onAutoForward": "ignore" + }, "1080": { "label": "Mail Catcher", "onAutoForward": "notify" @@ -50,12 +59,76 @@ "onAutoForward": "notify" }, "10001": { - "label": "Azurite Storage Queue ", + "label": "Azurite Storage Queue", "onAutoForward": "notify" }, "10002": { "label": "Azurite Storage Table", "onAutoForward": "notify" + }, + "4000": { + "label": "Api (Cloud)", + "onAutoForward": "notify" + }, + "4001": { + "label": "Api (SelfHost)", + "onAutoForward": "notify" + }, + "33656": { + "label": "Identity (Cloud)", + "onAutoForward": "notify" + }, + "33657": { + "label": "Identity (SelfHost)", + "onAutoForward": "notify" + }, + "44519": { + "label": "Billing", + "onAutoForward": "notify" + }, + "44559": { + "label": "Scim", + "onAutoForward": "notify" + }, + "46273": { + "label": "Events (Cloud)", + "onAutoForward": "notify" + }, + "46274": { + "label": "Events (SelfHost)", + "onAutoForward": "notify" + }, + "50024": { + "label": "Icons", + "onAutoForward": "notify" + }, + "51822": { + "label": "Sso (Cloud)", + "onAutoForward": "notify" + }, + "51823": { + "label": "Sso (SelfHost)", + "onAutoForward": "notify" + }, + "54103": { + "label": "EventsProcessor", + "onAutoForward": "notify" + }, + "61840": { + "label": "Notifications (Cloud)", + "onAutoForward": "notify" + }, + "61841": { + "label": "Notifications (SelfHost)", + "onAutoForward": "notify" + }, + "62911": { + "label": "Admin (Cloud)", + "onAutoForward": "notify" + }, + "62912": { + "label": "Admin (SelfHost)", + "onAutoForward": "notify" } } } diff --git a/.devcontainer/internal_dev/postCreateCommand.sh b/.devcontainer/internal_dev/postCreateCommand.sh index 3fd278be26..ceef0ef0f5 100755 --- a/.devcontainer/internal_dev/postCreateCommand.sh +++ b/.devcontainer/internal_dev/postCreateCommand.sh @@ -108,7 +108,7 @@ Press to continue." fi run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")" - if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + if [[ "$run_mssql_migrations" =~ ^([yY][eE][sS]|[yY])+$ ]]; then echo "Running migrations..." sleep 5 # wait for DB container to start dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING" diff --git a/dev/.env.example b/dev/.env.example index f31b5b9eeb..88fbd44036 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -34,4 +34,5 @@ RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123 # SETUP_AZURITE=yes # RUN_MSSQL_MIGRATIONS=yes # DEV_CERT_PASSWORD=dev_cert_password_here +# DEV_CERT_CONTENTS=base64_encoded_dev_pfx_here (alternative to placing dev.pfx file manually) # INSTALL_STRIPE_CLI=no From c8124667ee7423f21aaf03914e9370ae9c4a2d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:38:06 +0000 Subject: [PATCH 03/12] [PM-28842] Add validation to prevent excessive master password policy values (#6807) * Enhance MasterPasswordPolicyData with validation attributes Added data annotations for MinComplexity and MinLength properties to enforce validation rules. MinComplexity must be between 0 and 4, and MinLength must be between 12 and 128. * Implement model validation in PolicyDataValidator and enhance error handling Added a ValidateModel method to enforce validation rules for policy data. Updated error messages to provide clearer feedback on validation failures. Enhanced unit tests to cover new validation scenarios for MinLength and MinComplexity properties. * Update PoliciesControllerTests to reflect new validation rules for MinComplexity and MinLength Modified test cases to use updated values for MinComplexity (4) and MinLength (128). Added new tests to verify that excessive values for these properties return BadRequest responses. Ensured consistency across integration tests for both Admin and Public controllers. * Enhance MasterPasswordPolicyData with XML documentation for properties Added XML documentation comments for MinComplexity and MinLength properties to clarify their purpose and constraints. This improves code readability and provides better context for developers using the model. * Add unit tests for PolicyDataValidator to validate minLength and minComplexity rules Implemented new test cases to verify the behavior of the ValidateAndSerialize method in PolicyDataValidator. Tests cover scenarios for minimum and maximum values, as well as edge cases for invalid inputs, ensuring robust validation for MasterPassword policy data. --- .../Policies/MasterPasswordPolicyData.cs | 12 +- .../Utilities/PolicyDataValidator.cs | 21 ++- .../Controllers/PoliciesControllerTests.cs | 48 ++++++- .../Controllers/PoliciesControllerTests.cs | 50 ++++++- .../Utilities/PolicyDataValidatorTests.cs | 125 +++++++++++++++++- 5 files changed, 246 insertions(+), 10 deletions(-) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs index b66244ba5f..228d7a26f1 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs @@ -1,11 +1,21 @@ -using System.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; public class MasterPasswordPolicyData : IPolicyDataModel { + /// + /// Minimum password complexity score (0-4). Null indicates no complexity requirement. + /// [JsonPropertyName("minComplexity")] + [Range(0, 4)] public int? MinComplexity { get; set; } + + /// + /// Minimum password length (12-128). Null indicates no minimum length requirement. + /// [JsonPropertyName("minLength")] + [Range(12, 128)] public int? MinLength { get; set; } [JsonPropertyName("requireLower")] public bool? RequireLower { get; set; } diff --git a/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs b/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs index 84e63f2a20..d533ca88cf 100644 --- a/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs +++ b/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; @@ -30,7 +31,8 @@ public static class PolicyDataValidator switch (policyType) { case PolicyType.MasterPassword: - CoreHelpers.LoadClassFromJsonData(json); + var masterPasswordData = CoreHelpers.LoadClassFromJsonData(json); + ValidateModel(masterPasswordData, policyType); break; case PolicyType.SendOptions: CoreHelpers.LoadClassFromJsonData(json); @@ -44,11 +46,24 @@ public static class PolicyDataValidator } catch (JsonException ex) { - var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : ""; + var fieldName = !string.IsNullOrEmpty(ex.Path) ? ex.Path.TrimStart('$', '.') : null; + var fieldInfo = !string.IsNullOrEmpty(fieldName) ? $": {fieldName} has an invalid value" : ""; throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}."); } } + private static void ValidateModel(object model, PolicyType policyType) + { + var validationContext = new ValidationContext(model); + var validationResults = new List(); + + if (!Validator.TryValidateObject(model, validationContext, validationResults, true)) + { + var errors = string.Join(", ", validationResults.Select(r => r.ErrorMessage)); + throw new BadRequestException($"Invalid data for {policyType} policy: {errors}"); + } + } + /// /// Validates and deserializes policy metadata based on the policy type. /// diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs index e4098ce9a9..d58538ae1c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -150,8 +150,8 @@ public class PoliciesControllerTests : IClassFixture, IAs Enabled = true, Data = new Dictionary { - { "minComplexity", 10 }, - { "minLength", 12 }, + { "minComplexity", 4 }, + { "minLength", 128 }, { "requireUpper", true }, { "requireLower", false }, { "requireNumbers", true }, @@ -397,4 +397,48 @@ public class PoliciesControllerTests : IClassFixture, IAs // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinLength_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minLength", 129 } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinComplexity_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 5 } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs index 6144d7eebb..a669bdd93c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs @@ -61,7 +61,8 @@ public class PoliciesControllerTests : IClassFixture, IAs Enabled = true, Data = new Dictionary { - { "minComplexity", 15}, + { "minComplexity", 4}, + { "minLength", 128 }, { "requireLower", true} } }; @@ -78,7 +79,8 @@ public class PoliciesControllerTests : IClassFixture, IAs Assert.IsType(result.Id); Assert.NotEqual(default, result.Id); Assert.NotNull(result.Data); - Assert.Equal(15, ((JsonElement)result.Data["minComplexity"]).GetInt32()); + Assert.Equal(4, ((JsonElement)result.Data["minComplexity"]).GetInt32()); + Assert.Equal(128, ((JsonElement)result.Data["minLength"]).GetInt32()); Assert.True(((JsonElement)result.Data["requireLower"]).GetBoolean()); // Assert against the database values @@ -94,7 +96,7 @@ public class PoliciesControllerTests : IClassFixture, IAs Assert.NotNull(policy.Data); var data = policy.GetDataModel(); - var expectedData = new MasterPasswordPolicyData { MinComplexity = 15, RequireLower = true }; + var expectedData = new MasterPasswordPolicyData { MinComplexity = 4, MinLength = 128, RequireLower = true }; AssertHelper.AssertPropertyEqual(expectedData, data); } @@ -242,4 +244,46 @@ public class PoliciesControllerTests : IClassFixture, IAs // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinLength_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minLength", 129 } + } + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinComplexity_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 5 } + } + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } diff --git a/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs b/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs index 43725d23e0..dcc4ceb246 100644 --- a/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs @@ -19,12 +19,17 @@ public class PolicyDataValidatorTests [Fact] public void ValidateAndSerialize_ValidData_ReturnsSerializedJson() { - var data = new Dictionary { { "minLength", 12 } }; + var data = new Dictionary + { + { "minLength", 12 }, + { "minComplexity", 4 } + }; var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); Assert.NotNull(result); Assert.Contains("\"minLength\":12", result); + Assert.Contains("\"minComplexity\":4", result); } [Fact] @@ -56,4 +61,122 @@ public class PolicyDataValidatorTests Assert.IsType(result); } + + [Fact] + public void ValidateAndSerialize_ExcessiveMinLength_ThrowsBadRequestException() + { + var data = new Dictionary { { "minLength", 129 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_ExcessiveMinComplexity_ThrowsBadRequestException() + { + var data = new Dictionary { { "minComplexity", 5 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_MinLengthAtMinimum_Succeeds() + { + var data = new Dictionary { { "minLength", 12 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minLength\":12", result); + } + + [Fact] + public void ValidateAndSerialize_MinLengthAtMaximum_Succeeds() + { + var data = new Dictionary { { "minLength", 128 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minLength\":128", result); + } + + [Fact] + public void ValidateAndSerialize_MinLengthBelowMinimum_ThrowsBadRequestException() + { + var data = new Dictionary { { "minLength", 11 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_MinComplexityAtMinimum_Succeeds() + { + var data = new Dictionary { { "minComplexity", 0 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minComplexity\":0", result); + } + + [Fact] + public void ValidateAndSerialize_MinComplexityAtMaximum_Succeeds() + { + var data = new Dictionary { { "minComplexity", 4 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minComplexity\":4", result); + } + + [Fact] + public void ValidateAndSerialize_MinComplexityBelowMinimum_ThrowsBadRequestException() + { + var data = new Dictionary { { "minComplexity", -1 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_NullMinLength_Succeeds() + { + var data = new Dictionary + { + { "minComplexity", 2 } + // minLength is omitted, should be null + }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minComplexity\":2", result); + } + + [Fact] + public void ValidateAndSerialize_MultipleInvalidFields_ThrowsBadRequestException() + { + var data = new Dictionary + { + { "minLength", 200 }, + { "minComplexity", 10 } + }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } } From 40e293117d4cf1b5ea3ae461ff3225404eff199a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 26 Jan 2026 16:18:42 +0100 Subject: [PATCH 04/12] PM-2035: PRF Unlock (#6401) * Initial refactor * Add WebauthnPRFOptions to syncResponse * MAYBE: Use KM owned ResponseModel? * REVERT ^- Keep using PrfUnlockOptions for simplicity This reverts commit 5a34e7dfa8f3dfd473b916148078ec0923df22be. * UserDecryptionOptions: Only send one credential * format * Update UserDecryptionOptions.cs * format * Added feature flag (#6600) --- src/Api/Vault/Controllers/SyncController.cs | 9 ++++++++- .../Models/Response/SyncResponseModel.cs | 19 +++++++++++++++++-- .../Api/Response/UserDecryptionOptions.cs | 8 +++++++- src/Core/Constants.cs | 1 + .../Response/UserDecryptionResponseModel.cs | 11 ++++++++++- .../UserDecryptionOptionsBuilder.cs | 8 ++++++-- .../UserDecryptionOptionsBuilderTests.cs | 1 + 7 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 6ac8d06ba0..b186e4b601 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -6,6 +6,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; @@ -44,6 +45,7 @@ public class SyncController : Controller private readonly IFeatureService _featureService; private readonly IApplicationCacheService _applicationCacheService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; private readonly IUserAccountKeysQuery _userAccountKeysQuery; public SyncController( @@ -61,6 +63,7 @@ public class SyncController : Controller IFeatureService featureService, IApplicationCacheService applicationCacheService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IWebAuthnCredentialRepository webAuthnCredentialRepository, IUserAccountKeysQuery userAccountKeysQuery) { _userService = userService; @@ -77,6 +80,7 @@ public class SyncController : Controller _featureService = featureService; _applicationCacheService = applicationCacheService; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _webAuthnCredentialRepository = webAuthnCredentialRepository; _userAccountKeysQuery = userAccountKeysQuery; } @@ -120,6 +124,9 @@ public class SyncController : Controller var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var webAuthnCredentials = _featureService.IsEnabled(FeatureFlagKeys.PM2035PasskeyUnlock) + ? await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id) + : []; UserAccountKeysData userAccountKeys = null; // JIT TDE users and some broken/old users may not have a private key. @@ -130,7 +137,7 @@ public class SyncController : Controller var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, - folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); + folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials); return response; } diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index c965320b94..8f90452c6c 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -6,6 +6,9 @@ using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Api.Response; using Bit.Core.KeyManagement.Models.Data; @@ -39,7 +42,8 @@ public class SyncResponseModel() : ResponseModel("sync") IDictionary> collectionCiphersDict, bool excludeDomains, IEnumerable policies, - IEnumerable sends) + IEnumerable sends, + IEnumerable webAuthnCredentials) : this() { Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails, @@ -57,6 +61,16 @@ public class SyncResponseModel() : ResponseModel("sync") Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List(); Sends = sends.Select(s => new SendResponseModel(s)); + var webAuthnPrfOptions = webAuthnCredentials + .Where(c => c.GetPrfStatus() == WebAuthnPrfStatus.Enabled) + .Select(c => new WebAuthnPrfDecryptionOption( + c.EncryptedPrivateKey, + c.EncryptedUserKey, + c.CredentialId, + [] // transports as empty array + )) + .ToArray(); + UserDecryption = new UserDecryptionResponseModel { MasterPasswordUnlock = user.HasMasterPassword() @@ -72,7 +86,8 @@ public class SyncResponseModel() : ResponseModel("sync") MasterKeyEncryptedUserKey = user.Key!, Salt = user.Email.ToLowerInvariant() } - : null + : null, + WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null }; } diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index aa8a298200..bc22ab1266 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -45,13 +45,19 @@ public class WebAuthnPrfDecryptionOption { public string EncryptedPrivateKey { get; } public string EncryptedUserKey { get; } + public string CredentialId { get; } + public string[] Transports { get; } public WebAuthnPrfDecryptionOption( string encryptedPrivateKey, - string encryptedUserKey) + string encryptedUserKey, + string credentialId, + string[]? transports = null) { EncryptedPrivateKey = encryptedPrivateKey; EncryptedUserKey = encryptedUserKey; + CredentialId = credentialId; + Transports = transports ?? []; } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a480089592..356bbc58b9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -160,6 +160,7 @@ public static class FeatureFlagKeys public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; + public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; diff --git a/src/Core/KeyManagement/Models/Api/Response/UserDecryptionResponseModel.cs b/src/Core/KeyManagement/Models/Api/Response/UserDecryptionResponseModel.cs index 536347cea9..9656c8a68b 100644 --- a/src/Core/KeyManagement/Models/Api/Response/UserDecryptionResponseModel.cs +++ b/src/Core/KeyManagement/Models/Api/Response/UserDecryptionResponseModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.KeyManagement.Models.Api.Response; +using System.Text.Json.Serialization; +using Bit.Core.Auth.Models.Api.Response; + +namespace Bit.Core.KeyManagement.Models.Api.Response; public class UserDecryptionResponseModel { @@ -6,4 +9,10 @@ public class UserDecryptionResponseModel /// Returns the unlock data when the user has a master password that can be used to decrypt their vault. /// public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } + + /// + /// Gets or sets the WebAuthn PRF decryption keys. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public WebAuthnPrfDecryptionOption[]? WebAuthnPrfOptions { get; set; } } diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index 56b4bb0dcf..003e9a032e 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -64,8 +64,12 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder { if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled) { - _options.WebAuthnPrfOption = - new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); + _options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption( + credential.EncryptedPrivateKey, + credential.EncryptedUserKey, + credential.CredentialId, + [] // Stored credentials currently lack Transports, just send an empty array for now + ); } return this; diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs index 01f693bee9..d39da7f3e4 100644 --- a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -60,6 +60,7 @@ public class UserDecryptionOptionsBuilderTests { Assert.NotNull(result.WebAuthnPrfOption); Assert.Equal(credential.EncryptedPrivateKey, result.WebAuthnPrfOption!.EncryptedPrivateKey); + Assert.Equal(credential.CredentialId, result.WebAuthnPrfOption!.CredentialId); Assert.Equal(credential.EncryptedUserKey, result.WebAuthnPrfOption!.EncryptedUserKey); } else From 46a4c09b81b749817a93b69165292dc4d62ba0bd Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 26 Jan 2026 16:36:10 +0100 Subject: [PATCH 05/12] Add desktop-migration-milestone-4 flag (#6881) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 356bbc58b9..56c26ba8c7 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -149,6 +149,7 @@ public static class FeatureFlagKeys public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; public const string DesktopMigrationMilestone2 = "desktop-ui-migration-milestone-2"; public const string DesktopMigrationMilestone3 = "desktop-ui-migration-milestone-3"; + public const string DesktopMigrationMilestone4 = "desktop-ui-migration-milestone-4"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; From afb087161a99549e8dea09afcd75997e59a96d7a Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 26 Jan 2026 15:59:06 +0000 Subject: [PATCH 06/12] Bumped version to 2026.1.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index e7a8422605..c4c7f342fa 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2026.1.0 + 2026.1.1 Bit.$(MSBuildProjectName) enable From 5104ec5f981ddc36a135050fbecdc3597364f0d1 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:46:08 -0600 Subject: [PATCH 07/12] [PM-31040] Add logging to bank account setup process (#6898) * Add logging to bank account setup process * Missed test file constructor --- .../Services/Implementations/StripeEventService.cs | 11 +++++++++++ .../Implementations/SetupIntentDistributedCache.cs | 11 +++++++---- .../Payment/Commands/UpdatePaymentMethodCommand.cs | 2 ++ test/Billing.Test/Services/StripeEventServiceTests.cs | 9 ++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 03ca8eeb10..03865b48fe 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -9,6 +9,7 @@ namespace Bit.Billing.Services.Implementations; public class StripeEventService( GlobalSettings globalSettings, + ILogger logger, IOrganizationRepository organizationRepository, IProviderRepository providerRepository, ISetupIntentCache setupIntentCache, @@ -148,26 +149,36 @@ public class StripeEventService( { var setupIntent = await GetSetupIntent(localStripeEvent); + logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id); + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + + logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id); + if (subscriberId == null) { + logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id); return null; } var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id); if (organization is { GatewayCustomerId: not null }) { var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId); + logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id); return organizationCustomer.Metadata; } var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id); if (provider is not { GatewayCustomerId: not null }) { return null; } var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId); + logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id); return providerCustomer.Metadata; } } diff --git a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs index 8833c928fe..514898e53c 100644 --- a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs +++ b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs @@ -1,11 +1,13 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Bit.Core.Billing.Caches.Implementations; public class SetupIntentDistributedCache( [FromKeyedServices("persistent")] - IDistributedCache distributedCache) : ISetupIntentCache + IDistributedCache distributedCache, + ILogger logger) : ISetupIntentCache { public async Task GetSetupIntentIdForSubscriber(Guid subscriberId) { @@ -17,11 +19,12 @@ public class SetupIntentDistributedCache( { var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId); var value = await distributedCache.GetStringAsync(cacheKey); - if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId)) + if (!string.IsNullOrEmpty(value) && Guid.TryParse(value, out var subscriberId)) { - return null; + return subscriberId; } - return subscriberId; + logger.LogError("Subscriber ID value ({Value}) cached for Setup Intent ({SetupIntentId}) is null or not a valid Guid", value, setupIntentId); + return null; } public async Task RemoveSetupIntentForSubscriber(Guid subscriberId) diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs index a5a9e3e9c9..2166c4318c 100644 --- a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -94,6 +94,8 @@ public class UpdatePaymentMethodCommand( await setupIntentCache.Set(subscriber.Id, setupIntent.Id); + _logger.LogInformation("{Command}: Successfully cached Setup Intent ({SetupIntentId}) for subscriber ({SubscriberID})", CommandName, setupIntent.Id, subscriber.Id); + await UnlinkBraintreeCustomerAsync(customer); return MaskedPaymentMethod.From(setupIntent); diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs index 68aeab2f44..c438ef663c 100644 --- a/test/Billing.Test/Services/StripeEventServiceTests.cs +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Repositories; using Bit.Core.Settings; +using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -28,7 +29,13 @@ public class StripeEventServiceTests _providerRepository = Substitute.For(); _setupIntentCache = Substitute.For(); _stripeFacade = Substitute.For(); - _stripeEventService = new StripeEventService(globalSettings, _organizationRepository, _providerRepository, _setupIntentCache, _stripeFacade); + _stripeEventService = new StripeEventService( + globalSettings, + Substitute.For>(), + _organizationRepository, + _providerRepository, + _setupIntentCache, + _stripeFacade); } #region GetCharge From 2a458807a536ad19005f3d8e2191a7c6618de88c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:04:23 -0600 Subject: [PATCH 08/12] [deps] Vault: Update AngleSharp to 1.4.0 (#5868) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> --- src/Icons/Icons.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj index 97e9562183..9dc39eab1e 100644 --- a/src/Icons/Icons.csproj +++ b/src/Icons/Icons.csproj @@ -9,7 +9,7 @@ - + From 440f5dc0daf569ca12431ebc67ebe5605c031ed9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:36:13 +0100 Subject: [PATCH 09/12] [deps]: Update github/codeql-action action to v4.31.10 (#6906) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3cc279a58..e23711e449 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -270,7 +270,7 @@ jobs: output-format: sarif - name: Upload Grype results to GitHub - uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} From 67f8cbf5b31b11c22ec6385217a93b21ad8b34d4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:37:01 +0100 Subject: [PATCH 10/12] [deps]: Update anchore/scan-action action to v7.2.3 (#6905) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e23711e449..1adb6a8a1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -263,7 +263,7 @@ jobs: - name: Scan Docker image id: container-scan - uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2 + uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 with: image: ${{ steps.image-tags.outputs.primary_tag }} fail-build: false From 898904a673fad374caa71f5e9cedb2798fbc2ee0 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 27 Jan 2026 09:03:06 -0600 Subject: [PATCH 11/12] Renamed for clarity (#6902) --- .../AutomaticUserConfirmationPolicyRequirement.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs index 3430f33a77..9b6cf86257 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs @@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements /// Collection of policy details that apply to this user id public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement { - public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(); + public bool CannotHaveEmergencyAccess() => policyDetails.Any(); public bool CannotJoinProvider() => policyDetails.Any(); From 80eec2df853c111af3455d48769f32624818043f Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 28 Jan 2026 04:11:15 +1000 Subject: [PATCH 12/12] [PM-23768] Public API - add restore and revoke member endpoint (#6859) * Add restore and revoke to public api * Follow naming conventions * Use POST instead of PUT * hello claude * Update test names * Actually fix test names * Add JsonConstructor attr * Fix test --- .../Public/Controllers/MembersController.cs | 67 ++++++++- .../Public/Response/ErrorResponseModel.cs | 13 +- .../Controllers/MembersControllerTests.cs | 134 ++++++++++++++++++ 3 files changed, 206 insertions(+), 8 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 58e5db18c2..220c812cae 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -2,12 +2,16 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Services; using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -30,6 +34,8 @@ public class MembersController : Controller private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2; + private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -42,7 +48,9 @@ public class MembersController : Controller IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IResendOrganizationInviteCommand resendOrganizationInviteCommand) + IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2, + IRestoreOrganizationUserCommand restoreOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -55,6 +63,8 @@ public class MembersController : Controller _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2; + _restoreOrganizationUserCommand = restoreOrganizationUserCommand; } /// @@ -258,4 +268,59 @@ public class MembersController : Controller await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id); return new OkResult(); } + + /// + /// Revoke a member's access to an organization. + /// + /// The ID of the member to be revoked. + [HttpPost("{id}/revoke")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Revoke(Guid id) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + + var request = new RevokeOrganizationUsersRequest( + _currentContext.OrganizationId!.Value, + [id], + new SystemUser(EventSystemUser.PublicApi) + ); + + var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request); + var result = results.Single(); + + return result.Result.Match( + error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)), + _ => new OkResult() + ); + } + + /// + /// Restore a member. + /// + /// + /// Restores a previously revoked member of the organization. + /// + /// The identifier of the member to be restored. + [HttpPost("{id}/restore")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Restore(Guid id) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + + await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi); + + return new OkResult(); + } } diff --git a/src/Api/Models/Public/Response/ErrorResponseModel.cs b/src/Api/Models/Public/Response/ErrorResponseModel.cs index c5bb06d02e..a40b0c9569 100644 --- a/src/Api/Models/Public/Response/ErrorResponseModel.cs +++ b/src/Api/Models/Public/Response/ErrorResponseModel.cs @@ -1,7 +1,5 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Models.Public.Response; @@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel { } public ErrorResponseModel(string errorKey, string errorValue) - : this(errorKey, new string[] { errorValue }) + : this(errorKey, [errorValue]) { } public ErrorResponseModel(string errorKey, IEnumerable errorValues) : this(new Dictionary> { { errorKey, errorValues } }) { } + [JsonConstructor] public ErrorResponseModel(string message, Dictionary> errors) { Message = message; @@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel /// /// The request model is invalid. [Required] - public string Message { get; set; } + public string Message { get; init; } /// /// If multiple errors occurred, they are listed in dictionary. Errors related to a specific /// request parameter will include a dictionary key describing that parameter. /// - public Dictionary> Errors { get; set; } + public Dictionary>? Errors { get; } } diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs index 9f2512038e..e4bdbdb174 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture, IAsy new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true }, orgUser.GetPermissions()); } + + [Fact] + public async Task Revoke_Member_Success() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedUser = await _factory.GetService() + .GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status); + } + + [Fact] + public async Task Revoke_AlreadyRevoked_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Already revoked.", error?.Message); + } + + [Fact] + public async Task Revoke_NotFound_ReturnsNotFound() + { + var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Revoke_DifferentOrganization_ReturnsNotFound() + { + // Create a different organization + var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a user in the other organization + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, otherOrganization.Id, OrganizationUserType.User); + + // Re-authenticate with the original organization + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + + // Try to revoke the user from the other organization + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Restore_Member_Success() + { + // Invite a user to revoke + var email = $"integration-test{Guid.NewGuid()}@example.com"; + var inviteRequest = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.User, + }; + + var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest)); + Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode); + var invitedMember = await inviteResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(invitedMember); + + // Revoke the invited user + var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + // Restore the user + var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify user is restored to Invited state + var updatedUser = await _factory.GetService() + .GetByIdAsync(invitedMember.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status); + } + + [Fact] + public async Task Restore_AlreadyActive_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Already active.", error?.Message); + } + + [Fact] + public async Task Restore_NotFound_ReturnsNotFound() + { + var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Restore_DifferentOrganization_ReturnsNotFound() + { + // Create a different organization + var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a user in the other organization + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, otherOrganization.Id, OrganizationUserType.User); + + // Re-authenticate with the original organization + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + + // Try to restore the user from the other organization + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } }