1
0
mirror of https://github.com/bitwarden/server synced 2026-01-26 14:23:21 +00:00

Merge branch 'main' into PM-30247-Defect-Previously-archived-items-do-not-return-to-Archive-when-importing

This commit is contained in:
John Harrington
2026-01-23 17:01:41 -07:00
committed by GitHub
298 changed files with 23849 additions and 3807 deletions

View File

@@ -6,7 +6,8 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
}
},
"ghcr.io/devcontainers/features/rust:1": {}
},
"mounts": [
{

View File

@@ -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

View File

@@ -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": {

View File

@@ -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

View File

@@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
dotnet_naming_symbols.any_async_methods.required_modifiers = async
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_suffix = Async
dotnet_naming_style.end_in_async.capitalization = pascal_case
dotnet_naming_style.end_in_async.word_separator =
dotnet_naming_style.end_in_async.word_separator =
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
dotnet_diagnostic.CS0618.severity = suggestion
@@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion
# Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005
dotnet_diagnostic.IDE0005.severity = warning
# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304
dotnet_diagnostic.CA1304.severity = warning
# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305
dotnet_diagnostic.CA1305.severity = warning
# CSharp code style settings:
[*.cs]
# Prefer "var" everywhere

View File

@@ -21,12 +21,6 @@
commitMessagePrefix: "[deps] AC:",
reviewers: ["team:team-admin-console-dev"],
},
{
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
description: "Admin & SSO npm packages",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"DuoUniversal",
@@ -182,6 +176,14 @@
matchUpdateTypes: ["minor"],
addLabels: ["hold"],
},
{
groupName: "Admin and SSO npm dependencies",
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
matchUpdateTypes: ["minor", "patch"],
description: "Admin & SSO npm packages",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
groupName: "EntityFrameworkCore",

View File

@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Verify format
run: dotnet format --verify-no-changes
@@ -119,10 +119,10 @@ jobs:
fi
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -294,7 +294,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -420,7 +420,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Print environment
run: |

View File

@@ -49,7 +49,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Restore tools
run: dotnet tool restore
@@ -156,7 +156,7 @@ jobs:
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
- name: Report test results
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -183,7 +183,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Print environment
run: |

View File

@@ -32,7 +32,7 @@ jobs:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Install rust
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
@@ -59,7 +59,7 @@ jobs:
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results

84
.vscode/launch.json vendored
View File

@@ -69,6 +69,28 @@
"preLaunchTask": "buildFullServer",
"stopAll": true
},
{
"name": "Full Server with Seeder API",
"configurations": [
"run-Admin",
"run-API",
"run-Events",
"run-EventsProcessor",
"run-Identity",
"run-Sso",
"run-Icons",
"run-Billing",
"run-Notifications",
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 6
},
"preLaunchTask": "buildFullServerWithSeederApi",
"stopAll": true
},
{
"name": "Self Host: Bit",
"configurations": [
@@ -204,6 +226,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API",
"configurations": [
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "cloud",
},
"preLaunchTask": "buildSeederAPI",
},
{
"name": "Admin Self Host",
"configurations": [
@@ -270,6 +303,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API Self Host",
"configurations": [
"run-SeederAPI-SelfHost"
],
"presentation": {
"hidden": false,
"group": "self-host",
},
"preLaunchTask": "buildSeederAPI",
}
],
"configurations": [
// Configurations represent run-only scenarios so that they can be used in multiple compounds
@@ -311,6 +355,25 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Billing",
"presentation": {
@@ -488,6 +551,27 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI-SelfHost",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5048",
"developSelfHosted": "true",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Admin-SelfHost",
"presentation": {

69
.vscode/tasks.json vendored
View File

@@ -43,6 +43,21 @@
"label": "buildFullServer",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
"buildEventsProcessor",
"buildIdentity",
"buildSso",
"buildIcons",
"buildBilling",
"buildNotifications"
],
},
{
"label": "buildFullServerWithSeederApi",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
@@ -52,6 +67,7 @@
"buildIcons",
"buildBilling",
"buildNotifications",
"buildSeederAPI"
],
},
{
@@ -89,6 +105,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -102,6 +121,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -115,6 +137,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -128,6 +153,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -141,6 +169,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -154,6 +185,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -167,6 +201,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -180,6 +217,29 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "buildSeederAPI",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/util/SeederApi/SeederApi.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -197,6 +257,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -214,6 +277,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -224,6 +290,9 @@
"label": "test",
"type": "shell",
"command": "dotnet test",
"options": {
"cwd": "${workspaceFolder}"
},
"group": {
"kind": "test",
"isDefault": true

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.12.2</Version>
<Version>2026.1.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@@ -13,21 +13,21 @@
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
<XUnitVersion>2.6.6</XUnitVersion>
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<NSubstituteVersion>5.1.0</NSubstituteVersion>
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
</Project>

View File

@@ -137,10 +137,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\Rus
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -353,6 +359,14 @@ Global
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -361,6 +375,10 @@ Global
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -417,8 +435,11 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -44,6 +44,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -23,11 +23,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -0,0 +1,102 @@
using Bit.Sso.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Sso.IdentityServer;
/// <summary>
/// Distributed cache-backed persisted grant store for short-lived grants.
/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
/// and fall back to in-memory caching if Redis is not configured.
/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
/// </summary>
/// <remarks>
/// This is purposefully a different implementation from how Identity solves Persisted Grants.
/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
/// mechanism (cache, database).
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
/// </remarks>
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
{
private readonly IFusionCache _cache;
public DistributedCachePersistedGrantStore(
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
{
_cache = cache;
}
public async Task<PersistedGrant?> GetAsync(string key)
{
var result = await _cache.TryGetAsync<PersistedGrant>(key);
if (!result.HasValue)
{
return null;
}
var grant = result.Value;
// Check if grant has expired - remove expired grants from cache
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
{
await RemoveAsync(key);
return null;
}
return grant;
}
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
{
// Cache stores are key-value based and don't support querying by filter criteria.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(Enumerable.Empty<PersistedGrant>());
}
public Task RemoveAllAsync(PersistedGrantFilter filter)
{
// Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
// authentication cookies and performs federated logout with external IdPs. It does not invoke
// Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
// within 5 minutes, making explicit revocation unnecessary for SSO's security model.
// https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
// Cache stores are key-value based and don't support bulk deletion by filter.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(0);
}
public async Task RemoveAsync(string key)
{
await _cache.RemoveAsync(key);
}
public async Task StoreAsync(PersistedGrant grant)
{
// Calculate TTL based on grant expiration
var duration = grant.Expiration.HasValue
? grant.Expiration.Value - DateTime.UtcNow
: TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
// Ensure positive duration
if (duration <= TimeSpan.Zero)
{
return;
}
// Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
// give us a consistent cache key prefix for these grants.
await _cache.SetAsync(
grant.Key,
grant,
new FusionCacheEntryOptions { Duration = duration });
}
}

View File

@@ -41,6 +41,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -0,0 +1,10 @@
namespace Bit.Sso.Utilities;
public static class PersistedGrantsDistributedCacheConstants
{
/// <summary>
/// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
/// well as the cache key/namespace for grant storage.
/// </summary>
public const string CacheKey = "sso-grants";
}

View File

@@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer;
using Bit.Sso.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Sustainsys.Saml2.AspNetCore2;
@@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions
})
.AddIdentityServerCertificate(env, globalSettings);
// PM-23572
// Register named FusionCache for SSO authorization code grants.
// Provides separation of concerns and automatic Redis/in-memory negotiation
// .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
// and is separate from this keyed service, which only serves grant negotiation.
services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
// Store authorization codes in distributed cache for horizontal scaling
// Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
services.AddSingleton<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
return identityServerBuilder;
}
}

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -749,9 +749,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -792,9 +792,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -813,11 +813,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -834,9 +834,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -988,9 +988,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1022,9 +1022,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1418,13 +1418,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1541,9 +1545,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1874,9 +1878,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2109,9 +2113,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2165,9 +2169,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2217,9 +2221,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2232,21 +2236,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -16,9 +16,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -0,0 +1,257 @@
using Bit.Sso.IdentityServer;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using NSubstitute;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.SSO.Test.IdentityServer;
public class DistributedCachePersistedGrantStoreTests
{
private readonly IFusionCache _cache;
private readonly DistributedCachePersistedGrantStore _sut;
public DistributedCachePersistedGrantStoreTests()
{
_cache = Substitute.For<IFusionCache>();
_sut = new DistributedCachePersistedGrantStore(_cache);
}
[Fact]
public async Task StoreAsync_StoresGrantWithCalculatedTTL()
{
// Arrange
var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"test-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.Duration >= TimeSpan.FromMinutes(4.9) &&
opts.Duration <= TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"no-expiry-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts => opts.Duration == TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
// Act
await _sut.StoreAsync(expiredGrant);
// Assert
await _cache.DidNotReceive().SetAsync(
Arg.Any<string>(),
Arg.Any<PersistedGrant>(),
Arg.Any<FusionCacheEntryOptions?>());
}
[Fact]
public async Task StoreAsync_EnablesDistributedCache()
{
// Arrange
var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"distributed-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.SkipDistributedCache == false &&
opts.SkipDistributedCacheReadWhenStale == false));
}
[Fact]
public async Task GetAsync_WithValidGrant_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
_cache.TryGetAsync<PersistedGrant>("valid-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("valid-key");
// Assert
Assert.NotNull(result);
Assert.Equal("valid-key", result.Key);
Assert.Equal("authorization_code", result.Type);
Assert.Equal("test-subject", result.SubjectId);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
{
// Arrange
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
.Returns(MaybeValue<PersistedGrant>.None);
// Act
var result = await _sut.GetAsync("nonexistent-key");
// Assert
Assert.Null(result);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
_cache.TryGetAsync<PersistedGrant>("expired-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(expiredGrant));
// Act
var result = await _sut.GetAsync("expired-key");
// Assert
Assert.Null(result);
await _cache.Received(1).RemoveAsync("expired-key");
}
[Fact]
public async Task GetAsync_WithNoExpiration_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
_cache.TryGetAsync<PersistedGrant>("no-expiry-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("no-expiry-key");
// Assert
Assert.NotNull(result);
Assert.Equal("no-expiry-key", result.Key);
Assert.Null(result.Expiration);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task RemoveAsync_RemovesGrantFromCache()
{
// Act
await _sut.RemoveAsync("remove-key");
// Assert
await _cache.Received(1).RemoveAsync("remove-key");
}
[Fact]
public async Task GetAllAsync_ReturnsEmptyCollection()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
SessionId = "test-session",
ClientId = "test-client",
Type = "authorization_code"
};
// Act
var result = await _sut.GetAllAsync(filter);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task RemoveAllAsync_CompletesWithoutError()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
ClientId = "test-client"
};
// Act & Assert - should not throw
await _sut.RemoveAllAsync(filter);
// Verify no cache operations were performed
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task StoreAsync_PreservesAllGrantProperties()
{
// Arrange
var grant = new PersistedGrant
{
Key = "full-grant-key",
Type = "authorization_code",
SubjectId = "user-123",
SessionId = "session-456",
ClientId = "client-789",
Description = "Test grant",
CreationTime = DateTime.UtcNow.AddMinutes(-1),
Expiration = DateTime.UtcNow.AddMinutes(5),
ConsumedTime = null,
Data = "{\"test\":\"data\"}"
};
PersistedGrant? capturedGrant = null;
await _cache.SetAsync(
Arg.Any<string>(),
Arg.Do<PersistedGrant>(g => capturedGrant = g),
Arg.Any<FusionCacheEntryOptions?>());
// Act
await _sut.StoreAsync(grant);
// Assert
Assert.NotNull(capturedGrant);
Assert.Equal(grant.Key, capturedGrant.Key);
Assert.Equal(grant.Type, capturedGrant.Type);
Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
Assert.Equal(grant.SessionId, capturedGrant.SessionId);
Assert.Equal(grant.ClientId, capturedGrant.ClientId);
Assert.Equal(grant.Description, capturedGrant.Description);
Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
Assert.Equal(grant.Expiration, capturedGrant.Expiration);
Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
Assert.Equal(grant.Data, capturedGrant.Data);
}
private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
{
return new PersistedGrant
{
Key = key,
Type = "authorization_code",
SubjectId = "test-subject",
ClientId = "test-client",
CreationTime = DateTime.UtcNow,
Expiration = expiration,
Data = "{\"test\":\"data\"}"
};
}
}

View File

@@ -1,35 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

1
dev/.gitignore vendored
View File

@@ -18,3 +18,4 @@ signingkey.jwk
# Reverse Proxy Conifg
reverse-proxy.conf
*.crt

View File

@@ -39,6 +39,14 @@
},
"licenseDirectory": "<full path to license directory>",
"enableNewDeviceVerification": true,
"enableEmailVerification": true
"enableEmailVerification": true,
"communication": {
"bootstrap": "none",
"ssoCookieVendor": {
"idpLoginUrl": "",
"cookieName": "",
"cookieDomain": ""
}
}
}
}

23
dev/setup_secrets.ps1 Normal file → Executable file
View File

@@ -2,7 +2,7 @@
# Helper script for applying the same user secrets to each project
param (
[switch]$clear,
[Parameter(ValueFromRemainingArguments = $true, Position=1)]
[Parameter(ValueFromRemainingArguments = $true, Position = 1)]
$cmdArgs
)
@@ -16,17 +16,18 @@ if ($clear -eq $true) {
}
$projects = @{
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
}
foreach ($key in $projects.keys) {

View File

@@ -5,12 +5,19 @@
Validates that new database migration files follow naming conventions and chronological order.
.DESCRIPTION
This script validates migration files in util/Migrator/DbScripts/ to ensure:
This script validates migration files to ensure:
For SQL migrations in util/Migrator/DbScripts/:
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
2. New migrations are chronologically ordered (filename sorts after existing migrations)
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
4. A 2-digit sequence number is included (e.g., _00, _01)
For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations:
1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs
2. Each migration has both .cs and .Designer.cs files
3. New migrations are chronologically ordered (timestamp sorts after existing migrations)
.PARAMETER BaseRef
The base git reference to compare against (e.g., 'main', 'HEAD~1')
@@ -58,75 +65,288 @@ $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/"
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
$sqlValidationFailed = $false
if ($addedMigrations.Count -eq 0) {
Write-Host "No new migration files added."
exit 0
Write-Host "No new SQL migration files added."
Write-Host ""
}
else {
Write-Host "New SQL migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation."
Write-Host ""
}
else {
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last SQL migration in base reference: $lastBaseMigration"
Write-Host ""
# Required format regex: YYYY-MM-DD_NN_Description.sql
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Validate NEW migration filename format
if ($migrationName -notmatch $formatRegex) {
Write-Host "ERROR: Migration '$migrationName' does not match required format"
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
Write-Host " - YYYY: 4-digit year"
Write-Host " - MM: 2-digit month with leading zero (01-12)"
Write-Host " - DD: 2-digit day with leading zero (01-31)"
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
$sqlValidationFailed = $true
continue
}
# Compare migration name with last base migration (using ordinal string comparison)
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
$sqlValidationFailed = $true
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
}
}
Write-Host ""
}
if ($sqlValidationFailed) {
Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new SQL migration files must:"
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
Write-Host " 4. Have a filename that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 3. Ensure the date is after $lastBaseMigration"
Write-Host ""
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
}
else {
Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order"
}
Write-Host ""
}
Write-Host "New migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
# ===========================================================================================
# Validate Entity Framework Migrations
# ===========================================================================================
Write-Host "==================================================================="
Write-Host "Validating Entity Framework Migrations"
Write-Host "==================================================================="
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous migrations found (initial commit?). Skipping validation."
exit 0
}
$efMigrationPaths = @(
@{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" },
@{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" },
@{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" }
)
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last migration in base reference: $lastBaseMigration"
Write-Host ""
$efValidationFailed = $false
# Required format regex: YYYY-MM-DD_NN_Description.sql
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
foreach ($migrationPathInfo in $efMigrationPaths) {
$efPath = $migrationPathInfo.Path
$dbName = $migrationPathInfo.Name
$validationFailed = $false
Write-Host "-------------------------------------------------------------------"
Write-Host "Checking $dbName EF migrations in $efPath"
Write-Host "-------------------------------------------------------------------"
Write-Host ""
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Get list of migrations from base reference
try {
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
}
catch {
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
# Validate NEW migration filename format
if ($migrationName -notmatch $formatRegex) {
Write-Host "ERROR: Migration '$migrationName' does not match required format"
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
Write-Host " - YYYY: 4-digit year"
Write-Host " - MM: 2-digit month with leading zero (01-12)"
Write-Host " - DD: 2-digit day with leading zero (01-31)"
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
$validationFailed = $true
# Get list of migrations from current reference
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
if ($addedMigrations.Count -eq 0) {
Write-Host "No new $dbName EF migration files added."
Write-Host ""
continue
}
# Compare migration name with last base migration (using ordinal string comparison)
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
$validationFailed = $true
Write-Host "New $dbName EF migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous $dbName migrations found. Skipping chronological validation."
Write-Host ""
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last $dbName migration in base reference: $lastBaseMigration"
Write-Host ""
}
# Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs
$efFormatRegex = '^[0-9]{14}_.+\.cs$'
# Group migrations by base name (without .Designer.cs suffix)
$migrationGroups = @{}
$unmatchedFiles = @()
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Extract base name (remove .Designer.cs or .cs)
if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') {
$baseName = $matches[1]
if (-not $migrationGroups.ContainsKey($baseName)) {
$migrationGroups[$baseName] = @()
}
$migrationGroups[$baseName] += $migrationName
}
else {
# Track files that don't match the expected pattern
$unmatchedFiles += $migrationName
}
}
# Flag any files that don't match the expected pattern
if ($unmatchedFiles.Count -gt 0) {
Write-Host "ERROR: The following migration files do not match the required format:"
foreach ($unmatchedFile in $unmatchedFiles) {
Write-Host " - $unmatchedFile"
}
Write-Host ""
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs"
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
Write-Host " - Description: Descriptive name using PascalCase"
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
Write-Host ""
$efValidationFailed = $true
}
foreach ($baseName in $migrationGroups.Keys | Sort-Object) {
$files = $migrationGroups[$baseName]
# Validate format
$mainFile = "$baseName.cs"
$designerFile = "$baseName.Designer.cs"
if ($mainFile -notmatch $efFormatRegex) {
Write-Host "ERROR: Migration '$mainFile' does not match required format"
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
Write-Host "Example: 20250115120000_AddNewFeature.cs"
$efValidationFailed = $true
continue
}
# Check that both .cs and .Designer.cs files exist
$hasCsFile = $files -contains $mainFile
$hasDesignerFile = $files -contains $designerFile
if (-not $hasCsFile) {
Write-Host "ERROR: Missing main migration file: $mainFile"
$efValidationFailed = $true
}
if (-not $hasDesignerFile) {
Write-Host "ERROR: Missing designer file: $designerFile"
Write-Host "Each EF migration must have both a .cs and .Designer.cs file"
$efValidationFailed = $true
}
if (-not $hasCsFile -or -not $hasDesignerFile) {
continue
}
# Compare migration timestamp with last base migration (using ordinal string comparison)
if ($baseMigrations.Count -gt 0) {
if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'"
$efValidationFailed = $true
}
else {
Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'"
}
}
else {
Write-Host "OK: '$mainFile' (no previous migrations to compare)"
}
}
Write-Host ""
}
if ($efValidationFailed) {
Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new EF migration files must:"
Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " 2. Include both .cs and .Designer.cs files"
Write-Host " 3. Have a timestamp that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in the respective Migrations directory"
Write-Host " 2. Ensure both .cs and .Designer.cs files exist"
Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " 4. Ensure the timestamp is after the last migration"
Write-Host ""
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
}
else {
Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order"
}
Write-Host ""
Write-Host "==================================================================="
Write-Host "Validation Summary"
Write-Host "==================================================================="
if ($sqlValidationFailed -or $efValidationFailed) {
if ($sqlValidationFailed) {
Write-Host "❌ SQL migrations validation FAILED"
}
else {
Write-Host "✓ SQL migrations validation PASSED"
}
if ($efValidationFailed) {
Write-Host "❌ EF migrations validation FAILED"
}
else {
Write-Host "✓ EF migrations validation PASSED"
}
if ($validationFailed) {
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new migration files must:"
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
Write-Host " 4. Have a filename that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 3. Ensure the date is after $lastBaseMigration"
Write-Host ""
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
Write-Host "OVERALL RESULT: FAILED"
exit 1
}
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
exit 0
else {
Write-Host "✓ SQL migrations validation PASSED"
Write-Host "✓ EF migrations validation PASSED"
Write-Host ""
Write-Host "OVERALL RESULT: SUCCESS"
exit 0
}

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Admin' " />

View File

@@ -1,5 +1,5 @@
using Bit.Core.Utilities;
using Microsoft.Data.SqlClient;
using System.Data.Common;
using Bit.Core.Utilities;
namespace Bit.Admin.HostedServices;
@@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
// TODO: Maybe flip a flag somewhere to indicate migration is complete??
break;
}
catch (SqlException e)
catch (DbException e)
{
if (i >= maxMigrationAttempts)
{
@@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
else
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
"Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1);
await Task.Delay(20000, cancellationToken);
}
}

View File

@@ -65,6 +65,7 @@ public class Startup
default:
break;
}
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -20,11 +20,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -18,9 +18,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -750,9 +750,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -793,9 +793,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -814,11 +814,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -835,9 +835,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -989,9 +989,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1023,9 +1023,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1419,13 +1419,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1542,9 +1546,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1875,9 +1879,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2110,9 +2114,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2174,9 +2178,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2226,9 +2230,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2241,21 +2245,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -665,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
}
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{

View File

@@ -2,11 +2,13 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request;
public class OrganizationDomainRequestModel
{
[Required]
[DomainNameValidator]
public string DomainName { get; set; }
}

View File

@@ -4,6 +4,8 @@
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -7,7 +7,7 @@ using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Response;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
using Bit.Core.Jobs;
using Quartz;

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.EmergencyAccess;
using Bit.Core.Jobs;
using Quartz;

View File

@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
}
existingEmergencyAccess.Type = Type;
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
return existingEmergencyAccess;
}
}

View File

@@ -22,7 +22,7 @@ public class AccountsController(
IFeatureService featureService,
ILicensingService licensingService) : Controller
{
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpGet("subscription")]
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
[FromServices] GlobalSettings globalSettings,
@@ -61,7 +61,7 @@ public class AccountsController(
}
}
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("storage")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
@@ -118,7 +118,7 @@ public class AccountsController(
user.IsExpired());
}
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstateAsync()
@@ -131,10 +131,4 @@ public class AccountsController(
await userService.ReinstatePremiumAsync(user);
}
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
return organizationsClaimingUser.Select(o => o.Id);
}
}

View File

@@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@@ -21,9 +23,11 @@ namespace Bit.Api.Billing.Controllers.VNext;
public class AccountBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
@@ -91,10 +95,30 @@ public class AccountBillingVNextController(
return TypedResults.Ok(response);
}
[HttpPut("storage")]
[HttpGet("subscription")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateStorageAsync(
public async Task<IResult> GetSubscriptionAsync(
[BindNever] User user)
{
var subscription = await getBitwardenSubscriptionQuery.Run(user);
return TypedResults.Ok(subscription);
}
[HttpPost("subscription/reinstate")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> ReinstateSubscriptionAsync(
[BindNever] User user)
{
var result = await reinstateSubscriptionCommand.Run(user);
return Handle(result);
}
[HttpPut("subscription/storage")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateSubscriptionStorageAsync(
[BindNever] User user,
[FromBody] StorageUpdateRequest request)
{

View File

@@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject
/// Must be between 0 and the maximum allowed (minus base storage).
/// </summary>
[Required]
[Range(0, 99)]
public short AdditionalStorageGb { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
@@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject
{
yield return new ValidationResult(
"Additional storage cannot be negative.",
new[] { nameof(AdditionalStorageGb) });
[nameof(AdditionalStorageGb)]);
}
if (AdditionalStorageGb > 99)
{
yield return new ValidationResult(
"Maximum additional storage is 99 GB.",
new[] { nameof(AdditionalStorageGb) });
[nameof(AdditionalStorageGb)]);
}
}
}

View File

@@ -10,6 +10,7 @@ using Bit.Core.Utilities;
namespace Bit.Api.Models.Response;
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public class SubscriptionResponseModel : ResponseModel
{

View File

@@ -67,8 +67,9 @@ public class CollectionsController : Controller
{
var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value);
var collectionResponses = collections.Select(c =>
new CollectionResponseModel(c.Item1, c.Item2.Groups));
var collectionResponses = collections
.Where(c => c.Item1.Type != CollectionType.DefaultUserCollection)
.Select(c => new CollectionResponseModel(c.Item1, c.Item2.Groups));
var response = new ListResponseModel<CollectionResponseModel>(collectionResponses);
return new JsonResult(response);

View File

@@ -85,6 +85,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.UserFeatures.SendAccess;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
@@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Tools.Controllers;
[Route("sends")]
[Authorize("Application")]
public class SendsController : Controller
{
private readonly ISendRepository _sendRepository;
@@ -31,11 +32,10 @@ public class SendsController : Controller
private readonly ISendFileStorageService _sendFileStorageService;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
private readonly ISendOwnerQuery _sendOwnerQuery;
private readonly ILogger<SendsController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly IPushNotificationService _pushNotificationService;
public SendsController(
ISendRepository sendRepository,
@@ -46,7 +46,8 @@ public class SendsController : Controller
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
IFeatureService featureService,
IPushNotificationService pushNotificationService)
{
_sendRepository = sendRepository;
_userService = userService;
@@ -56,10 +57,12 @@ public class SendsController : Controller
_sendOwnerQuery = sendOwnerQuery;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
_featureService = featureService;
_pushNotificationService = pushNotificationService;
}
#region Anonymous endpoints
[AllowAnonymous]
[HttpPost("access/{id}")]
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
@@ -73,21 +76,32 @@ public class SendsController : Controller
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var send = await _sendRepository.GetByIdAsync(guid);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}
var sendAuthResult =
await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (sendAuthResult.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
@@ -99,6 +113,7 @@ public class SendsController : Controller
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
return new ObjectResult(sendResponse);
}
@@ -122,6 +137,13 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send");
}
/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
model.Password);
@@ -129,21 +151,19 @@ public class SendsController : Controller
{
return new UnauthorizedResult();
}
if (result.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (result.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}
return new ObjectResult(new SendFileDownloadDataResponseModel()
{
Id = fileId,
Url = url,
});
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
}
[AllowAnonymous]
@@ -157,7 +177,8 @@ public class SendsController : Controller
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var blobName =
eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
@@ -166,6 +187,7 @@ public class SendsController : Controller
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
@@ -173,7 +195,8 @@ public class SendsController : Controller
}
catch (Exception e)
{
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
JsonSerializer.Serialize(eventGridEvent));
return;
}
}
@@ -185,6 +208,7 @@ public class SendsController : Controller
#region Non-anonymous endpoints
[Authorize(Policies.Application)]
[HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id)
{
@@ -193,6 +217,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpGet("")]
public async Task<ListResponseModel<SendResponseModel>> GetAll()
{
@@ -203,6 +228,67 @@ public class SendsController : Controller
return result;
}
[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/")]
public async Task<IActionResult> AccessUsingAuth()
{
var guid = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(guid);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
{
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return new ObjectResult(sendResponse);
}
[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/file/{fileId}")]
public async Task<IActionResult> GetSendFileDownloadDataUsingAuth(string fileId)
{
var sendId = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(sendId);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
}
[Authorize(Policies.Application)]
[HttpPost("")]
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
{
@@ -213,6 +299,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPost("file/v2")]
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
{
@@ -243,6 +330,7 @@ public class SendsController : Controller
};
}
[Authorize(Policies.Application)]
[HttpGet("{id}/file/{fileId}")]
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
{
@@ -267,6 +355,7 @@ public class SendsController : Controller
};
}
[Authorize(Policies.Application)]
[HttpPost("{id}/file/{fileId}")]
[SelfHosted(SelfHostedOnly = true)]
[RequestSizeLimit(Constants.FileSize501mb)]
@@ -283,12 +372,14 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
await Request.GetFileAsync(async (stream) =>
{
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
});
}
[Authorize(Policies.Application)]
[HttpPut("{id}")]
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
{
@@ -304,6 +395,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-password")]
public async Task<SendResponseModel> PutRemovePassword(string id)
{
@@ -322,6 +414,28 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
// Removes ALL authentication (email or password) if any is present
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-auth")]
public async Task<SendResponseModel> PutRemoveAuth(string id)
{
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
}
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
// This allows clients to update other fields without re-submitting sensitive auth data.
send.Password = null;
send.Emails = null;
send.AuthType = AuthType.None;
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpDelete("{id}")]
public async Task Delete(string id)
{

View File

@@ -23,11 +23,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -3,12 +3,13 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Billing</UserSecretsId>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Label="Server SDK settings">
<!-- These features will be gradually turned on -->
<BitIncludeFeatures>false</BitIncludeFeatures>
<BitIncludeTelemetry>false</BitIncludeTelemetry>
<BitIncludeAuthentication>false</BitIncludeAuthentication>
</PropertyGroup>

View File

@@ -1,12 +1,13 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class InvoiceCreatedHandler(
IBraintreeService braintreeService,
ILogger<InvoiceCreatedHandler> logger,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IProviderEventService providerEventService)
: IInvoiceCreatedHandler
{
@@ -29,9 +30,9 @@ public class InvoiceCreatedHandler(
{
try
{
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]);
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId");
if (usingPayPal && invoice is
{
@@ -39,13 +40,12 @@ public class InvoiceCreatedHandler(
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason:
"subscription_create" or
"subscription_cycle" or
"automatic_pending_invoice_item_invoice",
Parent.SubscriptionDetails: not null
Parent.SubscriptionDetails.Subscription: not null
})
{
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice);
}
}
catch (Exception exception)

View File

@@ -275,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
.PreviousAttributes
.ToObject<Subscription>() as Subscription;
// Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the
// previous and/or current subscriptions.
var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans())
.Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null)
.Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId)
.ToHashSet();
// This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
// If there are changes to any subscription item, Stripe sends every item in the subscription, both
// changed and unchanged.
var previousSubscriptionHasSecretsManager =
previousSubscription?.Items is not null &&
previousSubscription.Items.Any(
previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id));
var currentSubscriptionHasSecretsManager =
subscription.Items.Any(
currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id));
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
{

View File

@@ -627,7 +627,7 @@ public class UpcomingInvoiceHandler(
{
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
DiscountAmount = $"{coupon.PercentOff}%",
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
}
};

View File

@@ -48,6 +48,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
UseApi = UseApi,
UseResetPassword = UseResetPassword,
UseSecretsManager = UseSecretsManager,
UsePasswordManager = UsePasswordManager,
SelfHost = SelfHost,
UsersGetPremium = UsersGetPremium,
UseCustomPermissions = UseCustomPermissions,
@@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
UsePhishingBlocker = UsePhishingBlocker,
UseOrganizationDomains = UseOrganizationDomains,
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
};
}
}

View File

@@ -0,0 +1,53 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections;
public static class CollectionUtils
{
/// <summary>
/// Arranges Collection and CollectionUser objects to create default user collections.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="organizationUserIds">The IDs for organization users who need default collections.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns>A tuple containing the collections and collection users.</returns>
public static (ICollection<Collection> collections, ICollection<CollectionUser> collectionUsers)
BuildDefaultUserCollections(Guid organizationId, IEnumerable<Guid> organizationUserIds,
string defaultCollectionName)
{
var now = DateTime.UtcNow;
var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();
foreach (var orgUserId in organizationUserIds)
{
var collectionId = CoreHelpers.GenerateComb();
collections.Add(new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
CreationDate = now,
RevisionDate = now,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null
});
collectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = orgUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true,
});
}
return (collections, collectionUsers);
}
}

View File

@@ -4,9 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -83,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
return;
}
await collectionRepository.CreateAsync(
new Collection
{
OrganizationId = request.Organization!.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
},
groups: null,
[new CollectionAccessSelection
{
Id = request.OrganizationUser!.Id,
Manage = true
}]);
await collectionRepository.CreateDefaultCollectionsAsync(
request.Organization!.Id,
[request.OrganizationUser!.Id],
request.DefaultUserCollectionName);
}
catch (Exception ex)
{

View File

@@ -14,7 +14,6 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -294,21 +293,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
var defaultCollection = new Collection
{
OrganizationId = organizationUser.OrganizationId,
Name = defaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
};
var collectionUser = new CollectionAccessSelection
{
Id = organizationUser.Id,
ReadOnly = false,
HidePasswords = false,
Manage = true
};
await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
await _collectionRepository.CreateDefaultCollectionsAsync(
organizationUser.OrganizationId,
[organizationUser.Id],
defaultUserCollectionName);
}
/// <summary>
@@ -339,7 +327,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
}
/// <summary>

View File

@@ -93,7 +93,7 @@ public class RestoreOrganizationUserCommand(
.twoFactorIsEnabled;
}
if (organization.PlanType == PlanType.Free)
if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue)
{
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
}

View File

@@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand
{
Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId);
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
}

View File

@@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand(
await organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked;
}
public async Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId)
{
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
.ToList();
if (!filteredUsers.Any())
{
throw new BadRequestException("Users invalid.");
}
if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds))
{
throw new BadRequestException("Organization must have at least one confirmed owner.");
}
var deletingUserIsOwner = false;
if (revokingUserId.HasValue)
{
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
}
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
{
try
{
if (organizationUser.Status == OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already revoked.");
}
if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId)
{
throw new BadRequestException("You cannot revoke yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue &&
!deletingUserIsOwner)
{
throw new BadRequestException("Only owners can revoke other owners.");
}
await organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
if (organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(organizationUser, e.Message));
}
}
return result;
}
}

View File

@@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler(
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
ICollection<OrganizationUserUserDetails> organizationUsers)
{
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
organizationUsers.Select(ou => ou.UserId!.Value)))
var userIds = organizationUsers.Where(
u => u.UserId is not null &&
u.Status != OrganizationUserStatusType.Invited)
.Select(u => u.UserId!.Value);
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
.Any(uo => uo.OrganizationId != organizationId
&& uo.Status != OrganizationUserStatusType.Invited);

View File

@@ -57,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator(
var userOrgIds = requirements
.Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId))
.Where(request => request.ShouldCreateDefaultCollection)
.Select(request => request.OrganizationUserId);
.Select(request => request.OrganizationUserId)
.ToList();
if (!userOrgIds.Any())
{
return;
}
await collectionRepository.UpsertDefaultCollectionsAsync(
await collectionRepository.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
userOrgIds,
defaultCollectionName);

View File

@@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId);
/// <summary>
/// Gets the organizations that have a verified domain matching the user's email domain.
/// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user.
/// This requires that the organization has claimed the user's domain and the user is an organization member.
/// It excludes invited members.
/// </summary>
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);

View File

@@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject<Guid>
public string KeyEncrypted { get; set; }
public EmergencyAccessType Type { get; set; }
public EmergencyAccessStatusType Status { get; set; }
public int WaitTimeDays { get; set; }
public short WaitTimeDays { get; set; }
public DateTime? RecoveryInitiatedDate { get; set; }
public DateTime? LastNotificationDate { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;

View File

@@ -4,7 +4,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
@@ -19,7 +18,7 @@ using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories;
using Bit.Core.Vault.Services;
namespace Bit.Core.Auth.Services;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess;
public class EmergencyAccessService : IEmergencyAccessService
{
@@ -61,7 +60,7 @@ public class EmergencyAccessService : IEmergencyAccessService
_removeOrganizationUserCommand = removeOrganizationUserCommand;
}
public async Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
public async Task<Entities.EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime)
{
if (!await _userService.CanAccessPremium(grantorUser))
{
@@ -73,13 +72,13 @@ public class EmergencyAccessService : IEmergencyAccessService
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector.");
}
var emergencyAccess = new EmergencyAccess
var emergencyAccess = new Entities.EmergencyAccess
{
GrantorId = grantorUser.Id,
Email = emergencyContactEmail.ToLowerInvariant(),
Status = EmergencyAccessStatusType.Invited,
Type = accessType,
WaitTimeDays = waitTime,
WaitTimeDays = (short)waitTime,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
@@ -113,7 +112,7 @@ public class EmergencyAccessService : IEmergencyAccessService
await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser));
}
public async Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService)
public async Task<Entities.EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null)
@@ -175,7 +174,7 @@ public class EmergencyAccessService : IEmergencyAccessService
await _emergencyAccessRepository.DeleteAsync(emergencyAccess);
}
public async Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId)
public async Task<Entities.EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted ||
@@ -201,7 +200,7 @@ public class EmergencyAccessService : IEmergencyAccessService
return emergencyAccess;
}
public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser)
public async Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser)
{
if (!await _userService.CanAccessPremium(grantorUser))
{
@@ -311,7 +310,7 @@ public class EmergencyAccessService : IEmergencyAccessService
}
// TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync
public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser)
public async Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
@@ -429,7 +428,7 @@ public class EmergencyAccessService : IEmergencyAccessService
return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId);
}
private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName)
private async Task SendInviteAsync(Entities.EmergencyAccess emergencyAccess, string invitingUsersName)
{
var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours));
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token);
@@ -449,7 +448,7 @@ public class EmergencyAccessService : IEmergencyAccessService
*/
//TODO PM-21687: this IsValidRequest() checks the validity based on the granteeUser. There should be a complementary method for the grantorUser
private static bool IsValidRequest(
EmergencyAccess availableAccess,
Entities.EmergencyAccess availableAccess,
User requestingUser,
EmergencyAccessType requestedAccessType)
{

View File

@@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;
@@ -7,7 +6,7 @@ using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Vault.Models.Data;
namespace Bit.Core.Auth.Services;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess;
public interface IEmergencyAccessService
{
@@ -20,7 +19,7 @@ public interface IEmergencyAccessService
/// <param name="accessType">Type of emergency access allowed to the emergency contact</param>
/// <param name="waitTime">The amount of time to pass before the invite is auto confirmed</param>
/// <returns>a new Emergency Access object</returns>
Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
Task<Entities.EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
/// <summary>
/// Sends an invite to the emergency contact associated with the emergency access id.
/// </summary>
@@ -37,7 +36,7 @@ public interface IEmergencyAccessService
/// <param name="token">the tokenable that was sent via email</param>
/// <param name="userService">service dependency</param>
/// <returns>void</returns>
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
Task<Entities.EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
/// <summary>
/// The creator of the emergency access request can delete the request.
/// </summary>
@@ -53,7 +52,7 @@ public interface IEmergencyAccessService
/// <param name="key">The grantor user key encrypted by the grantee public key; grantee.PubicKey(grantor.User.Key)</param>
/// <param name="grantorId">Id of grantor user</param>
/// <returns>emergency access object associated with the Id passed in</returns>
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
Task<Entities.EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
/// <summary>
/// Fetches an emergency access object. The grantor user must own the object being fetched.
/// </summary>
@@ -67,7 +66,7 @@ public interface IEmergencyAccessService
/// <param name="emergencyAccess">emergency access entity being updated</param>
/// <param name="grantorUser">grantor user</param>
/// <returns>void</returns>
Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser);
Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser);
/// <summary>
/// Initiates the recovery process. For either Takeover or view. Will send an email to the Grantor User notifying of the initiation.
/// </summary>
@@ -107,7 +106,7 @@ public interface IEmergencyAccessService
/// <param name="emergencyAccessId">Id of entity being accessed</param>
/// <param name="granteeUser">grantee user of the emergency access entity</param>
/// <returns>emergency access entity and the grantorUser</returns>
Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser);
/// <summary>
/// Updates the grantor's password hash and updates the key for the EmergencyAccess entity.
/// </summary>

View File

@@ -0,0 +1,14 @@
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
public class EmergencyAccessRemoveGranteesMailView : BaseMailView
{
public required IEnumerable<string> RemovedGranteeNames { get; set; }
public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/";
}
public class EmergencyAccessRemoveGranteesMail : BaseMail<EmergencyAccessRemoveGranteesMailView>
{
public override string Subject { get; set; } = "Emergency contacts removed";
}

View File

@@ -0,0 +1,499 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 0px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
</h1></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="undefined" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 0px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 10px 0px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">The following emergency contacts have been removed from your account:
<ul>
{{#each RemovedGranteeNames}}
<li>{{this}}</li>
{{/each}}
</ul>
Learn more about <a href="{{EmergencyAccessHelpPageUrl}}">emergency access</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
The following emergency contacts have been removed from your account:
{{#each RemovedGranteeNames}}
{{this}}
{{/each}}
Learn more about emergency access at {{EmergencyAccessHelpPageUrl}}

View File

@@ -42,6 +42,7 @@ public static class StripeConstants
public static class ErrorCodes
{
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";
public const string InvoiceUpcomingNone = "invoice_upcoming_none";
public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded";
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
@@ -65,8 +66,10 @@ public static class StripeConstants
public static class MetadataKeys
{
public const string BraintreeCustomerId = "btCustomerId";
public const string BraintreeTransactionId = "btTransactionId";
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string PayPalTransactionId = "btPayPalTransactionId";
public const string PreviousAdditionalStorage = "previous_additional_storage";
public const string PreviousPeriodEndDate = "previous_period_end_date";
public const string PreviousPremiumPriceId = "previous_premium_price_id";

View File

@@ -1,7 +1,11 @@
namespace Bit.Core.Billing.Enums;
using System.Runtime.Serialization;
namespace Bit.Core.Billing.Enums;
public enum PlanCadenceType
{
[EnumMember(Value = "annually")]
Annually,
[EnumMember(Value = "monthly")]
Monthly
}

View File

@@ -0,0 +1,12 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class DiscountExtensions
{
public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem)
=> discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id);
public static bool IsValid(this Discount? discount)
=> discount?.Coupon?.Valid ?? false;
}

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text.RegularExpressions;
using Stripe;
namespace Bit.Core.Billing.Extensions;
@@ -51,7 +52,7 @@ public static class InvoiceExtensions
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
{
var pricePerItem = (line.Amount / 100m) / line.Quantity;
priceInfo = $"(at ${pricePerItem:F2} / month)";
priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem);
}
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
@@ -70,7 +71,7 @@ public static class InvoiceExtensions
if (tax > 0)
{
var taxAmount = tax / 100m;
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount));
}
return items;

View File

@@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Services;
using Bit.Core.Services.Implementations;
namespace Bit.Core.Billing.Extensions;
@@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
services.AddTransient<IGetBitwardenSubscriptionQuery, GetBitwardenSubscriptionQuery>();
services.AddTransient<IReinstateSubscriptionCommand, ReinstateSubscriptionCommand>();
services.AddTransient<IBraintreeService, BraintreeService>();
}
private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)

View File

@@ -1,9 +1,11 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
@@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
}
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
// If the license has a Token (claims-based), extract all properties from claims BEFORE validation
// This ensures that CanUseLicense validation has access to the correct values from claims
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
if (claimsPrincipal != null)
{
license.Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name);
license.BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail);
license.BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName);
license.PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
license.Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats);
license.MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections);
license.UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies);
license.UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso);
license.UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector);
license.UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim);
license.UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups);
license.UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory);
license.UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents);
license.UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp);
license.Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa);
license.UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi);
license.UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword);
license.Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan);
license.SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost);
license.UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium);
license.UseCustomPermissions = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions);
license.Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled);
license.Expires = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires);
license.LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey);
license.UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager);
license.UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager);
license.SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats);
license.SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts);
license.UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights);
license.UseOrganizationDomains = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains);
license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies);
license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
}
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
selfHostedOrganization.CanUseLicense(license, out exception);
@@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
throw new BadRequestException(exception);
}
var useAutomaticUserConfirmation = claimsPrincipal?
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
await WriteLicenseFileAsync(selfHostedOrganization, license);
await UpdateOrganizationAsync(selfHostedOrganization, license);
}

View File

@@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
@@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
public class CreatePremiumCloudHostedSubscriptionCommand(
IBraintreeGateway braintreeGateway,
IBraintreeService braintreeService,
IGlobalSettings globalSettings,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
@@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};
return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
}
@@ -351,14 +354,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
if (usingPayPal)
if (!usingPayPal)
{
await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
});
return subscription;
}
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false,
Expand = ["customer"]
});
await braintreeService.PayInvoice(new UserId(userId), invoice);
return subscription;
}
}

View File

@@ -1,6 +1,8 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Utilities;
@@ -10,6 +12,8 @@ using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
/// <summary>
/// Updates the storage allocation for a premium user's subscription.
/// Handles both increases and decreases in storage in an idempotent manner.
@@ -26,6 +30,7 @@ public interface IUpdatePremiumStorageCommand
}
public class UpdatePremiumStorageCommand(
IBraintreeService braintreeService,
IStripeAdapter stripeAdapter,
IUserService userService,
IPricingClient pricingClient,
@@ -34,19 +39,22 @@ public class UpdatePremiumStorageCommand(
{
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
{
if (!user.Premium)
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
{
return new BadRequest("User does not have a premium subscription.");
}
if (!user.MaxStorageGb.HasValue)
{
return new BadRequest("No access to storage.");
return new BadRequest("User has no access to storage.");
}
// Fetch all premium plans and the user's subscription to find which plan they're on
var premiumPlans = await pricingClient.ListPremiumPlans();
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer"]
});
// Find the password manager subscription item (seat, not storage) and match it to a plan
var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i =>
@@ -54,7 +62,7 @@ public class UpdatePremiumStorageCommand(
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription item not found.");
return new Conflict("Premium subscription does not have a Password Manager line item.");
}
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
@@ -66,20 +74,20 @@ public class UpdatePremiumStorageCommand(
return new BadRequest("Additional storage cannot be negative.");
}
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
var maxStorageGb = (short)(baseStorageGb + additionalStorageGb);
if (newTotalStorageGb > 100)
if (maxStorageGb > 100)
{
return new BadRequest("Maximum storage is 100 GB.");
}
// Idempotency check: if user already has the requested storage, return success
if (user.MaxStorageGb == newTotalStorageGb)
if (user.MaxStorageGb == maxStorageGb)
{
return new None();
}
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
var remainingStorage = user.StorageBytesRemaining(maxStorageGb);
if (remainingStorage < 0)
{
return new BadRequest(
@@ -124,21 +132,46 @@ public class UpdatePremiumStorageCommand(
});
}
// Update subscription with prorations
// Storage is billed annually, so we create prorations and invoice immediately
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = Core.Constants.CreateProrations
};
var usingPayPal = subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
if (usingPayPal)
{
var options = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = ProrationBehavior.CreateProrations
};
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options);
var draftInvoice = await stripeAdapter.CreateInvoiceAsync(new InvoiceCreateOptions
{
Customer = subscription.CustomerId,
Subscription = subscription.Id,
AutoAdvance = false,
CollectionMethod = CollectionMethod.ChargeAutomatically
});
var finalizedInvoice = await stripeAdapter.FinalizeInvoiceAsync(draftInvoice.Id,
new InvoiceFinalizeOptions { AutoAdvance = false, Expand = ["customer"] });
await braintreeService.PayInvoice(new UserId(user.Id), finalizedInvoice);
}
else
{
var options = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = ProrationBehavior.AlwaysInvoice
};
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options);
}
// Update the user's max storage
user.MaxStorageGb = newTotalStorageGb;
user.MaxStorageGb = maxStorageGb;
await userService.SaveUserAsync(user);
// No payment intent needed - the subscription update will automatically create and finalize the invoice
return new None();
});
}

View File

@@ -24,6 +24,7 @@ public interface IStripeAdapter
Task<Subscription> CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null);
Task<Invoice> GetInvoiceAsync(string id, InvoiceGetOptions options);
Task<List<Invoice>> ListInvoicesAsync(StripeInvoiceListOptions options);
Task<Invoice> CreateInvoiceAsync(InvoiceCreateOptions options);
Task<Invoice> CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options);
Task<List<Invoice>> SearchInvoiceAsync(InvoiceSearchOptions options);
Task<Invoice> UpdateInvoiceAsync(string id, InvoiceUpdateOptions options);

View File

@@ -116,6 +116,9 @@ public class StripeAdapter : IStripeAdapter
return invoices;
}
public Task<Invoice> CreateInvoiceAsync(InvoiceCreateOptions options) =>
_invoiceService.CreateAsync(options);
public Task<Invoice> CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options) =>
_invoiceService.CreatePreviewAsync(options);

View File

@@ -0,0 +1,42 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Commands;
using static StripeConstants;
public interface IReinstateSubscriptionCommand
{
Task<BillingCommandResult<None>> Run(ISubscriber subscriber);
}
public class ReinstateSubscriptionCommand(
ILogger<ReinstateSubscriptionCommand> logger,
IStripeAdapter stripeAdapter) : BaseBillingCommand<ReinstateSubscriptionCommand>(logger), IReinstateSubscriptionCommand
{
public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsync<None>(async () =>
{
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
if (subscription is not
{
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
CancelAt: not null
})
{
return new BadRequest("Subscription is not pending cancellation.");
}
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});
return new None();
});
}

View File

@@ -0,0 +1,61 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.Utilities;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Models;
/// <summary>
/// The type of discounts Bitwarden supports.
/// </summary>
public enum BitwardenDiscountType
{
[EnumMember(Value = "amount-off")]
AmountOff,
[EnumMember(Value = "percent-off")]
PercentOff
}
/// <summary>
/// A record representing a discount applied to a Bitwarden subscription.
/// </summary>
public record BitwardenDiscount
{
/// <summary>
/// The type of the discount.
/// </summary>
[JsonConverter(typeof(EnumMemberJsonConverter<BitwardenDiscountType>))]
public required BitwardenDiscountType Type { get; init; }
/// <summary>
/// The value of the discount.
/// </summary>
public required decimal Value { get; init; }
public static implicit operator BitwardenDiscount(Discount? discount)
{
if (discount is not
{
Coupon.Valid: true
})
{
return null!;
}
return discount.Coupon switch
{
{ AmountOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.AmountOff,
Value = discount.Coupon.AmountOff.Value
},
{ PercentOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.PercentOff,
Value = discount.Coupon.PercentOff.Value
},
_ => null!
};
}
}

View File

@@ -0,0 +1,52 @@
namespace Bit.Core.Billing.Subscriptions.Models;
public record BitwardenSubscription
{
/// <summary>
/// The status of the subscription.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// The subscription's cart, including line items, any discounts, and estimated tax.
/// </summary>
public required Cart Cart { get; init; }
/// <summary>
/// The amount of storage available and used for the subscription.
/// <remarks>Allowed Subscribers: User, Organization</remarks>
/// </summary>
public Storage? Storage { get; init; }
/// <summary>
/// If the subscription is pending cancellation, the date at which the
/// subscription will be canceled.
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
/// </summary>
public DateTime? CancelAt { get; init; }
/// <summary>
/// The date the subscription was canceled.
/// <remarks>Allowed Statuses: 'canceled'</remarks>
/// </summary>
public DateTime? Canceled { get; init; }
/// <summary>
/// The date of the next charge for the subscription.
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
/// </summary>
public DateTime? NextCharge { get; init; }
/// <summary>
/// The date the subscription will be or was suspended due to lack of payment.
/// <remarks>Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'</remarks>
/// </summary>
public DateTime? Suspension { get; init; }
/// <summary>
/// The number of days after the subscription goes 'past_due' the subscriber has to resolve their
/// open invoices before the subscription is suspended.
/// <remarks>Allowed Statuses: 'past_due'</remarks>
/// </summary>
public int? GracePeriod { get; init; }
}

View File

@@ -0,0 +1,83 @@
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Billing.Subscriptions.Models;
public record CartItem
{
/// <summary>
/// The client-side translation key for the name of the cart item.
/// </summary>
public required string TranslationKey { get; init; }
/// <summary>
/// The quantity of the cart item.
/// </summary>
public required long Quantity { get; init; }
/// <summary>
/// The unit-cost of the cart item.
/// </summary>
public required decimal Cost { get; init; }
/// <summary>
/// An optional discount applied specifically to this cart item.
/// </summary>
public BitwardenDiscount? Discount { get; init; }
}
public record PasswordManagerCartItems
{
/// <summary>
/// The Password Manager seats in the cart.
/// </summary>
public required CartItem Seats { get; init; }
/// <summary>
/// The additional storage in the cart.
/// </summary>
public CartItem? AdditionalStorage { get; init; }
}
public record SecretsManagerCartItems
{
/// <summary>
/// The Secrets Manager seats in the cart.
/// </summary>
public required CartItem Seats { get; init; }
/// <summary>
/// The additional service accounts in the cart.
/// </summary>
public CartItem? AdditionalServiceAccounts { get; init; }
}
public record Cart
{
/// <summary>
/// The Password Manager items in the cart.
/// </summary>
public required PasswordManagerCartItems PasswordManager { get; init; }
/// <summary>
/// The Secrets Manager items in the cart.
/// </summary>
public SecretsManagerCartItems? SecretsManager { get; init; }
/// <summary>
/// The cart's billing cadence.
/// </summary>
[JsonConverter(typeof(EnumMemberJsonConverter<PlanCadenceType>))]
public PlanCadenceType Cadence { get; init; }
/// <summary>
/// An optional discount applied to the entire cart.
/// </summary>
public BitwardenDiscount? Discount { get; init; }
/// <summary>
/// The estimated tax for the cart.
/// </summary>
public required decimal EstimatedTax { get; init; }
}

View File

@@ -0,0 +1,52 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using OneOf;
namespace Bit.Core.Billing.Subscriptions.Models;
public record Storage
{
private const double _bytesPerGibibyte = 1073741824D;
/// <summary>
/// The amount of storage the subscriber has available.
/// </summary>
public required short Available { get; init; }
/// <summary>
/// The amount of storage the subscriber has used.
/// </summary>
public required double Used { get; init; }
/// <summary>
/// The amount of storage the subscriber has used, formatted as a human-readable string.
/// </summary>
public required string ReadableUsed { get; init; }
public static implicit operator Storage(User user) => From(user);
public static implicit operator Storage(Organization organization) => From(organization);
private static Storage From(OneOf<User, Organization> subscriber)
{
var maxStorageGB = subscriber.Match(
user => user.MaxStorageGb,
organization => organization.MaxStorageGb);
if (maxStorageGB == null)
{
return null!;
}
var storage = subscriber.Match(
user => user.Storage,
organization => organization.Storage);
return new Storage
{
Available = maxStorageGB.Value,
Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2),
ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0)
};
}
}

View File

@@ -0,0 +1,43 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Exceptions;
using OneOf;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Models;
using static StripeConstants;
public record UserId(Guid Value);
public record OrganizationId(Guid Value);
public record ProviderId(Guid Value);
public class SubscriberId : OneOfBase<UserId, OrganizationId, ProviderId>
{
private SubscriberId(OneOf<UserId, OrganizationId, ProviderId> input) : base(input) { }
public static implicit operator SubscriberId(UserId value) => new(value);
public static implicit operator SubscriberId(OrganizationId value) => new(value);
public static implicit operator SubscriberId(ProviderId value) => new(value);
public static implicit operator SubscriberId(Subscription subscription)
{
if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue)
&& Guid.TryParse(userIdValue, out var userId))
{
return new UserId(userId);
}
if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue)
&& Guid.TryParse(organizationIdValue, out var organizationId))
{
return new OrganizationId(organizationId);
}
return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) &&
Guid.TryParse(providerIdValue, out var providerId)
? new ProviderId(providerId)
: throw new ConflictException("Subscription does not have a valid subscriber ID");
}
}

View File

@@ -0,0 +1,201 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Microsoft.Extensions.Logging;
using OneOf;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Queries;
using static StripeConstants;
using static Utilities;
public interface IGetBitwardenSubscriptionQuery
{
/// <summary>
/// Retrieves detailed subscription information for a user, including subscription status,
/// cart items, discounts, and billing details.
/// </summary>
/// <param name="user">The user whose subscription information to retrieve.</param>
/// <returns>
/// A <see cref="BitwardenSubscription"/> containing the subscription details, or null if no
/// subscription is found or the subscription status is not recognized.
/// </returns>
/// <remarks>
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
/// <see cref="ISubscriber"/> types (User and Organization).
/// </remarks>
Task<BitwardenSubscription> Run(User user);
}
public class GetBitwardenSubscriptionQuery(
ILogger<GetBitwardenSubscriptionQuery> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
{
public async Task<BitwardenSubscription> Run(User user)
{
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand =
[
"customer.discount.coupon.applies_to",
"discounts.coupon.applies_to",
"items.data.price.product",
"test_clock"
]
});
var cart = await GetPremiumCartAsync(subscription);
var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user };
switch (subscription.Status)
{
case SubscriptionStatus.Incomplete:
case SubscriptionStatus.IncompleteExpired:
return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 };
case SubscriptionStatus.Trialing:
case SubscriptionStatus.Active:
return baseSubscription with
{
NextCharge = subscription.GetCurrentPeriodEnd(),
CancelAt = subscription.CancelAt
};
case SubscriptionStatus.PastDue:
case SubscriptionStatus.Unpaid:
var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
if (suspension == null)
{
return baseSubscription;
}
return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod };
case SubscriptionStatus.Canceled:
return baseSubscription with { Canceled = subscription.CanceledAt };
default:
{
logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status);
throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance.");
}
}
}
private async Task<Cart> GetPremiumCartAsync(
Subscription subscription)
{
var plans = await pricingClient.ListPremiumPlans();
var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item =>
plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id));
if (passwordManagerSeatsItem == null)
{
throw new ConflictException("Premium subscription does not have a Password Manager line item.");
}
var additionalStorageItem = subscription.Items.FirstOrDefault(item =>
plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id));
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
var passwordManagerSeats = new CartItem
{
TranslationKey = "premiumMembership",
Quantity = passwordManagerSeatsItem.Quantity,
Cost = GetCost(passwordManagerSeatsItem),
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem))
};
var additionalStorage = additionalStorageItem != null
? new CartItem
{
TranslationKey = "additionalStorageGB",
Quantity = additionalStorageItem.Quantity,
Cost = GetCost(additionalStorageItem),
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem))
}
: null;
var estimatedTax = await EstimateTaxAsync(subscription);
return new Cart
{
PasswordManager = new PasswordManagerCartItems
{
Seats = passwordManagerSeats,
AdditionalStorage = additionalStorage
},
Cadence = PlanCadenceType.Annually,
Discount = cartLevelDiscount,
EstimatedTax = estimatedTax
};
}
#region Utilities
private async Task<decimal> EstimateTaxAsync(Subscription subscription)
{
try
{
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions
{
Customer = subscription.Customer.Id,
Subscription = subscription.Id
});
return GetCost(invoice.TotalTaxes);
}
catch (StripeException stripeException) when
(stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone)
{
return 0;
}
}
private static decimal GetCost(OneOf<SubscriptionItem, List<InvoiceTotalTax>> value) =>
value.Match(
item => (item.Price.UnitAmountDecimal ?? 0) / 100M,
taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M);
private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDiscounts(
Subscription subscription)
{
var discounts = new List<Discount>();
if (subscription.Customer.Discount.IsValid())
{
discounts.Add(subscription.Customer.Discount);
}
discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid()));
var cartLevel = new List<Discount>();
var productLevel = new List<Discount>();
foreach (var discount in discounts)
{
switch (discount)
{
case { Coupon.AppliesTo.Products: null or { Count: 0 } }:
cartLevel.Add(discount);
break;
case { Coupon.AppliesTo.Products.Count: > 0 }:
productLevel.Add(discount);
break;
}
}
return (cartLevel.FirstOrDefault(), productLevel);
}
#endregion
}

View File

@@ -142,8 +142,8 @@ public static class FeatureFlagKeys
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
public const string PremiumAccessQuery = "pm-21411-premium-access-query";
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
@@ -163,8 +163,8 @@ public static class FeatureFlagKeys
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";
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
public const string SSHAgent = "ssh-agent";
@@ -174,6 +174,7 @@ public static class FeatureFlagKeys
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string WindowsDesktopAutotype = "windows-desktop-autotype";
public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga";
public const string NotificationUndeterminedCipherScenarioLogic = "undetermined-cipher-scenario-logic";
/* Billing Team */
public const string TrialPayment = "PM-8163-trial-payment";
@@ -202,6 +203,7 @@ public static class FeatureFlagKeys
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
public const string SdkKeyRotation = "pm-30144-sdk-key-rotation";
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
/* Mobile Team */
@@ -229,23 +231,12 @@ public static class FeatureFlagKeys
/// Enable this flag to share the send view used by the web and browser clients
/// on the desktop client.
/// </summary>
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
public const string UseChromiumImporter = "pm-23982-chromium-importer";
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
public const string SendEmailOTP = "pm-19051-send-email-verification";
/// <summary>
/// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When
/// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends.
/// </summary>
/// <remarks>
/// This flag is server-side only, and only inhibits the endpoint returning all sends.
/// Email/OTP sends can still be created and downloaded through other endpoints.
/// </remarks>
public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing";
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
@@ -265,6 +256,7 @@ public static class FeatureFlagKeys
/* DIRT Team */
public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike";
public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging";
public const string EventManagementForHuntress = "event-management-for-huntress";
/* UIF Team */
public const string RouterFocusManagement = "router-focus-management";

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<GenerateUserSecretsAttribute>false</GenerateUserSecretsAttribute>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -44,7 +46,7 @@
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.0" />
<PackageReference Include="Microsoft.Bot.Connector" Version="4.23.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.8.0" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />

View File

@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Entities;
/// <summary>
/// PlayItem is a join table tracking entities created during automated testing.
/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server
/// that any data created should be associated with the play, and therefore cleaned up with it.
/// </summary>
public class PlayItem : ITableObject<Guid>
{
public Guid Id { get; set; }
[MaxLength(256)]
public required string PlayId { get; init; }
public Guid? UserId { get; init; }
public Guid? OrganizationId { get; init; }
public DateTime CreationDate { get; init; }
/// <summary>
/// Generates and sets a new COMB GUID for the Id property.
/// </summary>
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
/// <summary>
/// Creates a new PlayItem record associated with a User.
/// </summary>
/// <param name="user">The user entity created during the play.</param>
/// <param name="playId">The play identifier from the x-play-id header.</param>
/// <returns>A new PlayItem instance tracking the user.</returns>
public static PlayItem Create(User user, string playId)
{
return new PlayItem
{
PlayId = playId,
UserId = user.Id,
CreationDate = DateTime.UtcNow
};
}
/// <summary>
/// Creates a new PlayItem record associated with an Organization.
/// </summary>
/// <param name="organization">The organization entity created during the play.</param>
/// <param name="playId">The play identifier from the x-play-id header.</param>
/// <returns>A new PlayItem instance tracking the organization.</returns>
public static PlayItem Create(Organization organization, string playId)
{
return new PlayItem
{
PlayId = playId,
OrganizationId = organization.Id,
CreationDate = DateTime.UtcNow
};
}
}

View File

@@ -8,8 +8,8 @@
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png"
title="You can now share passwords with members of {{OrganizationName}}!"
button-text="Log in"
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
button-text="<b>Log in</b>"
button-url="{{WebVaultUrl}}"
/>
</mj-wrapper>
@@ -33,7 +33,7 @@
icon-alt="Group Users Icon"
text="You can easily access and share passwords with your team."
foot-url-text="Share passwords in Bitwarden"
foot-url="https://bitwarden.com/help/share-to-a-collection/"
foot-url="https://bitwarden.com/help/sharing"
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>

View File

@@ -8,8 +8,8 @@
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png"
title="You can now share passwords with members of {{OrganizationName}}!"
button-text="Log in"
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
button-text="<b>Log in</b>"
button-url="{{WebVaultUrl}}"
/>
</mj-wrapper>
@@ -33,7 +33,7 @@
icon-alt="Group Users Icon"
text="You can easily share passwords with friends, family, or coworkers."
foot-url-text="Share passwords in Bitwarden"
foot-url="https://bitwarden.com/help/share-to-a-collection/"
foot-url="https://bitwarden.com/help/sharing"
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>

View File

@@ -0,0 +1,31 @@
<mjml>
<mj-head>
<mj-include path="../../../../components/head.mjml" />
</mj-head>
<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 0px 20px">
<mj-bw-hero title=""/>
</mj-wrapper>
<!-- Main Content -->
<mj-wrapper padding="0px 20px 0px 20px">
<mj-section background-color="#fff" padding="0px 10px 0px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
The following emergency contacts have been removed from your account:
<ul>
{{#each RemovedGranteeNames}}
<li>{{this}}</li>
{{/each}}
</ul>
Learn more about <a href="{{EmergencyAccessHelpPageUrl}}">emergency access</a>.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<!-- Footer -->
<mj-include path="../../../../components/footer.mjml" />
</mj-body>
</mjml>

View File

@@ -18,7 +18,7 @@
at {{BaseAnnualRenewalPrice}} + tax.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">

View File

@@ -17,8 +17,8 @@
Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
Questions? Contact

View File

@@ -202,7 +202,7 @@
<tr>
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.</div>
</td>
@@ -271,12 +271,12 @@
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
@@ -364,8 +364,8 @@
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
@@ -381,13 +381,13 @@
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
@@ -404,13 +404,13 @@
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
@@ -427,13 +427,13 @@
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
@@ -450,13 +450,13 @@
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
@@ -473,13 +473,13 @@
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
@@ -496,13 +496,13 @@
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
@@ -519,13 +519,13 @@
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
@@ -546,15 +546,15 @@
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
</p></div>
</td>

View File

@@ -1,7 +1,7 @@
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually
at {{BaseAnnualRenewalPrice}} + tax.
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
Questions? Contact support@bitwarden.com

View File

@@ -5,7 +5,7 @@ namespace Bit.Core.Models.Mail.Billing.Renewal.Premium;
public class PremiumRenewalMailView : BaseMailView
{
public required string BaseMonthlyRenewalPrice { get; set; }
public required string DiscountedMonthlyRenewalPrice { get; set; }
public required string DiscountedAnnualRenewalPrice { get; set; }
public required string DiscountAmount { get; set; }
}

Some files were not shown because too many files have changed in this diff Show More