Merge branch 'main' into billing/PM-29599/upgrade-proration

This commit is contained in:
Kyle Denney
2026-01-30 11:37:36 -06:00
235 changed files with 19036 additions and 2083 deletions

View File

@@ -11,3 +11,7 @@ checkmarx:
filter: "!test" filter: "!test"
kics: kics:
filter: "!dev,!.devcontainer" filter: "!dev,!.devcontainer"
sca:
filter: "!dev,!.devcontainer"
containers:
filter: "!dev,!.devcontainer"

View File

@@ -3,10 +3,12 @@
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml", "dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
"service": "bitwarden_server", "service": "bitwarden_server",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:1": {
"version": "16" "version": "22"
} },
"ghcr.io/devcontainers/features/rust:1": {}
}, },
"mounts": [ "mounts": [
{ {
@@ -21,5 +23,27 @@
"extensions": ["ms-dotnettools.csdevkit"] "extensions": ["ms-dotnettools.csdevkit"]
} }
}, },
"postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh" "postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh",
"forwardPorts": [1080, 1433, 3306, 5432],
"portsAttributes": {
"default": {
"onAutoForward": "ignore"
},
"1080": {
"label": "Mail Catcher",
"onAutoForward": "notify"
},
"1433": {
"label": "SQL Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "notify"
}
}
} }

View File

@@ -3,11 +3,46 @@ export DEV_DIR=/workspace/dev
export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev
git config --global --add safe.directory /workspace 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() { get_installation_id_and_key() {
pushd ./dev >/dev/null || exit pushd ./dev >/dev/null || exit
echo "Please enter your installation id and key from https://bitwarden.com/host:" echo "Please enter your installation id and key from https://bitwarden.com/host:"
read -r -p "Installation id: " INSTALLATION_ID INSTALLATION_ID="$(get_option "INSTALLATION_ID" "Installation id: " "00000000-0000-0000-0000-000000000001")"
read -r -p "Installation key: " INSTALLATION_KEY INSTALLATION_KEY="$(get_option "INSTALLATION_KEY" "Installation key: " "" 1)"
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" | jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \ .globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
secrets.json.example >secrets.json # create/overwrite secrets.json secrets.json.example >secrets.json # create/overwrite secrets.json
@@ -30,11 +65,10 @@ configure_other_vars() {
} }
one_time_setup() { one_time_setup() {
read -r -p \ do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like to configure your secrets and certificates for the first time?
"Would you like to configure your secrets and certificates for the first time?
WARNING: This will overwrite any existing secrets.json and certificate files. WARNING: This will overwrite any existing secrets.json and certificate files.
Proceed? [y/N] " response Proceed? [y/N] " "n")"
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "Running one-time setup script..." echo "Running one-time setup script..."
sleep 1 sleep 1
get_installation_id_and_key get_installation_id_and_key
@@ -50,11 +84,4 @@ Proceed? [y/N] " response
fi fi
} }
# main one_time_setup
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

View File

@@ -6,10 +6,12 @@
], ],
"service": "bitwarden_server", "service": "bitwarden_server",
"workspaceFolder": "/workspace", "workspaceFolder": "/workspace",
"initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:1": {
"version": "16" "version": "22"
} },
"ghcr.io/devcontainers/features/rust:1": {}
}, },
"mounts": [ "mounts": [
{ {
@@ -24,9 +26,18 @@
"extensions": ["ms-dotnettools.csdevkit"] "extensions": ["ms-dotnettools.csdevkit"]
} }
}, },
"onCreateCommand": "bash .devcontainer/internal_dev/onCreateCommand.sh",
"postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh", "postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh",
"forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002], "forwardPorts": [
1080, 1433, 3306, 5432, 10000, 10001, 10002,
4000, 4001, 33656, 33657, 44519, 44559,
46273, 46274, 50024, 51822, 51823,
54103, 61840, 61841, 62911, 62912
],
"portsAttributes": { "portsAttributes": {
"default": {
"onAutoForward": "ignore"
},
"1080": { "1080": {
"label": "Mail Catcher", "label": "Mail Catcher",
"onAutoForward": "notify" "onAutoForward": "notify"
@@ -48,12 +59,76 @@
"onAutoForward": "notify" "onAutoForward": "notify"
}, },
"10001": { "10001": {
"label": "Azurite Storage Queue ", "label": "Azurite Storage Queue",
"onAutoForward": "notify" "onAutoForward": "notify"
}, },
"10002": { "10002": {
"label": "Azurite Storage Table", "label": "Azurite Storage Table",
"onAutoForward": "notify" "onAutoForward": "notify"
},
"4000": {
"label": "Api (Cloud)",
"onAutoForward": "notify"
},
"4001": {
"label": "Api (SelfHost)",
"onAutoForward": "notify"
},
"33656": {
"label": "Identity (Cloud)",
"onAutoForward": "notify"
},
"33657": {
"label": "Identity (SelfHost)",
"onAutoForward": "notify"
},
"44519": {
"label": "Billing",
"onAutoForward": "notify"
},
"44559": {
"label": "Scim",
"onAutoForward": "notify"
},
"46273": {
"label": "Events (Cloud)",
"onAutoForward": "notify"
},
"46274": {
"label": "Events (SelfHost)",
"onAutoForward": "notify"
},
"50024": {
"label": "Icons",
"onAutoForward": "notify"
},
"51822": {
"label": "Sso (Cloud)",
"onAutoForward": "notify"
},
"51823": {
"label": "Sso (SelfHost)",
"onAutoForward": "notify"
},
"54103": {
"label": "EventsProcessor",
"onAutoForward": "notify"
},
"61840": {
"label": "Notifications (Cloud)",
"onAutoForward": "notify"
},
"61841": {
"label": "Notifications (SelfHost)",
"onAutoForward": "notify"
},
"62911": {
"label": "Admin (Cloud)",
"onAutoForward": "notify"
},
"62912": {
"label": "Admin (SelfHost)",
"onAutoForward": "notify"
} }
} }
} }

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

@@ -108,7 +108,7 @@ Press <Enter> to continue."
fi fi
run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")" run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")"
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ "$run_mssql_migrations" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "Running migrations..." echo "Running migrations..."
sleep 5 # wait for DB container to start sleep 5 # wait for DB container to start
dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING" dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING"

7
.github/CODEOWNERS vendored
View File

@@ -11,6 +11,9 @@
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre **/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
# Scanning tools
.checkmarx/ @bitwarden/team-appsec
## BRE team owns these workflows ## ## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre .github/workflows/publish.yml @bitwarden/dept-bre
@@ -94,9 +97,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
.github/workflows/test-database.yml @bitwarden/team-platform-dev .github/workflows/test-database.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev
**/*Platform* @bitwarden/team-platform-dev **/*Platform* @bitwarden/team-platform-dev
**/.dockerignore @bitwarden/team-platform-dev
**/Dockerfile @bitwarden/team-platform-dev
**/entrypoint.sh @bitwarden/team-platform-dev
# The PushType enum is expected to be editted by anyone without need for Platform review # The PushType enum is expected to be editted by anyone without need for Platform review
src/Core/Platform/Push/PushType.cs src/Core/Platform/Push/PushType.cs

View File

@@ -21,12 +21,6 @@
commitMessagePrefix: "[deps] AC:", commitMessagePrefix: "[deps] AC:",
reviewers: ["team:team-admin-console-dev"], 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: [ matchPackageNames: [
"DuoUniversal", "DuoUniversal",
@@ -182,6 +176,14 @@
matchUpdateTypes: ["minor"], matchUpdateTypes: ["minor"],
addLabels: ["hold"], 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/"], matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
groupName: "EntityFrameworkCore", groupName: "EntityFrameworkCore",

View File

@@ -31,7 +31,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
@@ -119,10 +119,10 @@ jobs:
fi fi
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
@@ -245,7 +245,7 @@ jobs:
- name: Install Cosign - name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Sign image with Cosign - name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -263,14 +263,14 @@ jobs:
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2 uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
@@ -294,7 +294,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up .NET - 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 - name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main uses: bitwarden/gh-actions/azure-login@main
@@ -420,7 +420,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

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

View File

@@ -32,7 +32,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Install rust - name: Install rust
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable 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" 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 - 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() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2026.1.0</Version> <Version>2026.1.1</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

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

View File

@@ -2,7 +2,7 @@
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models; using Bit.Core.Auth.Models;
@@ -45,7 +45,7 @@ public class AccountController : Controller
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository; private readonly ISsoUserRepository _ssoUserRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly II18nService _i18nService; private readonly II18nService _i18nService;
private readonly UserManager<User> _userManager; private readonly UserManager<User> _userManager;
@@ -67,7 +67,7 @@ public class AccountController : Controller
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository, ISsoUserRepository ssoUserRepository,
IUserRepository userRepository, IUserRepository userRepository,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IUserService userService, IUserService userService,
II18nService i18nService, II18nService i18nService,
UserManager<User> userManager, UserManager<User> userManager,
@@ -88,7 +88,7 @@ public class AccountController : Controller
_userRepository = userRepository; _userRepository = userRepository;
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_ssoUserRepository = ssoUserRepository; _ssoUserRepository = ssoUserRepository;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_userService = userService; _userService = userService;
_i18nService = i18nService; _i18nService = i18nService;
_userManager = userManager; _userManager = userManager;
@@ -687,9 +687,8 @@ public class AccountController : Controller
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy = var twoFactorPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.TwoFactorAuthentication);
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication); if (twoFactorPolicy.Enabled)
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{ {
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider> newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{ {

View File

@@ -8,7 +8,6 @@ using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using Microsoft.IdentityModel.Logging;
using Stripe; using Stripe;
namespace Bit.Sso; namespace Bit.Sso;
@@ -91,20 +90,15 @@ public class Startup
public void Configure( public void Configure(
IApplicationBuilder app, IApplicationBuilder app,
IWebHostEnvironment env, IWebHostEnvironment environment,
IHostApplicationLifetime appLifetime, IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILogger<Startup> logger) ILogger<Startup> logger)
{ {
if (env.IsDevelopment() || globalSettings.SelfHosted)
{
IdentityModelEventSource.ShowPII = true;
}
// Add general security headers // Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>(); app.UseMiddleware<SecurityHeadersMiddleware>();
if (!env.IsDevelopment()) if (!environment.IsDevelopment())
{ {
var uri = new Uri(globalSettings.BaseServiceUri.Sso); var uri = new Uri(globalSettings.BaseServiceUri.Sso);
app.Use(async (ctx, next) => app.Use(async (ctx, next) =>
@@ -120,7 +114,7 @@ public class Startup
app.UseForwardedHeaders(globalSettings); app.UseForwardedHeaders(globalSettings);
} }
if (env.IsDevelopment()) if (environment.IsDevelopment())
{ {
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
app.UseCookiePolicy(); app.UseCookiePolicy();

View File

@@ -34,4 +34,5 @@ RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123
# SETUP_AZURITE=yes # SETUP_AZURITE=yes
# RUN_MSSQL_MIGRATIONS=yes # RUN_MSSQL_MIGRATIONS=yes
# DEV_CERT_PASSWORD=dev_cert_password_here # DEV_CERT_PASSWORD=dev_cert_password_here
# DEV_CERT_CONTENTS=base64_encoded_dev_pfx_here (alternative to placing dev.pfx file manually)
# INSTALL_STRIPE_CLI=no # INSTALL_STRIPE_CLI=no

1
dev/.gitignore vendored
View File

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

View File

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

View File

@@ -28,6 +28,7 @@ $projects = @{
Scim = "../bitwarden_license/src/Scim" Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest" IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi" SeederApi = "../util/SeederApi"
SeederUtility = "../util/DbSeederUtility"
} }
foreach ($key in $projects.keys) { foreach ($key in $projects.keys) {

View File

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

View File

@@ -57,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly ICollectionRepository _collectionRepository; private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository; private readonly IGroupRepository _groupRepository;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
@@ -90,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
ICollectionRepository collectionRepository, ICollectionRepository collectionRepository,
IGroupRepository groupRepository, IGroupRepository groupRepository,
IUserService userService, IUserService userService,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
ICurrentContext currentContext, ICurrentContext currentContext,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
@@ -123,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
_collectionRepository = collectionRepository; _collectionRepository = collectionRepository;
_groupRepository = groupRepository; _groupRepository = groupRepository;
_userService = userService; _userService = userService;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_currentContext = currentContext; _currentContext = currentContext;
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
@@ -287,14 +287,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
var userId = _userService.GetProperUserId(User); var userId = _userService.GetProperUserId(User);
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result; IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)) result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
{
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
}
else
{
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
}
return new ListResponseModel<OrganizationUserBulkResponseModel>( return new ListResponseModel<OrganizationUserBulkResponseModel>(
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
@@ -357,10 +350,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
return false; return false;
} }
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy != null && var useMasterPasswordPolicy = masterPasswordPolicy.Enabled &&
masterPasswordPolicy.Enabled && masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
return useMasterPasswordPolicy; return useMasterPasswordPolicy;
} }
@@ -697,7 +689,16 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task RestoreAsync(Guid orgId, Guid id) public async Task RestoreAsync(Guid orgId, Guid id)
{ {
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, null));
}
[HttpPut("{id}/restore/vnext")]
[Authorize<ManageUsersRequirement>]
[RequireFeature(FeatureFlagKeys.DefaultUserCollectionRestore)]
public async Task RestoreAsync_vNext(Guid orgId, Guid id, [FromBody] OrganizationUserRestoreRequest request)
{
await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, request.DefaultUserCollectionName));
} }
[HttpPatch("{id}/restore")] [HttpPatch("{id}/restore")]
@@ -712,7 +713,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); return await RestoreOrRevokeUsersAsync(orgId, model,
(orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds,
restoringUserId, _userService, model.DefaultUserCollectionName));
} }
[HttpPatch("restore")] [HttpPatch("restore")]

View File

@@ -48,7 +48,7 @@ public class OrganizationsController : Controller
{ {
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
@@ -74,7 +74,7 @@ public class OrganizationsController : Controller
public OrganizationsController( public OrganizationsController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IOrganizationService organizationService, IOrganizationService organizationService,
IUserService userService, IUserService userService,
ICurrentContext currentContext, ICurrentContext currentContext,
@@ -99,7 +99,7 @@ public class OrganizationsController : Controller
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_organizationService = organizationService; _organizationService = organizationService;
_userService = userService; _userService = userService;
_currentContext = currentContext; _currentContext = currentContext;
@@ -183,15 +183,14 @@ public class OrganizationsController : Controller
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id)); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
} }
var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null) if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
{ {
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
} }
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
} }
[HttpPost("")] [HttpPost("")]

View File

@@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@@ -43,6 +42,7 @@ public class PoliciesController : Controller
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly ISavePolicyCommand _savePolicyCommand; private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
private readonly IPolicyQuery _policyQuery;
public PoliciesController(IPolicyRepository policyRepository, public PoliciesController(IPolicyRepository policyRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@@ -54,7 +54,8 @@ public class PoliciesController : Controller
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ISavePolicyCommand savePolicyCommand, ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand) IVNextSavePolicyCommand vNextSavePolicyCommand,
IPolicyQuery policyQuery)
{ {
_policyRepository = policyRepository; _policyRepository = policyRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@@ -68,27 +69,24 @@ public class PoliciesController : Controller
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_savePolicyCommand = savePolicyCommand; _savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand;
_policyQuery = policyQuery;
} }
[HttpGet("{type}")] [HttpGet("{type}")]
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type) public async Task<PolicyStatusResponseModel> Get(Guid orgId, PolicyType type)
{ {
if (!await _currentContext.ManagePolicies(orgId)) if (!await _currentContext.ManagePolicies(orgId))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null)
{
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
}
var policy = await _policyQuery.RunAsync(orgId, type);
if (policy.Type is PolicyType.SingleOrg) if (policy.Type is PolicyType.SingleOrg)
{ {
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery); return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery);
} }
return new PolicyDetailResponseModel(policy); return new PolicyStatusResponseModel(policy);
} }
[HttpGet("")] [HttpGet("")]

View File

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

View File

@@ -116,12 +116,17 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
public string ResetPasswordKey { get; set; } public string ResetPasswordKey { get; set; }
public string MasterPasswordHash { get; set; } public string MasterPasswordHash { get; set; }
} }
#nullable enable
public class OrganizationUserBulkRequestModel public class OrganizationUserBulkRequestModel
{ {
[Required, MinLength(1)] [Required, MinLength(1)]
public IEnumerable<Guid> Ids { get; set; } public IEnumerable<Guid> Ids { get; set; } = new List<Guid>();
[EncryptedString]
[EncryptedStringLength(1000)]
public string? DefaultUserCollectionName { get; set; }
} }
#nullable disable
public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel
{ {

View File

@@ -0,0 +1,13 @@
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationUserRestoreRequest
{
/// <summary>
/// This is the encrypted default collection name to be used for restored users if required
/// </summary>
[EncryptedString]
[EncryptedStringLength(1000)]
public string? DefaultUserCollectionName { get; set; }
}

View File

@@ -1,19 +1,21 @@
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
namespace Bit.Api.AdminConsole.Models.Response.Helpers; namespace Bit.Api.AdminConsole.Models.Response.Helpers;
public static class PolicyDetailResponses public static class PolicyStatusResponses
{ {
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery) public static async Task<PolicyStatusResponseModel> GetSingleOrgPolicyStatusResponseAsync(
this PolicyStatus policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
{ {
if (policy.Type is not PolicyType.SingleOrg) if (policy.Type is not PolicyType.SingleOrg)
{ {
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy)); throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
} }
return new PolicyDetailResponseModel(policy, await CanToggleState());
return new PolicyStatusResponseModel(policy, await CanToggleState());
async Task<bool> CanToggleState() async Task<bool> CanToggleState()
{ {
@@ -25,5 +27,4 @@ public static class PolicyDetailResponses
return !policy.Enabled; return !policy.Enabled;
} }
} }
} }

View File

@@ -1,20 +0,0 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyDetailResponseModel : PolicyResponseModel
{
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
{
}
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
{
CanToggleState = canToggleState;
}
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; set; } = true;
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyStatusResponseModel : ResponseModel
{
public PolicyStatusResponseModel(PolicyStatus policy, bool canToggleState = true) : base("policy")
{
OrganizationId = policy.OrganizationId;
Type = policy.Type;
if (!string.IsNullOrWhiteSpace(policy.Data))
{
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data) ?? new();
}
Enabled = policy.Enabled;
CanToggleState = canToggleState;
}
public Guid OrganizationId { get; init; }
public PolicyType Type { get; init; }
public Dictionary<string, object> Data { get; init; } = new();
public bool Enabled { get; init; }
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; init; }
}

View File

@@ -2,12 +2,16 @@
using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response; using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -30,6 +34,8 @@ public class MembersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public MembersController( public MembersController(
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@@ -42,7 +48,9 @@ public class MembersController : Controller
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand) IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository; _groupRepository = groupRepository;
@@ -55,6 +63,8 @@ public class MembersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand; _resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
} }
/// <summary> /// <summary>
@@ -258,4 +268,59 @@ public class MembersController : Controller
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id); await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
return new OkResult(); return new OkResult();
} }
/// <summary>
/// Revoke a member's access to an organization.
/// </summary>
/// <param name="id">The ID of the member to be revoked.</param>
[HttpPost("{id}/revoke")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Revoke(Guid id)
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
{
return new NotFoundResult();
}
var request = new RevokeOrganizationUsersRequest(
_currentContext.OrganizationId!.Value,
[id],
new SystemUser(EventSystemUser.PublicApi)
);
var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request);
var result = results.Single();
return result.Result.Match<IActionResult>(
error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
_ => new OkResult()
);
}
/// <summary>
/// Restore a member.
/// </summary>
/// <remarks>
/// Restores a previously revoked member of the organization.
/// </remarks>
/// <param name="id">The identifier of the member to be restored.</param>
[HttpPost("{id}/restore")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Restore(Guid id)
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
{
return new NotFoundResult();
}
await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi);
return new OkResult();
}
} }

View File

@@ -7,7 +7,7 @@ using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Response; using Bit.Api.Auth.Models.Response;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.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.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ using Bit.Api.Models.Response;
using Bit.Api.Models.Response.Organizations; using Bit.Api.Models.Response.Organizations;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand; private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
public OrganizationSponsorshipsController( public OrganizationSponsorshipsController(
@@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand, ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
IUserService userService, IUserService userService,
ICurrentContext currentContext, ICurrentContext currentContext,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IFeatureService featureService) IFeatureService featureService)
{ {
_organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationSponsorshipRepository = organizationSponsorshipRepository;
@@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller
_syncSponsorshipsCommand = syncSponsorshipsCommand; _syncSponsorshipsCommand = syncSponsorshipsCommand;
_userService = userService; _userService = userService;
_currentContext = currentContext; _currentContext = currentContext;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_featureService = featureService; _featureService = featureService;
} }
@@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{ {
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId); var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy); PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true) if (freeFamiliesSponsorshipPolicy.Enabled)
{ {
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
} }
@@ -108,10 +108,10 @@ public class OrganizationSponsorshipsController : Controller
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName) public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName)
{ {
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy); PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true) if (freeFamiliesSponsorshipPolicy.Enabled)
{ {
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
} }
@@ -138,9 +138,9 @@ public class OrganizationSponsorshipsController : Controller
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email); var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && sponsorship.SponsoringOrganizationId.HasValue) if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
{ {
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value, var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy); PolicyType.FreeFamiliesSponsorshipPolicy);
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false; isFreeFamilyPolicyEnabled = policy.Enabled;
} }
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled); var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
@@ -165,10 +165,10 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Can only redeem sponsorship for an organization you own."); throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
} }
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync( var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy); model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true) if (freeFamiliesSponsorshipPolicy.Enabled)
{ {
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
} }

View File

@@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator<IEnumerable<
} }
// Exclude any account recovery that do not have a key. // Exclude any account recovery that do not have a key.
existing = existing.Where(o => o.ResetPasswordKey != null).ToList(); existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList();
foreach (var ou in existing) foreach (var ou in existing)
{ {

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below using System.ComponentModel.DataAnnotations;
#nullable disable using System.Text.Json.Serialization;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Models.Public.Response; namespace Bit.Api.Models.Public.Response;
@@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel
{ } { }
public ErrorResponseModel(string errorKey, string errorValue) public ErrorResponseModel(string errorKey, string errorValue)
: this(errorKey, new string[] { errorValue }) : this(errorKey, [errorValue])
{ } { }
public ErrorResponseModel(string errorKey, IEnumerable<string> errorValues) public ErrorResponseModel(string errorKey, IEnumerable<string> errorValues)
: this(new Dictionary<string, IEnumerable<string>> { { errorKey, errorValues } }) : this(new Dictionary<string, IEnumerable<string>> { { errorKey, errorValues } })
{ } { }
[JsonConstructor]
public ErrorResponseModel(string message, Dictionary<string, IEnumerable<string>> errors) public ErrorResponseModel(string message, Dictionary<string, IEnumerable<string>> errors)
{ {
Message = message; Message = message;
@@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
/// </summary> /// </summary>
/// <example>The request model is invalid.</example> /// <example>The request model is invalid.</example>
[Required] [Required]
public string Message { get; set; } public string Message { get; init; }
/// <summary> /// <summary>
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific /// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
/// request parameter will include a dictionary key describing that parameter. /// request parameter will include a dictionary key describing that parameter.
/// </summary> /// </summary>
public Dictionary<string, IEnumerable<string>> Errors { get; set; } public Dictionary<string, IEnumerable<string>>? Errors { get; }
} }

View File

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

View File

@@ -14,7 +14,6 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Request;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.SharedWeb.Health; using Bit.SharedWeb.Health;
using Microsoft.IdentityModel.Logging;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
@@ -238,8 +237,6 @@ public class Startup
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILogger<Startup> logger) ILogger<Startup> logger)
{ {
IdentityModelEventSource.ShowPII = true;
// Add general security headers // Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>(); app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -239,12 +239,6 @@ public class SendsController : Controller
{ {
throw new BadRequestException("Could not locate send"); 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); var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault()) if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
@@ -272,12 +266,6 @@ public class SendsController : Controller
{ {
throw new BadRequestException("Could not locate send"); 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); var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);

View File

@@ -102,9 +102,17 @@ public class SendRequestModel
/// Comma-separated list of emails that may access the send using OTP /// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>. /// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary> /// </summary>
[StringLength(4000)] [EncryptedString]
[EncryptedStringLength(4000)]
public string Emails { get; set; } public string Emails { get; set; }
/// <summary>
/// Comma-separated list of email **hashes** that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[StringLength(4000)]
public string EmailHashes { get; set; }
/// <summary> /// <summary>
/// When <see langword="true"/>, send access is disabled. /// When <see langword="true"/>, send access is disabled.
/// Defaults to <see langword="false"/>. /// Defaults to <see langword="false"/>.
@@ -253,6 +261,7 @@ public class SendRequestModel
// normalize encoding // normalize encoding
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries); var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
existingSend.Emails = string.Join(",", emails); existingSend.Emails = string.Join(",", emails);
existingSend.EmailHashes = EmailHashes;
existingSend.Password = null; existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Email; existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
} }

View File

@@ -6,6 +6,7 @@ using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
@@ -44,6 +45,7 @@ public class SyncController : Controller
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly IUserAccountKeysQuery _userAccountKeysQuery;
public SyncController( public SyncController(
@@ -61,6 +63,7 @@ public class SyncController : Controller
IFeatureService featureService, IFeatureService featureService,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IWebAuthnCredentialRepository webAuthnCredentialRepository,
IUserAccountKeysQuery userAccountKeysQuery) IUserAccountKeysQuery userAccountKeysQuery)
{ {
_userService = userService; _userService = userService;
@@ -77,6 +80,7 @@ public class SyncController : Controller
_featureService = featureService; _featureService = featureService;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
_userAccountKeysQuery = userAccountKeysQuery; _userAccountKeysQuery = userAccountKeysQuery;
} }
@@ -120,6 +124,9 @@ public class SyncController : Controller
var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id); var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id);
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var webAuthnCredentials = _featureService.IsEnabled(FeatureFlagKeys.PM2035PasskeyUnlock)
? await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id)
: [];
UserAccountKeysData userAccountKeys = null; UserAccountKeysData userAccountKeys = null;
// JIT TDE users and some broken/old users may not have a private key. // JIT TDE users and some broken/old users may not have a private key.
@@ -130,7 +137,7 @@ public class SyncController : Controller
var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities,
organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails,
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials);
return response; return response;
} }

View File

@@ -6,6 +6,9 @@ using Bit.Api.Models.Response;
using Bit.Api.Tools.Models.Response; using Bit.Api.Tools.Models.Response;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Api.Response; using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Models.Data;
@@ -39,7 +42,8 @@ public class SyncResponseModel() : ResponseModel("sync")
IDictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersDict, IDictionary<Guid, IGrouping<Guid, CollectionCipher>> collectionCiphersDict,
bool excludeDomains, bool excludeDomains,
IEnumerable<Policy> policies, IEnumerable<Policy> policies,
IEnumerable<Send> sends) IEnumerable<Send> sends,
IEnumerable<WebAuthnCredential> webAuthnCredentials)
: this() : this()
{ {
Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails, Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails,
@@ -57,6 +61,16 @@ public class SyncResponseModel() : ResponseModel("sync")
Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>(); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
Sends = sends.Select(s => new SendResponseModel(s)); Sends = sends.Select(s => new SendResponseModel(s));
var webAuthnPrfOptions = webAuthnCredentials
.Where(c => c.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
.Select(c => new WebAuthnPrfDecryptionOption(
c.EncryptedPrivateKey,
c.EncryptedUserKey,
c.CredentialId,
[] // transports as empty array
))
.ToArray();
UserDecryption = new UserDecryptionResponseModel UserDecryption = new UserDecryptionResponseModel
{ {
MasterPasswordUnlock = user.HasMasterPassword() MasterPasswordUnlock = user.HasMasterPassword()
@@ -72,7 +86,8 @@ public class SyncResponseModel() : ResponseModel("sync")
MasterKeyEncryptedUserKey = user.Key!, MasterKeyEncryptedUserKey = user.Key!,
Salt = user.Email.ToLowerInvariant() Salt = user.Email.ToLowerInvariant()
} }
: null : null,
WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null
}; };
} }

View File

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

View File

@@ -9,6 +9,7 @@ namespace Bit.Billing.Services.Implementations;
public class StripeEventService( public class StripeEventService(
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILogger<StripeEventService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IProviderRepository providerRepository, IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
@@ -148,26 +149,36 @@ public class StripeEventService(
{ {
var setupIntent = await GetSetupIntent(localStripeEvent); var setupIntent = await GetSetupIntent(localStripeEvent);
logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id);
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id);
if (subscriberId == null) if (subscriberId == null)
{ {
logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id);
return null; return null;
} }
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id);
if (organization is { GatewayCustomerId: not null }) if (organization is { GatewayCustomerId: not null })
{ {
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId); var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id);
return organizationCustomer.Metadata; return organizationCustomer.Metadata;
} }
var provider = await providerRepository.GetByIdAsync(subscriberId.Value); var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id);
if (provider is not { GatewayCustomerId: not null }) if (provider is not { GatewayCustomerId: not null })
{ {
return null; return null;
} }
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId); var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id);
return providerCustomer.Metadata; return providerCustomer.Metadata;
} }
} }

View File

@@ -275,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
.PreviousAttributes .PreviousAttributes
.ToObject<Subscription>() as Subscription; .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. // 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 // If there are changes to any subscription item, Stripe sends every item in the subscription, both
// changed and unchanged. // changed and unchanged.
var previousSubscriptionHasSecretsManager = var previousSubscriptionHasSecretsManager =
previousSubscription?.Items is not null && previousSubscription?.Items is not null &&
previousSubscription.Items.Any( previousSubscription.Items.Any(
previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id));
var currentSubscriptionHasSecretsManager = var currentSubscriptionHasSecretsManager =
subscription.Items.Any( subscription.Items.Any(
currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id));
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager) if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
{ {

View File

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

View File

@@ -1,11 +1,21 @@
using System.Text.Json.Serialization; using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
public class MasterPasswordPolicyData : IPolicyDataModel public class MasterPasswordPolicyData : IPolicyDataModel
{ {
/// <summary>
/// Minimum password complexity score (0-4). Null indicates no complexity requirement.
/// </summary>
[JsonPropertyName("minComplexity")] [JsonPropertyName("minComplexity")]
[Range(0, 4)]
public int? MinComplexity { get; set; } public int? MinComplexity { get; set; }
/// <summary>
/// Minimum password length (12-128). Null indicates no minimum length requirement.
/// </summary>
[JsonPropertyName("minLength")] [JsonPropertyName("minLength")]
[Range(12, 128)]
public int? MinLength { get; set; } public int? MinLength { get; set; }
[JsonPropertyName("requireLower")] [JsonPropertyName("requireLower")]
public bool? RequireLower { get; set; } public bool? RequireLower { get; set; }

View File

@@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
public class PolicyStatus
{
public PolicyStatus(Guid organizationId, PolicyType policyType, Policy? policy = null)
{
OrganizationId = policy?.OrganizationId ?? organizationId;
Data = policy?.Data;
Type = policy?.Type ?? policyType;
Enabled = policy?.Enabled ?? false;
}
public Guid OrganizationId { get; set; }
public PolicyType Type { get; set; }
public bool Enabled { get; set; }
public string? Data { get; set; }
public T GetDataModel<T>() where T : IPolicyDataModel, new()
{
return CoreHelpers.LoadClassFromJsonData<T>(Data);
}
}

View File

@@ -1,5 +1,5 @@
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity;
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository, public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IUserRepository userRepository, IUserRepository userRepository,
IMailService mailService, IMailService mailService,
IEventService eventService, IEventService eventService,
@@ -30,9 +30,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo
} }
// Enterprise policy must be enabled // Enterprise policy must be enabled
var resetPasswordPolicy = var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); if (!resetPasswordPolicy.Enabled)
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
{ {
throw new BadRequestException("Organization does not have the password reset policy enabled."); throw new BadRequestException("Organization does not have the password reset policy enabled.");
} }

View File

@@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
@@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
IUserService userService, IUserService userService,
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator
{ {
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync( public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request) AutomaticallyConfirmOrganizationUserValidationRequest request)
@@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
} }
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) => private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true } (await policyQuery.RunAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation)).Enabled
&& request.Organization is { UseAutomaticUserConfirmation: true }; && request.Organization is { UseAutomaticUserConfirmation: true };
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)

View File

@@ -4,7 +4,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Models.Business; using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
public class SendOrganizationInvitesCommand( public class SendOrganizationInvitesCommand(
IUserRepository userRepository, IUserRepository userRepository,
ISsoConfigRepository ssoConfigurationRepository, ISsoConfigRepository ssoConfigurationRepository,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory, IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService) : ISendOrganizationInvitesCommand IMailService mailService) : ISendOrganizationInvitesCommand
@@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand(
// need to check the policy if the org has SSO enabled. // need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies && organization.UsePolicies &&
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true; (await policyQuery.RunAsync(organization.Id, PolicyType.RequireSso)).Enabled;
// Generate the list of org users and expiring tokens // Generate the list of org users and expiring tokens
// create helper function to create expiring tokens // create helper function to create expiring tokens

View File

@@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand
/// </summary> /// </summary>
/// <param name="organizationUser">Revoked user to be restored.</param> /// <param name="organizationUser">Revoked user to be restored.</param>
/// <param name="restoringUserId">UserId of the user performing the action.</param> /// <param name="restoringUserId">UserId of the user performing the action.</param>
Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId); Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string? defaultCollectionName);
/// <summary> /// <summary>
/// Validates that the requesting user can perform the action. There is also a check done to ensure the organization /// Validates that the requesting user can perform the action. There is also a check done to ensure the organization
@@ -50,5 +50,5 @@ public interface IRestoreOrganizationUserCommand
/// <param name="userService">Passed in from caller to avoid circular dependency</param> /// <param name="userService">Passed in from caller to avoid circular dependency</param>
/// <returns>List of organization user Ids and strings. A successful restoration will have an empty string. /// <returns>List of organization user Ids and strings. A successful restoration will have an empty string.
/// If an error occurs, the error message will be provided.</returns> /// If an error occurs, the error message will be provided.</returns>
Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService); Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService, string? defaultCollectionName);
} }

View File

@@ -31,9 +31,10 @@ public class RestoreOrganizationUserCommand(
IOrganizationService organizationService, IOrganizationService organizationService,
IFeatureService featureService, IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand
{ {
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName)
{ {
if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value) if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value)
{ {
@@ -46,7 +47,7 @@ public class RestoreOrganizationUserCommand(
throw new BadRequestException("Only owners can restore other owners."); throw new BadRequestException("Only owners can restore other owners.");
} }
await RepositoryRestoreUserAsync(organizationUser); await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue) if (organizationUser.UserId.HasValue)
@@ -57,7 +58,7 @@ public class RestoreOrganizationUserCommand(
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser) public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
{ {
await RepositoryRestoreUserAsync(organizationUser); await RepositoryRestoreUserAsync(organizationUser, null); // users stored by a system user will not get a default collection at this point.
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored,
systemUser); systemUser);
@@ -67,7 +68,7 @@ public class RestoreOrganizationUserCommand(
} }
} }
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser) private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName)
{ {
if (organizationUser.Status != OrganizationUserStatusType.Revoked) if (organizationUser.Status != OrganizationUserStatusType.Revoked)
{ {
@@ -93,7 +94,7 @@ public class RestoreOrganizationUserCommand(
.twoFactorIsEnabled; .twoFactorIsEnabled;
} }
if (organization.PlanType == PlanType.Free) if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue)
{ {
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser); await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
} }
@@ -104,7 +105,17 @@ public class RestoreOrganizationUserCommand(
await organizationUserRepository.RestoreAsync(organizationUser.Id, status); await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status; if (organizationUser.UserId.HasValue
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(organizationUser.UserId
.Value)).State == OrganizationDataOwnershipState.Enabled
&& status == OrganizationUserStatusType.Confirmed
&& featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore)
&& !string.IsNullOrWhiteSpace(defaultCollectionName))
{
await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId,
[organizationUser.Id],
defaultCollectionName);
}
} }
private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser) private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
@@ -156,7 +167,8 @@ public class RestoreOrganizationUserCommand(
} }
public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId, public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService) IEnumerable<Guid> organizationUserIds, Guid? restoringUserId, IUserService userService,
string defaultCollectionName)
{ {
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
@@ -187,6 +199,9 @@ public class RestoreOrganizationUserCommand(
var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers); var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers);
var result = new List<Tuple<OrganizationUser, string>>(); var result = new List<Tuple<OrganizationUser, string>>();
var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId))
.ToList();
foreach (var organizationUser in filteredUsers) foreach (var organizationUser in filteredUsers)
{ {
@@ -223,13 +238,24 @@ public class RestoreOrganizationUserCommand(
var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser); var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser);
await organizationUserRepository.RestoreAsync(organizationUser.Id, status); await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue) if (organizationUser.UserId.HasValue)
{ {
if (organizationUsersDataOwnershipEnabled.Contains(organizationUser.Id)
&& status == OrganizationUserStatusType.Confirmed
&& !string.IsNullOrWhiteSpace(defaultCollectionName)
&& featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore))
{
await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId,
[organizationUser.Id],
defaultCollectionName);
}
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
} }
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
result.Add(Tuple.Create(organizationUser, "")); result.Add(Tuple.Create(organizationUser, ""));
} }
catch (BadRequestException e) catch (BadRequestException e)

View File

@@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
public interface IPolicyQuery
{
/// <summary>
/// Retrieves a summary view of an organization's usage of a policy specified by the <paramref name="policyType"/>.
/// </summary>
/// <remarks>
/// This query is the entrypoint for consumers interested in understanding how a particular <see cref="PolicyType"/>
/// has been applied to an organization; the resultant <see cref="PolicyStatus"/> is not indicative of explicit
/// policy configuration.
/// </remarks>
Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType);
}

View File

@@ -0,0 +1,14 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
public class PolicyQuery(IPolicyRepository policyRepository) : IPolicyQuery
{
public async Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType)
{
var dbPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, policyType);
return new PolicyStatus(organizationId, policyType, dbPolicy);
}
}

View File

@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
/// <param name="policyDetails">Collection of policy details that apply to this user id</param> /// <param name="policyDetails">Collection of policy details that apply to this user id</param>
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
{ {
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(); public bool CannotHaveEmergencyAccess() => policyDetails.Any();
public bool CannotJoinProvider() => policyDetails.Any(); public bool CannotJoinProvider() => policyDetails.Any();

View File

@@ -18,6 +18,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>(); services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>(); services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>(); services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
services.AddScoped<IPolicyQuery, PolicyQuery>();
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>(); services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
services.AddPolicyValidators(); services.AddPolicyValidators();

View File

@@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId); Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId);
/// <summary> /// <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> /// </summary>
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId); Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);

View File

@@ -27,7 +27,6 @@ public interface IOrganizationService
OrganizationUserInvite invite, string externalId); OrganizationUserInvite invite, string externalId);
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); IEnumerable<(OrganizationUserInvite invite, string externalId)> invites);
Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable<Guid> organizationUsersId);
Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId);
Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId);
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);

View File

@@ -14,7 +14,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
@@ -49,7 +48,7 @@ public class OrganizationService : IOrganizationService
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IStripePaymentService _paymentService; private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly ISsoUserRepository _ssoUserRepository; private readonly ISsoUserRepository _ssoUserRepository;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
@@ -76,7 +75,7 @@ public class OrganizationService : IOrganizationService
IEventService eventService, IEventService eventService,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IStripePaymentService paymentService, IStripePaymentService paymentService,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IPolicyService policyService, IPolicyService policyService,
ISsoUserRepository ssoUserRepository, ISsoUserRepository ssoUserRepository,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
@@ -103,7 +102,7 @@ public class OrganizationService : IOrganizationService
_eventService = eventService; _eventService = eventService;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_paymentService = paymentService; _paymentService = paymentService;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_policyService = policyService; _policyService = policyService;
_ssoUserRepository = ssoUserRepository; _ssoUserRepository = ssoUserRepository;
_globalSettings = globalSettings; _globalSettings = globalSettings;
@@ -718,32 +717,6 @@ public class OrganizationService : IOrganizationService
return (allOrgUsers, events); return (allOrgUsers, events);
} }
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> ResendInvitesAsync(Guid organizationId,
Guid? invitingUserId,
IEnumerable<Guid> organizationUsersId)
{
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId);
_logger.LogUserInviteStateDiagnostics(orgUsers);
var org = await GetOrgById(organizationId);
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var orgUser in orgUsers)
{
if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId)
{
result.Add(Tuple.Create(orgUser, "User invalid."));
continue;
}
await SendInviteAsync(orgUser, org, false);
result.Add(Tuple.Create(orgUser, ""));
}
return result;
}
private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) => private async Task SendInvitesAsync(IEnumerable<OrganizationUser> orgUsers, Organization organization) =>
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization)); await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
@@ -862,9 +835,8 @@ public class OrganizationService : IOrganizationService
} }
// Make sure the organization has the policy enabled // Make sure the organization has the policy enabled
var resetPasswordPolicy = var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword);
await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword); if (!resetPasswordPolicy.Enabled)
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
{ {
throw new BadRequestException("Organization does not have the password reset policy enabled."); throw new BadRequestException("Organization does not have the password reset policy enabled.");
} }

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
@@ -30,7 +31,8 @@ public static class PolicyDataValidator
switch (policyType) switch (policyType)
{ {
case PolicyType.MasterPassword: case PolicyType.MasterPassword:
CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json); var masterPasswordData = CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
ValidateModel(masterPasswordData, policyType);
break; break;
case PolicyType.SendOptions: case PolicyType.SendOptions:
CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(json); CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(json);
@@ -44,11 +46,24 @@ public static class PolicyDataValidator
} }
catch (JsonException ex) catch (JsonException ex)
{ {
var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : ""; var fieldName = !string.IsNullOrEmpty(ex.Path) ? ex.Path.TrimStart('$', '.') : null;
var fieldInfo = !string.IsNullOrEmpty(fieldName) ? $": {fieldName} has an invalid value" : "";
throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}."); throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}.");
} }
} }
private static void ValidateModel(object model, PolicyType policyType)
{
var validationContext = new ValidationContext(model);
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(model, validationContext, validationResults, true))
{
var errors = string.Join(", ", validationResults.Select(r => r.ErrorMessage));
throw new BadRequestException($"Invalid data for {policyType} policy: {errors}");
}
}
/// <summary> /// <summary>
/// Validates and deserializes policy metadata based on the policy type. /// Validates and deserializes policy metadata based on the policy type.
/// </summary> /// </summary>

View File

@@ -45,13 +45,19 @@ public class WebAuthnPrfDecryptionOption
{ {
public string EncryptedPrivateKey { get; } public string EncryptedPrivateKey { get; }
public string EncryptedUserKey { get; } public string EncryptedUserKey { get; }
public string CredentialId { get; }
public string[] Transports { get; }
public WebAuthnPrfDecryptionOption( public WebAuthnPrfDecryptionOption(
string encryptedPrivateKey, string encryptedPrivateKey,
string encryptedUserKey) string encryptedUserKey,
string credentialId,
string[]? transports = null)
{ {
EncryptedPrivateKey = encryptedPrivateKey; EncryptedPrivateKey = encryptedPrivateKey;
EncryptedUserKey = encryptedUserKey; EncryptedUserKey = encryptedUserKey;
CredentialId = credentialId;
Transports = transports ?? [];
} }
} }

View File

@@ -5,9 +5,9 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
@@ -21,7 +21,7 @@ namespace Bit.Core.Auth.Services;
public class SsoConfigService : ISsoConfigService public class SsoConfigService : ISsoConfigService
{ {
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IEventService _eventService; private readonly IEventService _eventService;
@@ -29,14 +29,14 @@ public class SsoConfigService : ISsoConfigService
public SsoConfigService( public SsoConfigService(
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IEventService eventService, IEventService eventService,
IVNextSavePolicyCommand vNextSavePolicyCommand) IVNextSavePolicyCommand vNextSavePolicyCommand)
{ {
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_eventService = eventService; _eventService = eventService;
@@ -114,14 +114,14 @@ public class SsoConfigService : ISsoConfigService
throw new BadRequestException("Organization cannot use Key Connector."); throw new BadRequestException("Organization cannot use Key Connector.");
} }
var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg); var singleOrgPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true }) if (!singleOrgPolicy.Enabled)
{ {
throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled."); throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled.");
} }
var ssoPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso); var ssoPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.RequireSso);
if (ssoPolicy is not { Enabled: true }) if (!ssoPolicy.Enabled)
{ {
throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled."); throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled.");
} }

View File

@@ -4,7 +4,6 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data; 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.Repositories;
using Bit.Core.Vault.Services; using Bit.Core.Vault.Services;
namespace Bit.Core.Auth.Services; namespace Bit.Core.Auth.UserFeatures.EmergencyAccess;
public class EmergencyAccessService : IEmergencyAccessService public class EmergencyAccessService : IEmergencyAccessService
{ {
@@ -61,7 +60,7 @@ public class EmergencyAccessService : IEmergencyAccessService
_removeOrganizationUserCommand = removeOrganizationUserCommand; _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)) if (!await _userService.CanAccessPremium(grantorUser))
{ {
@@ -73,7 +72,7 @@ public class EmergencyAccessService : IEmergencyAccessService
throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); 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, GrantorId = grantorUser.Id,
Email = emergencyContactEmail.ToLowerInvariant(), Email = emergencyContactEmail.ToLowerInvariant(),
@@ -113,7 +112,7 @@ public class EmergencyAccessService : IEmergencyAccessService
await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); 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); var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null) if (emergencyAccess == null)
@@ -175,7 +174,7 @@ public class EmergencyAccessService : IEmergencyAccessService
await _emergencyAccessRepository.DeleteAsync(emergencyAccess); 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); var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted || if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted ||
@@ -201,7 +200,7 @@ public class EmergencyAccessService : IEmergencyAccessService
return emergencyAccess; return emergencyAccess;
} }
public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser) public async Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser)
{ {
if (!await _userService.CanAccessPremium(grantorUser)) if (!await _userService.CanAccessPremium(grantorUser))
{ {
@@ -311,7 +310,7 @@ public class EmergencyAccessService : IEmergencyAccessService
} }
// TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync // 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); var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
@@ -429,7 +428,7 @@ public class EmergencyAccessService : IEmergencyAccessService
return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); 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)); var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours));
await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); 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 //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( private static bool IsValidRequest(
EmergencyAccess availableAccess, Entities.EmergencyAccess availableAccess,
User requestingUser, User requestingUser,
EmergencyAccessType requestedAccessType) EmergencyAccessType requestedAccessType)
{ {

View File

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

@@ -1,6 +1,6 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models; using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
@@ -27,7 +27,7 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
@@ -50,7 +50,7 @@ public class RegisterUserCommand : IRegisterUserCommand
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IOrganizationDomainRepository organizationDomainRepository, IOrganizationDomainRepository organizationDomainRepository,
IFeatureService featureService, IFeatureService featureService,
IDataProtectionProvider dataProtectionProvider, IDataProtectionProvider dataProtectionProvider,
@@ -65,7 +65,7 @@ public class RegisterUserCommand : IRegisterUserCommand
_globalSettings = globalSettings; _globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
_featureService = featureService; _featureService = featureService;
@@ -246,9 +246,9 @@ public class RegisterUserCommand : IRegisterUserCommand
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
if (orgUser != null) if (orgUser != null)
{ {
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, var twoFactorPolicy = await _policyQuery.RunAsync(orgUser.OrganizationId,
PolicyType.TwoFactorAuthentication); PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled) if (twoFactorPolicy.Enabled)
{ {
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider> user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{ {

View File

@@ -1,11 +1,13 @@
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Caches.Implementations; namespace Bit.Core.Billing.Caches.Implementations;
public class SetupIntentDistributedCache( public class SetupIntentDistributedCache(
[FromKeyedServices("persistent")] [FromKeyedServices("persistent")]
IDistributedCache distributedCache) : ISetupIntentCache IDistributedCache distributedCache,
ILogger<SetupIntentDistributedCache> logger) : ISetupIntentCache
{ {
public async Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId) public async Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId)
{ {
@@ -17,11 +19,12 @@ public class SetupIntentDistributedCache(
{ {
var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId); var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
var value = await distributedCache.GetStringAsync(cacheKey); var value = await distributedCache.GetStringAsync(cacheKey);
if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId)) if (!string.IsNullOrEmpty(value) && Guid.TryParse(value, out var subscriberId))
{ {
return null; return subscriberId;
} }
return subscriberId; logger.LogError("Subscriber ID value ({Value}) cached for Setup Intent ({SetupIntentId}) is null or not a valid Guid", value, setupIntentId);
return null;
} }
public async Task RemoveSetupIntentForSubscriber(Guid subscriberId) public async Task RemoveSetupIntentForSubscriber(Guid subscriberId)

View File

@@ -94,6 +94,8 @@ public class UpdatePaymentMethodCommand(
await setupIntentCache.Set(subscriber.Id, setupIntent.Id); await setupIntentCache.Set(subscriber.Id, setupIntent.Id);
_logger.LogInformation("{Command}: Successfully cached Setup Intent ({SetupIntentId}) for subscriber ({SubscriberID})", CommandName, setupIntent.Id, subscriber.Id);
await UnlinkBraintreeCustomerAsync(customer); await UnlinkBraintreeCustomerAsync(customer);
return MaskedPaymentMethod.From(setupIntent); return MaskedPaymentMethod.From(setupIntent);

View File

@@ -2,6 +2,7 @@
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@@ -29,6 +30,7 @@ public interface IUpdatePremiumStorageCommand
} }
public class UpdatePremiumStorageCommand( public class UpdatePremiumStorageCommand(
IBraintreeService braintreeService,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IUserService userService, IUserService userService,
IPricingClient pricingClient, IPricingClient pricingClient,
@@ -49,7 +51,10 @@ public class UpdatePremiumStorageCommand(
// Fetch all premium plans and the user's subscription to find which plan they're on // Fetch all premium plans and the user's subscription to find which plan they're on
var premiumPlans = await pricingClient.ListPremiumPlans(); 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 // Find the password manager subscription item (seat, not storage) and match it to a plan
var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i => var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i =>
@@ -127,13 +132,41 @@ public class UpdatePremiumStorageCommand(
}); });
} }
var subscriptionUpdateOptions = new SubscriptionUpdateOptions var usingPayPal = subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
{
Items = subscriptionItemOptions,
ProrationBehavior = ProrationBehavior.AlwaysInvoice
};
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 // Update the user's max storage
user.MaxStorageGb = maxStorageGb; user.MaxStorageGb = maxStorageGb;

View File

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

View File

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

View File

@@ -141,13 +141,15 @@ public static class FeatureFlagKeys
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; 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 BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; public const string DefaultUserCollectionRestore = "pm-30883-my-items-restored-users";
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface"; public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
/* Architecture */ /* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
public const string DesktopMigrationMilestone2 = "desktop-ui-migration-milestone-2"; public const string DesktopMigrationMilestone2 = "desktop-ui-migration-milestone-2";
public const string DesktopMigrationMilestone3 = "desktop-ui-migration-milestone-3"; public const string DesktopMigrationMilestone3 = "desktop-ui-migration-milestone-3";
public const string DesktopMigrationMilestone4 = "desktop-ui-migration-milestone-4";
/* Auth Team */ /* Auth Team */
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
@@ -159,11 +161,12 @@ public static class FeatureFlagKeys
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock";
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; 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 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 PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
public const string SafariAccountSwitching = "pm-5594-safari-account-switching";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password"; public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */ /* Autofill Team */
@@ -174,6 +177,7 @@ public static class FeatureFlagKeys
public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string WindowsDesktopAutotype = "windows-desktop-autotype"; public const string WindowsDesktopAutotype = "windows-desktop-autotype";
public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga"; public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga";
public const string NotificationUndeterminedCipherScenarioLogic = "undetermined-cipher-scenario-logic";
/* Billing Team */ /* Billing Team */
public const string TrialPayment = "PM-8163-trial-payment"; public const string TrialPayment = "PM-8163-trial-payment";
@@ -221,9 +225,10 @@ public static class FeatureFlagKeys
/* Platform Team */ /* Platform Team */
public const string WebPush = "web-push"; public const string WebPush = "web-push";
public const string IpcChannelFramework = "ipc-channel-framework"; public const string ContentScriptIpcFramework = "content-script-ipc-channel-framework";
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins";
/* Tools Team */ /* Tools Team */
/// <summary> /// <summary>
@@ -255,6 +260,8 @@ public static class FeatureFlagKeys
/* DIRT Team */ /* DIRT Team */
public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike";
public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging";
public const string EventManagementForHuntress = "event-management-for-huntress";
public const string Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements";
/* UIF Team */ /* UIF Team */
public const string RouterFocusManagement = "router-focus-management"; public const string RouterFocusManagement = "router-focus-management";

View File

@@ -62,7 +62,7 @@
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" /> <PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.10.4" /> <PackageReference Include="LaunchDarkly.ServerSdk" Version="8.11.0" />
<PackageReference Include="Quartz" Version="3.15.1" /> <PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" /> <PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" /> <PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />

View File

@@ -1,4 +1,7 @@
namespace Bit.Core.KeyManagement.Models.Api.Response; using System.Text.Json.Serialization;
using Bit.Core.Auth.Models.Api.Response;
namespace Bit.Core.KeyManagement.Models.Api.Response;
public class UserDecryptionResponseModel public class UserDecryptionResponseModel
{ {
@@ -6,4 +9,10 @@ public class UserDecryptionResponseModel
/// Returns the unlock data when the user has a master password that can be used to decrypt their vault. /// Returns the unlock data when the user has a master password that can be used to decrypt their vault.
/// </summary> /// </summary>
public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; }
/// <summary>
/// Gets or sets the WebAuthn PRF decryption keys.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public WebAuthnPrfDecryptionOption[]? WebAuthnPrfOptions { get; set; }
} }

View File

@@ -2,17 +2,17 @@
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="display: table; width:100%; padding: 30px; text-align: left;" align="center"> <table width="100%" border="0" cellpadding="0" cellspacing="0" style="display: table; width:100%; padding: 30px; text-align: left;" align="center">
<tr> <tr>
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;"> <td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
<b>Here's what that means:</b> <b>What this means for you</b>
<ul> <ul>
<li>Your Bitwarden account is owned by {{OrganizationName}}</li> <li>Your day-to-day use of Bitwarden remains the same.</li>
<li>Your administrators can delete your account at any time</li> <li>Only store work-related items in your {{OrganizationName}} vault.</li>
<li>You cannot leave the organization</li> <li>{{OrganizationName}} admins now manage your account, meaning they can revoke or delete your account.</li>
</ul> </ul>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;"> <td style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; font-style: normal; font-weight: 400; font-size: 16px; line-height: 24px; margin-top: 30px; margin-bottom: 25px; margin-left: 35px; margin-right: 35px;">
For more information, please refer to the following help article: <a href="https://bitwarden.com/help/claimed-accounts">Claimed Accounts</a> For more information, please refer to the following help article: <a href="https://bitwarden.com/help/claimed-accounts">Claimed accounts</a>
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -1,7 +1,6 @@
As a member of {{OrganizationName}}, your Bitwarden account is claimed and owned by your organization. What this means for you:
- Your day-to-day use of Bitwarden remains the same.
- Only store work-related items in your {{OrganizationName}} vault.
- {{OrganizationName}} admins now manage your account, meaning they can revoke or delete your account.
Here's what that means: For more information, please refer to the following help article: Claimed accounts (https://bitwarden.com/help/claimed-accounts)
- Your administrators can delete your account at any time
- You cannot leave the organization
For more information, please refer to the following help article: Claimed Accounts (https://bitwarden.com/help/claimed-accounts)

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

View File

@@ -202,7 +202,7 @@
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;"> <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> This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.</div>
</td> </td>
@@ -271,12 +271,12 @@
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;"> <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> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;"> <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%;"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> <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:660px;" ><![endif]--> <!--[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%;"> <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> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -404,13 +404,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -427,13 +427,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -450,13 +450,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -473,13 +473,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -496,13 +496,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -519,13 +519,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -546,15 +546,15 @@
<tr> <tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <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 © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA Barbara, CA, USA
</p> </p>
<p style="margin-top: 5px"> <p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging Always confirm you are on a trusted Bitwarden domain before logging
in:<br> in:<br>
<a href="https://bitwarden.com/">bitwarden.com</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/">Learn why we include this</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> </p></div>
</td> </td>

View File

@@ -1,7 +1,7 @@
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually
at {{BaseAnnualRenewalPrice}} + tax. 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. This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
Questions? Contact support@bitwarden.com Questions? Contact support@bitwarden.com

View File

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

View File

@@ -201,8 +201,8 @@
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;"> <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 an existing 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 an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal.
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.</div> This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.</div>
</td> </td>
</tr> </tr>
@@ -270,12 +270,12 @@
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;"> <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> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;"> <td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
@@ -363,8 +363,8 @@
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"> <table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody> <tbody>
<tr> <tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;"> <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:660px;" ><![endif]--> <!--[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%;"> <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%;">
@@ -380,13 +380,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -403,13 +403,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -426,13 +426,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -449,13 +449,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -472,13 +472,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -495,13 +495,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -518,13 +518,13 @@
<tbody> <tbody>
<tr> <tr>
<td style="padding:10px;vertical-align:middle;"> <td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody> <tbody>
<tr> <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"> <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> </a>
</td> </td>
</tr> </tr>
@@ -545,15 +545,15 @@
<tr> <tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <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 © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA Barbara, CA, USA
</p> </p>
<p style="margin-top: 5px"> <p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging Always confirm you are on a trusted Bitwarden domain before logging
in:<br> in:<br>
<a href="https://bitwarden.com/">bitwarden.com</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/">Learn why we include this</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> </p></div>
</td> </td>

View File

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

View File

@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
@@ -30,6 +31,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
private readonly IGroupRepository _groupRepository; private readonly IGroupRepository _groupRepository;
private readonly IStripePaymentService _paymentService; private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IServiceAccountRepository _serviceAccountRepository;
@@ -45,6 +47,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
IGroupRepository groupRepository, IGroupRepository groupRepository,
IStripePaymentService paymentService, IStripePaymentService paymentService,
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IOrganizationConnectionRepository organizationConnectionRepository, IOrganizationConnectionRepository organizationConnectionRepository,
IServiceAccountRepository serviceAccountRepository, IServiceAccountRepository serviceAccountRepository,
@@ -59,6 +62,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
_groupRepository = groupRepository; _groupRepository = groupRepository;
_paymentService = paymentService; _paymentService = paymentService;
_policyRepository = policyRepository; _policyRepository = policyRepository;
_policyQuery = policyQuery;
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_organizationConnectionRepository = organizationConnectionRepository; _organizationConnectionRepository = organizationConnectionRepository;
_serviceAccountRepository = serviceAccountRepository; _serviceAccountRepository = serviceAccountRepository;
@@ -184,9 +188,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
if (!newPlan.HasResetPassword && organization.UseResetPassword) if (!newPlan.HasResetPassword && organization.UseResetPassword)
{ {
var resetPasswordPolicy = var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); if (resetPasswordPolicy.Enabled)
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
{ {
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
"Disable your Password Reset policy."); "Disable your Password Reset policy.");

View File

@@ -657,11 +657,11 @@ public class HandlebarsMailService : IMailService
return; return;
MailQueueMessage CreateMessage(string emailAddress, Organization org) => MailQueueMessage CreateMessage(string emailAddress, Organization org) =>
new(CreateDefaultMessage($"Your Bitwarden account is claimed by {org.DisplayName()}", emailAddress), new(CreateDefaultMessage($"Important update to your Bitwarden account", emailAddress),
"AdminConsole.DomainClaimedByOrganization", "AdminConsole.DomainClaimedByOrganization",
new ClaimedDomainUserNotificationViewModel new ClaimedDomainUserNotificationViewModel
{ {
TitleFirst = $"Your Bitwarden account is claimed by {org.DisplayName()}", TitleFirst = $"Important update to your Bitwarden account",
OrganizationName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false) OrganizationName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false)
}); });
} }

View File

@@ -61,7 +61,7 @@ public class UserService : UserManager<User>, IUserService
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IStripePaymentService _paymentService; private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository; private readonly IPolicyQuery _policyQuery;
private readonly IPolicyService _policyService; private readonly IPolicyService _policyService;
private readonly IFido2 _fido2; private readonly IFido2 _fido2;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
@@ -98,7 +98,7 @@ public class UserService : UserManager<User>, IUserService
IEventService eventService, IEventService eventService,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
IStripePaymentService paymentService, IStripePaymentService paymentService,
IPolicyRepository policyRepository, IPolicyQuery policyQuery,
IPolicyService policyService, IPolicyService policyService,
IFido2 fido2, IFido2 fido2,
ICurrentContext currentContext, ICurrentContext currentContext,
@@ -139,7 +139,7 @@ public class UserService : UserManager<User>, IUserService
_eventService = eventService; _eventService = eventService;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_paymentService = paymentService; _paymentService = paymentService;
_policyRepository = policyRepository; _policyQuery = policyQuery;
_policyService = policyService; _policyService = policyService;
_fido2 = fido2; _fido2 = fido2;
_currentContext = currentContext; _currentContext = currentContext;
@@ -722,9 +722,8 @@ public class UserService : UserManager<User>, IUserService
} }
// Enterprise policy must be enabled // Enterprise policy must be enabled
var resetPasswordPolicy = var resetPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); if (!resetPasswordPolicy.Enabled)
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
{ {
throw new BadRequestException("Organization does not have the password reset policy enabled."); throw new BadRequestException("Organization does not have the password reset policy enabled.");
} }

View File

@@ -83,7 +83,6 @@ public class GlobalSettings : IGlobalSettings
public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings();
public virtual string DevelopmentDirectory { get; set; } public virtual string DevelopmentDirectory { get; set; }
public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings();
public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5;
public virtual bool EnableEmailVerification { get; set; } public virtual bool EnableEmailVerification { get; set; }
public virtual string KdfDefaultHashKey { get; set; } public virtual string KdfDefaultHashKey { get; set; }
@@ -93,6 +92,7 @@ public class GlobalSettings : IGlobalSettings
public virtual string SendDefaultHashKey { get; set; } public virtual string SendDefaultHashKey { get; set; }
public virtual string PricingUri { get; set; } public virtual string PricingUri { get; set; }
public virtual Fido2Settings Fido2 { get; set; } = new Fido2Settings(); public virtual Fido2Settings Fido2 { get; set; } = new Fido2Settings();
public virtual ICommunicationSettings Communication { get; set; } = new CommunicationSettings();
public string BuildExternalUri(string explicitValue, string name) public string BuildExternalUri(string explicitValue, string name)
{ {
@@ -776,4 +776,17 @@ public class GlobalSettings : IGlobalSettings
{ {
public HashSet<string> Origins { get; set; } public HashSet<string> Origins { get; set; }
} }
public class CommunicationSettings : ICommunicationSettings
{
public string Bootstrap { get; set; } = "none";
public ISsoCookieVendorSettings SsoCookieVendor { get; set; } = new SsoCookieVendorSettings();
}
public class SsoCookieVendorSettings : ISsoCookieVendorSettings
{
public string IdpLoginUrl { get; set; }
public string CookieName { get; set; }
public string CookieDomain { get; set; }
}
} }

View File

@@ -0,0 +1,7 @@
namespace Bit.Core.Settings;
public interface ICommunicationSettings
{
string Bootstrap { get; set; }
ISsoCookieVendorSettings SsoCookieVendor { get; set; }
}

View File

@@ -29,4 +29,5 @@ public interface IGlobalSettings
IWebPushSettings WebPush { get; set; } IWebPushSettings WebPush { get; set; }
GlobalSettings.EventLoggingSettings EventLogging { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; }
GlobalSettings.WebAuthnSettings WebAuthn { get; set; } GlobalSettings.WebAuthnSettings WebAuthn { get; set; }
ICommunicationSettings Communication { get; set; }
} }

View File

@@ -0,0 +1,8 @@
namespace Bit.Core.Settings;
public interface ISsoCookieVendorSettings
{
string IdpLoginUrl { get; set; }
string CookieName { get; set; }
string CookieDomain { get; set; }
}

View File

@@ -81,6 +81,15 @@ public class Send : ITableObject<Guid>
[MaxLength(4000)] [MaxLength(4000)]
public string? Emails { get; set; } public string? Emails { get; set; }
/// <summary>
/// Comma-separated list of email **hashes** for OTP authentication.
/// </summary>
/// <remarks>
/// This field is mutually exclusive with <see cref="Password" />
/// </remarks>
[MaxLength(4000)]
public string? EmailHashes { get; set; }
/// <summary> /// <summary>
/// The send becomes unavailable to API callers when /// The send becomes unavailable to API callers when
/// <see cref="AccessCount"/> &gt;= <see cref="MaxAccessCount"/>. /// <see cref="AccessCount"/> &gt;= <see cref="MaxAccessCount"/>.

View File

@@ -74,7 +74,7 @@ public class ImportCiphersCommand : IImportCiphersCommand
if (cipher.UserId.HasValue && cipher.Favorite) if (cipher.UserId.HasValue && cipher.Favorite)
{ {
cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":\"true\"}}"; cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}";
} }
} }

View File

@@ -44,7 +44,7 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
/// <summary> /// <summary>
/// Create a send claim by requesting a one time password (OTP) confirmation code. /// Create a send claim by requesting a one time password (OTP) confirmation code.
/// </summary> /// </summary>
/// <param name="Emails"> /// <param name="EmailHashes">
/// The list of email addresses permitted access to the send. /// The list of email address **hashes** permitted access to the send.
/// </param> /// </param>
public record EmailOtp(string[] Emails) : SendAuthenticationMethod; public record EmailOtp(string[] EmailHashes) : SendAuthenticationMethod;

View File

@@ -37,8 +37,11 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
SendAuthenticationMethod method = send switch SendAuthenticationMethod method = send switch
{ {
null => NEVER_AUTHENTICATE, null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE, var s when s.Disabled => NEVER_AUTHENTICATE,
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails), var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE,
var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE,
var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE,
var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password), var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED _ => NOT_AUTHENTICATED
}; };
@@ -46,9 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
return method; return method;
} }
private EmailOtp emailOtp(string emails) private static EmailOtp EmailOtp(string? emailHashes)
{ {
var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (string.IsNullOrWhiteSpace(emailHashes))
{
return new EmailOtp([]);
}
var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new EmailOtp(list); return new EmailOtp(list);
} }
} }

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@@ -27,6 +28,7 @@ public class SendValidationService : ISendValidationService
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
@@ -38,7 +40,7 @@ public class SendValidationService : ISendValidationService
IUserService userService, IUserService userService,
IPolicyRequirementQuery policyRequirementQuery, IPolicyRequirementQuery policyRequirementQuery,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IPricingClient pricingClient,
ICurrentContext currentContext) ICurrentContext currentContext)
{ {
_userRepository = userRepository; _userRepository = userRepository;
@@ -48,6 +50,7 @@ public class SendValidationService : ISendValidationService
_userService = userService; _userService = userService;
_policyRequirementQuery = policyRequirementQuery; _policyRequirementQuery = policyRequirementQuery;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_pricingClient = pricingClient;
_currentContext = currentContext; _currentContext = currentContext;
} }
@@ -123,10 +126,19 @@ public class SendValidationService : ISendValidationService
} }
else else
{ {
// Users that get access to file storage/premium from their organization get the default // Users that get access to file storage/premium from their organization get storage
// 1 GB max storage. // based on the current premium plan from the pricing service
short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1; short provided;
storageBytesRemaining = user.StorageBytesRemaining(limit); if (_globalSettings.SelfHosted)
{
provided = Constants.SelfHostedMaxStorageGb;
}
else
{
var premiumPlan = await _pricingClient.GetAvailablePremiumPlan();
provided = (short)premiumPlan.Storage.Provided;
}
storageBytesRemaining = user.StorageBytesRemaining(provided);
} }
} }
else if (send.OrganizationId.HasValue) else if (send.OrganizationId.HasValue)

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