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

Merge branch 'main' into ac/pm-30610/fix-formatting

This commit is contained in:
Jimmy Vo
2026-02-12 11:20:03 -05:00
446 changed files with 50857 additions and 3936 deletions

View File

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

10
.claude/settings.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extraKnownMarketplaces": {
"bitwarden-marketplace": {
"source": {
"source": "github",
"repo": "bitwarden/ai-plugins"
}
}
}
}

View File

@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "9.0.4",
"version": "10.1.0",
"commands": ["swagger"]
},
"dotnet-ef": {

View File

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

View File

@@ -3,11 +3,46 @@ export DEV_DIR=/workspace/dev
export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev
git config --global --add safe.directory /workspace
if [[ -z "${CODESPACES}" ]]; then
allow_interactive=1
else
echo "Doing non-interactive setup"
allow_interactive=0
fi
get_option() {
# Helper function for reading the value of an environment variable
# primarily but then falling back to an interactive question if allowed
# and lastly falling back to a default value input when either other
# option is available.
name_of_var="$1"
question_text="$2"
default_value="$3"
is_secret="$4"
if [[ -n "${!name_of_var}" ]]; then
# If the env variable they gave us has a value, then use that value
echo "${!name_of_var}"
elif [[ "$allow_interactive" == 1 ]]; then
# If we can be interactive, then use the text they gave us to request input
if [[ "$is_secret" == 1 ]]; then
read -r -s -p "$question_text" response
echo "$response"
else
read -r -p "$question_text" response
echo "$response"
fi
else
# If no environment variable and not interactive, then just give back default value
echo "$default_value"
fi
}
get_installation_id_and_key() {
pushd ./dev >/dev/null || exit
echo "Please enter your installation id and key from https://bitwarden.com/host:"
read -r -p "Installation id: " INSTALLATION_ID
read -r -p "Installation key: " INSTALLATION_KEY
INSTALLATION_ID="$(get_option "INSTALLATION_ID" "Installation id: " "00000000-0000-0000-0000-000000000001")"
INSTALLATION_KEY="$(get_option "INSTALLATION_KEY" "Installation key: " "" 1)"
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
secrets.json.example >secrets.json # create/overwrite secrets.json
@@ -30,11 +65,10 @@ configure_other_vars() {
}
one_time_setup() {
read -r -p \
"Would you like to configure your secrets and certificates for the first time?
do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like to configure your secrets and certificates for the first time?
WARNING: This will overwrite any existing secrets.json and certificate files.
Proceed? [y/N] " response
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
Proceed? [y/N] " "n")"
if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "Running one-time setup script..."
sleep 1
get_installation_id_and_key
@@ -50,11 +84,4 @@ Proceed? [y/N] " response
fi
}
# main
if [[ -z "${CODESPACES}" ]]; then
one_time_setup
else
# Ignore interactive elements when running in codespaces since they are not supported there
# TODO Write codespaces specific instructions and link here
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
fi
one_time_setup

View File

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

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

7
.github/CODEOWNERS vendored
View File

@@ -11,6 +11,9 @@
**/docker-compose.yml @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 ##
.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.yml @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
src/Core/Platform/Push/PushType.cs

View File

@@ -9,27 +9,3 @@
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## ⏰ Reminders before review
- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Protected functional changes with optionality (feature flags)
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
## 🦮 Reviewer guidelines
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2026.1.0</Version>
<Version>2026.2.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@@ -13,6 +13,10 @@
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<BitIncludeAuthentication>false</BitIncludeAuthentication>
<BitIncludeFeatures>false</BitIncludeFeatures>
</PropertyGroup>
<PropertyGroup>

View File

@@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Scim</UserSecretsId>

View File

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

View File

@@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Sso</UserSecretsId>

View File

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

View File

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

1
dev/.gitignore vendored
View File

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

View File

@@ -77,6 +77,7 @@ services:
- 4306:3306
environment:
MARIADB_USER: maria
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: vault_dev
MARIADB_RANDOM_ROOT_PASSWORD: "true"
volumes:

View File

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

View File

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

View File

@@ -6,6 +6,6 @@
"msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0",
"Microsoft.Build.Sql": "1.0.0",
"Bitwarden.Server.Sdk": "1.2.0"
"Bitwarden.Server.Sdk": "1.4.0"
}
}

View File

@@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>

View File

@@ -10,8 +10,10 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Services;
@@ -59,6 +61,7 @@ public class OrganizationsController : Controller
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IOrganizationBillingService _organizationBillingService;
private readonly IAutomaticUserConfirmationOrganizationPolicyComplianceValidator _automaticUserConfirmationOrganizationPolicyComplianceValidator;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@@ -84,7 +87,8 @@ public class OrganizationsController : Controller
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IOrganizationBillingService organizationBillingService)
IOrganizationBillingService organizationBillingService,
IAutomaticUserConfirmationOrganizationPolicyComplianceValidator automaticUserConfirmationOrganizationPolicyComplianceValidator)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -110,6 +114,7 @@ public class OrganizationsController : Controller
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_organizationBillingService = organizationBillingService;
_automaticUserConfirmationOrganizationPolicyComplianceValidator = automaticUserConfirmationOrganizationPolicyComplianceValidator;
}
[RequirePermission(Permission.Org_List_View)]
@@ -250,7 +255,8 @@ public class OrganizationsController : Controller
BillingEmail = organization.BillingEmail,
Status = organization.Status,
PlanType = organization.PlanType,
Seats = organization.Seats
Seats = organization.Seats,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation
};
if (model.PlanType.HasValue)
@@ -285,6 +291,13 @@ public class OrganizationsController : Controller
return RedirectToAction("Edit", new { id });
}
if (await CheckOrganizationPolicyComplianceAsync(existingOrganizationData, organization) is { } error)
{
TempData["Error"] = error.Message;
return RedirectToAction("Edit", new { id });
}
await HandlePotentialProviderSeatScalingAsync(
existingOrganizationData,
model);
@@ -312,6 +325,19 @@ public class OrganizationsController : Controller
return RedirectToAction("Edit", new { id });
}
private async Task<Error> CheckOrganizationPolicyComplianceAsync(Organization existingOrganizationData, Organization updatedOrganization)
{
if (!existingOrganizationData.UseAutomaticUserConfirmation && updatedOrganization.UseAutomaticUserConfirmation)
{
var validationResult = await _automaticUserConfirmationOrganizationPolicyComplianceValidator.IsOrganizationCompliantAsync(
new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(existingOrganizationData.Id));
return validationResult.Match(error => error, _ => null);
}
return null;
}
[HttpPost]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Org_Delete)]

View File

@@ -86,7 +86,7 @@ public class UsersController : Controller
return RedirectToAction("Index");
}
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);

View File

@@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.ConfigureKestrel(o =>

View File

@@ -57,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly ICurrentContext _currentContext;
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
@@ -90,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
ICollectionRepository collectionRepository,
IGroupRepository groupRepository,
IUserService userService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
ICurrentContext currentContext,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
@@ -123,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
_collectionRepository = collectionRepository;
_groupRepository = groupRepository;
_userService = userService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_currentContext = currentContext;
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
@@ -155,7 +155,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<OrganizationUserDetailsResponseModel> Get(Guid orgId, Guid id, bool includeGroups = false)
{
var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithSharedCollectionsAsync(id);
if (organizationUser == null || organizationUser.OrganizationId != orgId)
{
throw new NotFoundException();
@@ -287,14 +287,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
var userId = _userService.GetProperUserId(User);
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
{
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
}
else
{
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
}
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
return new ListResponseModel<OrganizationUserBulkResponseModel>(
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
@@ -331,6 +324,12 @@ public class OrganizationUsersController : BaseAdminConsoleController
throw new UnauthorizedAccessException();
}
var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
if (organizationUser == null || organizationUser.OrganizationId != orgId)
{
throw new NotFoundException("Organization user mismatch");
}
var useMasterPasswordPolicy = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
? (await _policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id)).AutoEnrollEnabled(orgId)
: await ShouldHandleResetPasswordAsync(orgId);
@@ -357,10 +356,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
return false;
}
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
return useMasterPasswordPolicy;
}
@@ -697,7 +695,16 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
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")]
@@ -712,7 +719,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
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")]

View File

@@ -48,7 +48,7 @@ public class OrganizationsController : Controller
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
@@ -74,7 +74,7 @@ public class OrganizationsController : Controller
public OrganizationsController(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrganizationService organizationService,
IUserService userService,
ICurrentContext currentContext,
@@ -99,7 +99,7 @@ public class OrganizationsController : Controller
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_organizationService = organizationService;
_userService = userService;
_currentContext = currentContext;
@@ -183,15 +183,14 @@ public class OrganizationsController : Controller
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
}
var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
{
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
}
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
}
[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.Organizations;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@@ -43,6 +42,7 @@ public class PoliciesController : Controller
private readonly IUserService _userService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
private readonly IPolicyQuery _policyQuery;
public PoliciesController(IPolicyRepository policyRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -54,7 +54,8 @@ public class PoliciesController : Controller
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
IVNextSavePolicyCommand vNextSavePolicyCommand,
IPolicyQuery policyQuery)
{
_policyRepository = policyRepository;
_organizationUserRepository = organizationUserRepository;
@@ -68,27 +69,24 @@ public class PoliciesController : Controller
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
_policyQuery = policyQuery;
}
[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))
{
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)
{
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery);
}
return new PolicyDetailResponseModel(policy);
return new PolicyStatusResponseModel(policy);
}
[HttpGet("")]

View File

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

View File

@@ -116,12 +116,17 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
public string ResetPasswordKey { get; set; }
public string MasterPasswordHash { get; set; }
}
#nullable enable
public class OrganizationUserBulkRequestModel
{
[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
{

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.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
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)
{
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()
{
@@ -25,5 +27,4 @@ public static class PolicyDetailResponses
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.Response;
using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
@@ -30,6 +34,8 @@ public class MembersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public MembersController(
IOrganizationUserRepository organizationUserRepository,
@@ -42,7 +48,9 @@ public class MembersController : Controller
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
@@ -55,6 +63,8 @@ public class MembersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
}
/// <summary>
@@ -70,7 +80,7 @@ public class MembersController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(Guid id)
{
var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithSharedCollectionsAsync(id);
if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
{
return new NotFoundResult();
@@ -113,7 +123,7 @@ public class MembersController : Controller
[ProducesResponseType(typeof(ListResponseModel<MemberResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List()
{
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeCollections: true);
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeSharedCollections: true);
var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails);
var memberResponses = organizationUserUserDetails.Select(u =>
@@ -258,4 +268,59 @@ public class MembersController : Controller
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
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

@@ -1,4 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Api</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
@@ -36,7 +38,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Models.Requests;
using System.ComponentModel.DataAnnotations;
using Bit.Core.KeyManagement.Models.Api.Request;
namespace Bit.Api.Auth.Models.Request.Accounts;

View File

@@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Entities;

View File

@@ -112,6 +112,7 @@ public class EmergencyAccessTakeoverResponseModel : ResponseModel
KdfIterations = grantor.KdfIterations;
KdfMemory = grantor.KdfMemory;
KdfParallelism = grantor.KdfParallelism;
Salt = grantor.GetMasterPasswordSalt();
}
public int KdfIterations { get; private set; }
@@ -119,6 +120,7 @@ public class EmergencyAccessTakeoverResponseModel : ResponseModel
public int? KdfParallelism { get; private set; }
public KdfType Kdf { get; private set; }
public string KeyEncrypted { get; private set; }
public string Salt { get; private set; }
}
public class EmergencyAccessViewResponseModel : ResponseModel

View File

@@ -6,7 +6,7 @@ using Bit.Api.Models.Response;
using Bit.Api.Models.Response.Organizations;
using Bit.Core.AdminConsole.Enums;
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.Entities;
using Bit.Core.Exceptions;
@@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IFeatureService _featureService;
public OrganizationSponsorshipsController(
@@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
IUserService userService,
ICurrentContext currentContext,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IFeatureService featureService)
{
_organizationSponsorshipRepository = organizationSponsorshipRepository;
@@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller
_syncSponsorshipsCommand = syncSponsorshipsCommand;
_userService = userService;
_currentContext = currentContext;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_featureService = featureService;
}
@@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
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)]
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName)
{
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
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);
if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy);
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
isFreeFamilyPolicyEnabled = policy.Enabled;
}
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.");
}
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(
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.");
}

View File

@@ -1,8 +1,9 @@
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Tax;
using Bit.Api.Billing.Models.Requests.PreviewInvoice;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Organizations.Commands;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
[Route("billing/tax")]
public class TaxController(
[Route("billing/preview-invoice")]
public class PreviewInvoiceController(
IPreviewOrganizationTaxCommand previewOrganizationTaxCommand,
IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController
IPreviewPremiumTaxCommand previewPremiumTaxCommand,
IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController
{
[HttpPost("organizations/subscriptions/purchase")]
public async Task<IResult> PreviewOrganizationSubscriptionPurchaseTaxAsync(
@@ -21,11 +23,7 @@ public class TaxController(
{
var (purchase, billingAddress) = request.ToDomain();
var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress);
return Handle(result.Map(pair => new
{
pair.Tax,
pair.Total
}));
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPost("organizations/{organizationId:guid}/subscription/plan-change")]
@@ -36,11 +34,7 @@ public class TaxController(
{
var (planChange, billingAddress) = request.ToDomain();
var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress);
return Handle(result.Map(pair => new
{
pair.Tax,
pair.Total
}));
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPut("organizations/{organizationId:guid}/subscription/update")]
@@ -51,11 +45,7 @@ public class TaxController(
{
var update = request.ToDomain();
var result = await previewOrganizationTaxCommand.Run(organization, update);
return Handle(result.Map(pair => new
{
pair.Tax,
pair.Total
}));
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPost("premium/subscriptions/purchase")]
@@ -64,10 +54,29 @@ public class TaxController(
{
var (purchase, billingAddress) = request.ToDomain();
var result = await previewPremiumTaxCommand.Run(purchase, billingAddress);
return Handle(result.Map(pair => new
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPost("premium/subscriptions/upgrade")]
[InjectUser]
public async Task<IResult> PreviewPremiumUpgradeProrationAsync(
[BindNever] User user,
[FromBody] PreviewPremiumUpgradeProrationRequest request)
{
var (planType, billingAddress) = request.ToDomain();
var result = await previewPremiumUpgradeProrationCommand.Run(
user,
planType,
billingAddress);
return Handle(result.Map(proration => new
{
pair.Tax,
pair.Total
proration.NewPlanProratedAmount,
proration.Credit,
proration.Tax,
proration.Total,
proration.NewPlanProratedMonths
}));
}
}

View File

@@ -132,8 +132,8 @@ public class AccountBillingVNextController(
[BindNever] User user,
[FromBody] UpgradePremiumToOrganizationRequest request)
{
var (organizationName, key, planType) = request.ToDomain();
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
var (organizationName, key, planType, billingAddress) = request.ToDomain();
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress);
return Handle(result);
}
}

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Enums;
namespace Bit.Api.Billing.Models.Requests.Premium;
@@ -14,24 +15,30 @@ public class UpgradePremiumToOrganizationRequest
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public ProductTierType Tier { get; set; }
public required ProductTierType TargetProductTierType { get; set; }
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public PlanCadenceType Cadence { get; set; }
public required MinimalBillingAddressRequest BillingAddress { get; set; }
private PlanType PlanType =>
Tier switch
private PlanType PlanType
{
get
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
? PlanType.TeamsMonthly
: PlanType.TeamsAnnually,
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
? PlanType.EnterpriseMonthly
: PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
};
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
{
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
}
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
return TargetProductTierType switch
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => PlanType.TeamsAnnually,
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
};
}
}
public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() =>
(OrganizationName, Key, PlanType, BillingAddress.ToDomain());
}

View File

@@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewOrganizationSubscriptionPlanChangeTaxRequest
{

View File

@@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewOrganizationSubscriptionPurchaseTaxRequest
{

View File

@@ -1,7 +1,7 @@
using Bit.Api.Billing.Models.Requests.Organizations;
using Bit.Core.Billing.Organizations.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public class PreviewOrganizationSubscriptionUpdateTaxRequest
{

View File

@@ -2,7 +2,7 @@
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewPremiumSubscriptionPurchaseTaxRequest
{

View File

@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewPremiumUpgradeProrationRequest
{
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public required ProductTierType TargetProductTierType { get; set; }
[Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; }
private PlanType PlanType
{
get
{
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
{
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
}
return TargetProductTierType switch
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => PlanType.TeamsAnnually,
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
};
}
}
public (PlanType, BillingAddress) ToDomain() =>
(PlanType, BillingAddress.ToDomain());
}

View File

@@ -81,7 +81,7 @@ public class CollectionsController : Controller
[HttpGet("details")]
public async Task<ListResponseModel<CollectionAccessDetailsResponseModel>> GetManyWithDetails(Guid orgId)
{
var allOrgCollections = await _collectionRepository.GetManyByOrganizationIdWithPermissionsAsync(
var allOrgCollections = await _collectionRepository.GetManySharedByOrganizationIdWithPermissionsAsync(
orgId, _currentContext.UserId.Value, true);
var readAllAuthorized =

View File

@@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator<IEnumerable<
}
// 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)
{

View File

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

View File

@@ -18,6 +18,7 @@ public class ConfigResponseModel : ResponseModel
public EnvironmentConfigResponseModel Environment { get; set; }
public IDictionary<string, object> FeatureStates { get; set; }
public PushSettings Push { get; set; }
public CommunicationSettings Communication { get; set; }
public ServerSettingsResponseModel Settings { get; set; }
public ConfigResponseModel() : base("config")
@@ -48,6 +49,7 @@ public class ConfigResponseModel : ResponseModel
FeatureStates = featureService.GetAll();
var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false;
Push = PushSettings.Build(webPushEnabled, globalSettings);
Communication = CommunicationSettings.Build(globalSettings);
Settings = new ServerSettingsResponseModel
{
DisableUserRegistration = globalSettings.DisableUserRegistration
@@ -88,6 +90,40 @@ public class PushSettings
}
}
public class CommunicationSettings
{
public CommunicationBootstrapSettings Bootstrap { get; private init; }
public static CommunicationSettings Build(IGlobalSettings globalSettings)
{
var bootstrap = CommunicationBootstrapSettings.Build(globalSettings);
return bootstrap == null ? null : new() { Bootstrap = bootstrap };
}
}
public class CommunicationBootstrapSettings
{
public string Type { get; private init; }
public string IdpLoginUrl { get; private init; }
public string CookieName { get; private init; }
public string CookieDomain { get; private init; }
public static CommunicationBootstrapSettings Build(IGlobalSettings globalSettings)
{
return globalSettings.Communication?.Bootstrap?.ToLowerInvariant() switch
{
"ssocookievendor" => new()
{
Type = "ssoCookieVendor",
IdpLoginUrl = globalSettings.Communication?.SsoCookieVendor?.IdpLoginUrl,
CookieName = globalSettings.Communication?.SsoCookieVendor?.CookieName,
CookieDomain = globalSettings.Communication?.SsoCookieVendor?.CookieDomain
},
_ => null
};
}
}
public class ServerSettingsResponseModel
{
public bool DisableUserRegistration { get; set; }

View File

@@ -0,0 +1,119 @@
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Platform.SsoCookieVendor;
/// <summary>
/// Provides an endpoint to read an SSO cookie and redirect to a custom URI
/// scheme. The load balancer/reverse proxy must be configured such that
/// requests to this endpoint do not have the auth cookie stripped.
/// </summary>
[Route("sso-cookie-vendor")]
[SelfHosted(SelfHostedOnly = true)]
public class SsoCookieVendorController(IGlobalSettings globalSettings) : Controller
{
private readonly IGlobalSettings _globalSettings = globalSettings;
private const int _maxShardCount = 20;
private const int _maxUriLength = 8192;
/// <summary>
/// Reads SSO cookie (shards supported) and redirects to the bitwarden://
/// URI with cookie value(s).
/// </summary>
/// <returns>
/// 302 redirect on success, 404 if no cookies found, 400 if URI too long,
/// 500 if misconfigured
/// </returns>
[HttpGet]
[AllowAnonymous]
public IActionResult Get()
{
var bootstrap = _globalSettings.Communication?.Bootstrap;
if (string.IsNullOrEmpty(bootstrap) || !bootstrap.Equals("ssoCookieVendor", StringComparison.OrdinalIgnoreCase))
{
return NotFound();
}
var cookieName = _globalSettings.Communication?.SsoCookieVendor?.CookieName;
if (string.IsNullOrWhiteSpace(cookieName))
{
return StatusCode(500, "SSO cookie vendor is not properly configured");
}
var uri = string.Empty;
if (TryGetCookie(cookieName, out var cookie))
{
uri = BuildRedirectUri(cookie);
}
else if (TryGetShardedCookie(cookieName, out var shardedCookie))
{
uri = BuildRedirectUri(shardedCookie);
}
if (uri == string.Empty)
{
return NotFound("No SSO cookies found");
}
if (uri.Length > _maxUriLength)
{
return BadRequest();
}
return Redirect(uri);
}
private bool TryGetCookie(string cookieName, out Dictionary<string, string> cookie)
{
cookie = [];
if (Request.Cookies.TryGetValue(cookieName, out var value) && !string.IsNullOrEmpty(value))
{
cookie[cookieName] = value;
return true;
}
return false;
}
private bool TryGetShardedCookie(string cookieName, out Dictionary<string, string> cookies)
{
var shardedCookies = new Dictionary<string, string>();
for (var i = 0; i < _maxShardCount; i++)
{
var shardName = $"{cookieName}-{i}";
if (Request.Cookies.TryGetValue(shardName, out var value) && !string.IsNullOrEmpty(value))
{
shardedCookies[shardName] = value;
}
else
{
// Stop at first missing shard to maintain order integrity
break;
}
}
cookies = shardedCookies;
return shardedCookies.Count > 0;
}
private static string BuildRedirectUri(Dictionary<string, string> cookies)
{
var queryParams = new List<string>();
foreach (var kvp in cookies)
{
var encodedValue = Uri.EscapeDataString(kvp.Value);
queryParams.Add($"{kvp.Key}={encodedValue}");
}
// Add a sentinel value so clients can detect a truncated URI, in the
// event a user agent decides the URI is too long.
queryParams.Add("d=1");
return $"bitwarden://sso-cookie-vendor?{string.Join("&", queryParams)}";
}
}

View File

@@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

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

View File

@@ -14,8 +14,7 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
using Bit.Core.Auth.Entities;
using Bit.SharedWeb.Health;
using Microsoft.IdentityModel.Logging;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -238,8 +237,6 @@ public class Startup
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
IdentityModelEventSource.ShowPII = true;
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
@@ -304,44 +301,43 @@ public class Startup
// Remove all Bitwarden cloud servers and only register the local server
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
{
swaggerDoc.Servers.Clear();
swaggerDoc.Servers.Add(new OpenApiServer
{
Url = globalSettings.BaseServiceUri.Api,
});
swaggerDoc.Servers =
[
new() {
Url = globalSettings.BaseServiceUri.Api,
}
];
swaggerDoc.Components.SecuritySchemes.Clear();
swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme
swaggerDoc.Components ??= new OpenApiComponents();
swaggerDoc.Components.SecuritySchemes = new Dictionary<string, IOpenApiSecurityScheme>
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
"oauth2-client-credentials",
new OpenApiSecurityScheme
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
{
{ ApiScopes.ApiOrganization, "Organization APIs" }
}
}
}
}
}
});
};
swaggerDoc.SecurityRequirements.Clear();
swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement
{
swaggerDoc.Security =
[
new OpenApiSecurityRequirement
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2-client-credentials"
}
},
[ApiScopes.ApiOrganization]
}
});
[new OpenApiSecuritySchemeReference("oauth2-client-credentials")] = [ApiScopes.ApiOrganization]
},
];
});
});

View File

@@ -74,11 +74,6 @@ public class ImportCiphersController : Controller
throw new BadRequestException("You cannot import this much data at once.");
}
if (model.Ciphers.Any(c => c.ArchivedDate.HasValue))
{
throw new BadRequestException("You cannot import archived items into an organization.");
}
var orgId = new Guid(organizationId);
var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();

View File

@@ -239,9 +239,8 @@ public class SendsController : Controller
{
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)
if (!INonAnonymousSendCommand.SendCanBeAccessed(send))
{
throw new NotFoundException();
}
@@ -253,9 +252,19 @@ public class SendsController : Controller
sendResponse.CreatorIdentifier = creator.Email;
}
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
/*
* AccessCount is incremented differently for File and Text Send types:
* - Text Sends are incremented at every access
* - File Sends are incremented only when the file is downloaded
*
* Note that this endpoint is initially called for all Send types
*/
if (send.Type == SendType.Text)
{
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
}
return new ObjectResult(sendResponse);
}
@@ -272,19 +281,14 @@ public class SendsController : Controller
{
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)
var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
if (result.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
}
@@ -399,19 +403,7 @@ public class SendsController : Controller
[HttpPut("{id}/remove-password")]
public async Task<SendResponseModel> PutRemovePassword(string id)
{
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
}
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
// This allows clients to update other fields without re-submitting sensitive auth data.
send.Password = null;
send.AuthType = AuthType.None;
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send);
return await this.PutRemoveAuth(id);
}
// Removes ALL authentication (email or password) if any is present

View File

@@ -3,7 +3,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Api.Tools.Utilities;
using Bit.Core.Exceptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
@@ -264,8 +263,9 @@ public class SendRequestModel
}
else
{
// Neither Password nor Emails provided - preserve existing values and infer AuthType
existingSend.AuthType = SendUtilities.InferAuthType(existingSend);
existingSend.Emails = null;
existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.None;
}
existingSend.Disabled = Disabled.GetValueOrDefault();

View File

@@ -7,7 +7,7 @@ using Bit.SharedWeb.Health;
using Bit.SharedWeb.Swagger;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi;
namespace Bit.Api.Utilities;

View File

@@ -976,14 +976,14 @@ public class CiphersController : Controller
public async Task DeleteAdmin(Guid id)
{
var userId = _userService.GetProperUserId(User).Value;
var cipher = await GetByIdAsync(id, userId);
var cipher = await GetByIdAsyncAdmin(id);
if (cipher == null || !cipher.OrganizationId.HasValue ||
!await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id }))
{
throw new NotFoundException();
}
await _cipherService.DeleteAsync(cipher, userId, true);
await _cipherService.DeleteAsync(new CipherDetails(cipher), userId, true);
}
[HttpPost("{id}/delete-admin")]

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MarkDig" Version="0.44.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
</ItemGroup>
</Project>

View File

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

View File

@@ -1,17 +1,15 @@
using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Quartz;
using Stripe;
using Stripe.TestHelpers;
using static Bit.Core.Billing.Constants.StripeConstants;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -25,14 +23,11 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ISchedulerFactory _schedulerFactory;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IPricingClient _pricingClient;
private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ILogger<SubscriptionUpdatedHandler> _logger;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
public SubscriptionUpdatedHandler(
@@ -43,14 +38,11 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
IUserService userService,
IOrganizationRepository organizationRepository,
ISchedulerFactory schedulerFactory,
IOrganizationEnableCommand organizationEnableCommand,
IOrganizationDisableCommand organizationDisableCommand,
IPricingClient pricingClient,
IFeatureService featureService,
IProviderRepository providerRepository,
IProviderService providerService,
ILogger<SubscriptionUpdatedHandler> logger,
IPushNotificationAdapter pushNotificationAdapter)
{
_stripeEventService = stripeEventService;
@@ -62,183 +54,147 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_userService = userService;
_organizationRepository = organizationRepository;
_providerRepository = providerRepository;
_schedulerFactory = schedulerFactory;
_organizationEnableCommand = organizationEnableCommand;
_organizationDisableCommand = organizationDisableCommand;
_pricingClient = pricingClient;
_featureService = featureService;
_providerRepository = providerRepository;
_providerService = providerService;
_logger = logger;
_pushNotificationAdapter = pushNotificationAdapter;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.SubscriptionUpdated"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]);
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
SubscriberId subscriberId = subscription;
var currentPeriodEnd = subscription.GetCurrentPeriodEnd();
switch (subscription.Status)
if (SubscriptionWentUnpaid(parsedEvent, subscription))
{
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
when organizationId.HasValue:
{
await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd);
if (subscription.Status == StripeSubscriptionStatus.Unpaid &&
subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" })
{
await ScheduleCancellationJobAsync(subscription.Id, organizationId.Value);
}
break;
}
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired when providerId.HasValue:
{
await HandleUnpaidProviderSubscriptionAsync(providerId.Value, parsedEvent, subscription);
break;
}
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired:
{
if (!userId.HasValue)
{
break;
}
if (await IsPremiumSubscriptionAsync(subscription))
{
await CancelSubscription(subscription.Id);
await VoidOpenInvoices(subscription.Id);
}
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
break;
}
case StripeSubscriptionStatus.Incomplete when userId.HasValue:
{
// Handle Incomplete subscriptions for Premium users that have open invoices from failed payments
// This prevents duplicate subscriptions when users retry the subscription flow
if (await IsPremiumSubscriptionAsync(subscription) &&
subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open })
{
await CancelSubscription(subscription.Id);
await VoidOpenInvoices(subscription.Id);
await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd);
}
break;
}
case StripeSubscriptionStatus.Active when organizationId.HasValue:
{
await _organizationEnableCommand.EnableAsync(organizationId.Value);
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization != null)
{
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
}
break;
}
case StripeSubscriptionStatus.Active when providerId.HasValue:
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
provider.Enabled = true;
await _providerService.UpdateAsync(provider);
if (IsProviderSubscriptionNowActive(parsedEvent, subscription))
{
// Update the CancelAtPeriodEnd subscription option to prevent the now active provider subscription from being cancelled
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAtPeriodEnd = false };
await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionUpdateOptions);
}
}
break;
}
case StripeSubscriptionStatus.Active:
{
if (userId.HasValue)
{
await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd);
}
break;
}
await DisableSubscriberAsync(subscriberId, currentPeriodEnd);
await SetSubscriptionToCancelAsync(subscription);
}
else if (SubscriptionBecameActive(parsedEvent, subscription))
{
await EnableSubscriberAsync(subscriberId, currentPeriodEnd);
await RemovePendingCancellationAsync(subscription);
}
if (organizationId.HasValue)
{
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd);
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue)
await subscriberId.Match(
userId => _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd),
async organizationId =>
{
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value);
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd);
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue)
{
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value);
}
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
},
_ => Task.CompletedTask);
}
private static bool SubscriptionWentUnpaid(
Event parsedEvent,
Subscription currentSubscription) =>
parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() is Subscription
{
Status:
SubscriptionStatus.Trialing or
SubscriptionStatus.Active or
SubscriptionStatus.PastDue
} && currentSubscription is
{
Status: SubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle
};
private static bool SubscriptionBecameActive(
Event parsedEvent,
Subscription currentSubscription) =>
parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() is Subscription
{
Status:
SubscriptionStatus.Incomplete or
SubscriptionStatus.Unpaid
} && currentSubscription is
{
Status: SubscriptionStatus.Active,
LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle
};
private Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) =>
subscriberId.Match(
userId => _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd),
async organizationId =>
{
await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd);
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization != null)
{
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
}
},
async providerId =>
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
provider.Enabled = false;
await _providerService.UpdateAsync(provider);
}
});
private Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) =>
subscriberId.Match(
userId => _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd),
async organizationId =>
{
await _organizationEnableCommand.EnableAsync(organizationId.Value, currentPeriodEnd);
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization != null)
{
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
}
},
async providerId =>
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
provider.Enabled = true;
await _providerService.UpdateAsync(provider);
}
});
private async Task SetSubscriptionToCancelAsync(Subscription subscription)
{
if (subscription.TestClock != null)
{
await WaitForTestClockToAdvanceAsync(subscription.TestClock);
}
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions
{
CancelAt = now.AddDays(7),
ProrationBehavior = ProrationBehavior.None,
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = $"Automation: Setting unpaid subscription to cancel 7 days from {now:yyyy-MM-dd}."
}
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
}
else if (userId.HasValue)
{
await _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd);
}
});
}
private async Task CancelSubscription(string subscriptionId) =>
await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
private async Task VoidOpenInvoices(string subscriptionId)
{
var options = new InvoiceListOptions
private async Task RemovePendingCancellationAsync(Subscription subscription)
=> await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions
{
Status = StripeInvoiceStatus.Open,
Subscription = subscriptionId
};
var invoices = await _stripeFacade.ListInvoices(options);
foreach (var invoice in invoices)
{
await _stripeFacade.VoidInvoice(invoice.Id);
}
}
private async Task<bool> IsPremiumSubscriptionAsync(Subscription subscription)
{
var premiumPlans = await _pricingClient.ListPremiumPlans();
var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet();
return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id));
}
/// <summary>
/// Checks if the provider subscription status has changed from a non-active to an active status type
/// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false.
/// </summary>
/// <param name="parsedEvent">The event containing the previous subscription status</param>
/// <param name="subscription">The current subscription status</param>
/// <returns>A boolean that represents whether the event status has changed from a non-active status to an active status</returns>
private static bool IsProviderSubscriptionNowActive(Event parsedEvent, Subscription subscription)
{
if (parsedEvent.Data.PreviousAttributes == null)
{
return false;
}
var previousSubscription = parsedEvent
.Data
.PreviousAttributes
.ToObject<Subscription>() as Subscription;
return previousSubscription?.Status switch
{
StripeSubscriptionStatus.IncompleteExpired
or StripeSubscriptionStatus.Paused
or StripeSubscriptionStatus.Incomplete
or StripeSubscriptionStatus.Unpaid
when subscription.Status == StripeSubscriptionStatus.Active => true,
_ => false
};
}
CancelAtPeriodEnd = false,
ProrationBehavior = ProrationBehavior.None
});
/// <summary>
/// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial.
@@ -305,7 +261,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
?.Id == "sm-standalone";
var subscriptionHasSecretsManagerTrial = subscription.Discounts.Select(discount => discount.Coupon.Id)
.Contains(StripeConstants.CouponIDs.SecretsManagerStandalone);
.Contains(CouponIDs.SecretsManagerStandalone);
if (customerHasSecretsManagerTrial)
{
@@ -318,75 +274,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
}
}
private async Task ScheduleCancellationJobAsync(string subscriptionId, Guid organizationId)
{
var scheduler = await _schedulerFactory.GetScheduler();
var job = JobBuilder.Create<SubscriptionCancellationJob>()
.WithIdentity($"cancel-sub-{subscriptionId}", "subscription-cancellations")
.UsingJobData("subscriptionId", subscriptionId)
.UsingJobData("organizationId", organizationId.ToString())
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"cancel-trigger-{subscriptionId}", "subscription-cancellations")
.StartAt(DateTimeOffset.UtcNow.AddDays(7))
.Build();
await scheduler.ScheduleJob(job, trigger);
}
private async Task HandleUnpaidProviderSubscriptionAsync(
Guid providerId,
Event parsedEvent,
Subscription currentSubscription)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
return;
}
try
{
provider.Enabled = false;
await _providerService.UpdateAsync(provider);
if (parsedEvent.Data.PreviousAttributes != null)
{
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
if (previousSubscription is
{
Status:
StripeSubscriptionStatus.Trialing or
StripeSubscriptionStatus.Active or
StripeSubscriptionStatus.PastDue
} && currentSubscription is
{
Status: StripeSubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
})
{
if (currentSubscription.TestClock != null)
{
await WaitForTestClockToAdvanceAsync(currentSubscription.TestClock);
}
var now = currentSubscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
}
}
}
catch (Exception exception)
{
_logger.LogError(exception, "An error occurred while trying to disable and schedule subscription cancellation for provider ({ProviderID})", providerId);
}
}
private async Task WaitForTestClockToAdvanceAsync(TestClock testClock)
{
while (testClock.Status != "ready")

View File

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

View File

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

@@ -778,7 +778,7 @@
<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
© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">

View File

@@ -946,7 +946,7 @@
<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
© {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">

View File

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

View File

@@ -157,6 +157,6 @@ public class VerifyOrganizationDomainCommand(
var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId);
await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization));
await mailService.SendClaimedDomainUserEmailAsync(new ClaimedUserDomainClaimedEmails(domainUserEmails, organization, domain.DomainName));
}
}

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.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
@@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
IPolicyRequirementQuery policyRequirementQuery,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
IUserService userService,
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator
{
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request)
@@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
}
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 };
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)

View File

@@ -4,7 +4,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
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.Tokenables;
using Bit.Core.Auth.Repositories;
@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
public class SendOrganizationInvitesCommand(
IUserRepository userRepository,
ISsoConfigRepository ssoConfigurationRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService) : ISendOrganizationInvitesCommand
@@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand(
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
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
// create helper function to create expiring tokens

View File

@@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand
/// </summary>
/// <param name="organizationUser">Revoked user to be restored.</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>
/// 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>
/// <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>
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,
IFeatureService featureService,
IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
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)
{
@@ -46,7 +47,7 @@ public class RestoreOrganizationUserCommand(
throw new BadRequestException("Only owners can restore other owners.");
}
await RepositoryRestoreUserAsync(organizationUser);
await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue)
@@ -57,7 +58,7 @@ public class RestoreOrganizationUserCommand(
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,
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)
{
@@ -93,7 +94,7 @@ public class RestoreOrganizationUserCommand(
.twoFactorIsEnabled;
}
if (organization.PlanType == PlanType.Free)
if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue)
{
await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser);
}
@@ -104,7 +105,16 @@ public class RestoreOrganizationUserCommand(
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)
@@ -156,7 +166,8 @@ public class RestoreOrganizationUserCommand(
}
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 filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
@@ -224,12 +235,14 @@ public class RestoreOrganizationUserCommand(
await organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
@@ -238,9 +251,37 @@ public class RestoreOrganizationUserCommand(
}
}
if (featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore))
{
await CreateDefaultCollectionsForConfirmedUsersAsync(organizationId, defaultCollectionName,
result.Where(r => r.Item2 == "").Select(x => x.Item1).ToList());
}
return result;
}
private async Task CreateDefaultCollectionsForConfirmedUsersAsync(Guid organizationId, string defaultCollectionName,
ICollection<OrganizationUser> restoredUsers)
{
if (!string.IsNullOrWhiteSpace(defaultCollectionName))
{
var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery
.GetManyByOrganizationIdAsync<OrganizationDataOwnershipPolicyRequirement>(organizationId))
.ToList();
var usersToCreateDefaultCollectionsFor = restoredUsers.Where(x =>
organizationUsersDataOwnershipEnabled.Contains(x.Id)
&& x.Status == OrganizationUserStatusType.Confirmed).ToList();
if (usersToCreateDefaultCollectionsFor.Count != 0)
{
await collectionRepository.CreateDefaultCollectionsAsync(organizationId,
usersToCreateDefaultCollectionsFor.Select(x => x.Id),
defaultCollectionName);
}
}
}
private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled)
{
// An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant

View File

@@ -0,0 +1,58 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
public class AutomaticUserConfirmationOrganizationPolicyComplianceValidator(
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository)
: IAutomaticUserConfirmationOrganizationPolicyComplianceValidator
{
public async Task<ValidationResult<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>>
IsOrganizationCompliantAsync(AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request)
{
var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(request.OrganizationId);
if (await ValidateUserComplianceWithSingleOrgAsync(request, organizationUsers) is { } singleOrgNonCompliant)
{
return Invalid(request, singleOrgNonCompliant);
}
if (await ValidateNoProviderUsersAsync(organizationUsers) is { } orgHasProviderMember)
{
return Invalid(request, orgHasProviderMember);
}
return Valid(request);
}
private async Task<Error?> ValidateUserComplianceWithSingleOrgAsync(
AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request,
ICollection<OrganizationUserUserDetails> organizationUsers)
{
var userIds = organizationUsers
.Where(u => u.UserId is not null && u.Status != OrganizationUserStatusType.Invited)
.Select(u => u.UserId!.Value);
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
.Any(uo => uo.OrganizationId != request.OrganizationId
&& uo.Status != OrganizationUserStatusType.Invited);
return hasNonCompliantUser ? new UserNotCompliantWithSingleOrganization() : null;
}
private async Task<Error?> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> organizationUsers)
{
var userIds = organizationUsers.Where(x => x.UserId is not null)
.Select(x => x.UserId!.Value);
return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0
? new ProviderExistsInOrganization()
: null;
}
}

View File

@@ -0,0 +1,3 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
public record AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(Guid OrganizationId);

View File

@@ -0,0 +1,7 @@
using Bit.Core.AdminConsole.Utilities.v2;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
public record UserNotCompliantWithSingleOrganization() : BadRequestError("All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.");
public record ProviderExistsInOrganization() : BadRequestError("The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy.");

View File

@@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Utilities.v2.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
/// <summary>
/// Validates that an organization meets the prerequisites for enabling the Automatic User Confirmation policy.
/// </summary>
/// <remarks>
/// The following conditions must be met:
/// <list type="bullet">
/// <item>All non-invited organization users belong only to this organization (Single Organization compliance)</item>
/// <item>No organization users are provider members</item>
/// </list>
/// </remarks>
public interface IAutomaticUserConfirmationOrganizationPolicyComplianceValidator
{
/// <summary>
/// Checks whether the organization is compliant with the Automatic User Confirmation policy prerequisites.
/// </summary>
/// <param name="request">The request containing the organization ID to validate.</param>
/// <returns>
/// A <see cref="ValidationResult{TRequest}"/> that is valid if the organization is compliant,
/// or contains a <see cref="UserNotCompliantWithSingleOrganization"/> or <see cref="ProviderExistsInOrganization"/>
/// error if validation fails.
/// </returns>
Task<ValidationResult<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>>
IsOrganizationCompliantAsync(AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest request);
}

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>
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
{
public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any();
public bool CannotHaveEmergencyAccess() => policyDetails.Any();
public bool CannotJoinProvider() => policyDetails.Any();

View File

@@ -18,14 +18,17 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
services.AddScoped<IPolicyQuery, PolicyQuery>();
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
services.AddScoped<IAutomaticUserConfirmationPolicyEnforcementValidator, AutomaticUserConfirmationPolicyEnforcementValidator>();
services.AddScoped<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator, AutomaticUserConfirmationOrganizationPolicyComplianceValidator>();
services.AddPolicyValidators();
services.AddPolicyRequirements();
services.AddPolicySideEffects();
services.AddPolicyUpdateEvents();
services.AddScoped<IAutomaticUserConfirmationPolicyEnforcementValidator, AutomaticUserConfirmationPolicyEnforcementValidator>();
}
[Obsolete("Use AddPolicyUpdateEvents instead.")]

View File

@@ -1,11 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
@@ -19,19 +16,11 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
/// <li>No provider users exist</li>
/// </ul>
/// </summary>
public class AutomaticUserConfirmationPolicyEventHandler(
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository)
public class AutomaticUserConfirmationPolicyEventHandler(IAutomaticUserConfirmationOrganizationPolicyComplianceValidator validator)
: IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
private const string _usersNotCompliantWithSingleOrgErrorMessage =
"All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.";
private const string _providerUsersExistErrorMessage =
"The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy.";
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
@@ -43,7 +32,11 @@ public class AutomaticUserConfirmationPolicyEventHandler(
return string.Empty;
}
return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId);
return (await validator.IsOrganizationCompliantAsync(
new AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(policyUpdate.OrganizationId)))
.Match(
error => error.Message,
_ => string.Empty);
}
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
@@ -51,48 +44,4 @@ public class AutomaticUserConfirmationPolicyEventHandler(
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) =>
Task.CompletedTask;
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
{
var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers);
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
{
return singleOrgValidationError;
}
var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers);
if (!string.IsNullOrWhiteSpace(providerValidationError))
{
return providerValidationError;
}
return string.Empty;
}
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
ICollection<OrganizationUserUserDetails> organizationUsers)
{
var userIds = organizationUsers.Where(
u => u.UserId is not null &&
u.Status != OrganizationUserStatusType.Invited)
.Select(u => u.UserId!.Value);
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
.Any(uo => uo.OrganizationId != organizationId
&& uo.Status != OrganizationUserStatusType.Invited);
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
}
private async Task<string> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> organizationUsers)
{
var userIds = organizationUsers.Where(x => x.UserId is not null)
.Select(x => x.UserId!.Value);
return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0
? _providerUsersExistErrorMessage
: string.Empty;
}
}

View File

@@ -5,21 +5,17 @@ using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent
{
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly IFeatureService _featureService;
public BlockClaimedDomainAccountCreationPolicyValidator(
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IFeatureService featureService)
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_featureService = featureService;
}
public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation;
@@ -34,12 +30,6 @@ public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
// Check if feature is enabled
if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
{
return "This feature is not enabled";
}
// Only validate when trying to ENABLE the policy
if (policyUpdate is { Enabled: true })
{

View File

@@ -28,21 +28,21 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
/// </summary>
/// <param name="id">The id of the OrganizationUser</param>
/// <returns>A tuple containing the OrganizationUser and its associated collections</returns>
Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id);
Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithSharedCollectionsAsync(Guid id);
/// <summary>
/// Returns the OrganizationUsers and their associated collections (excluding DefaultUserCollections).
/// </summary>
/// <param name="organizationId">The id of the organization</param>
/// <param name="includeGroups">Whether to include groups</param>
/// <param name="includeCollections">Whether to include collections</param>
/// <param name="includeSharedCollections">Whether to include shared collections</param>
/// <returns>A list of OrganizationUserUserDetails</returns>
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeSharedCollections = false);
/// <inheritdoc cref="GetManyDetailsByOrganizationAsync"/>
/// <remarks>
/// This method is optimized for performance.
/// Reduces database round trips by fetching all data in fewer queries.
/// </remarks>
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeSharedCollections = false);
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
OrganizationUserStatusType? status = null);
Task<OrganizationUserOrganizationDetails?> GetDetailsByUserAsync(Guid userId, Guid organizationId,

View File

@@ -27,7 +27,6 @@ public interface IOrganizationService
OrganizationUserInvite invite, string externalId);
Task<List<OrganizationUser>> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser,
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 DeleteSsoUserAsync(Guid userId, Guid? organizationId);
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.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Constants;
@@ -49,7 +48,7 @@ public class OrganizationService : IOrganizationService
private readonly IEventService _eventService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IPolicyService _policyService;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IGlobalSettings _globalSettings;
@@ -76,7 +75,7 @@ public class OrganizationService : IOrganizationService
IEventService eventService,
IApplicationCacheService applicationCacheService,
IStripePaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IPolicyService policyService,
ISsoUserRepository ssoUserRepository,
IGlobalSettings globalSettings,
@@ -103,7 +102,7 @@ public class OrganizationService : IOrganizationService
_eventService = eventService;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_policyService = policyService;
_ssoUserRepository = ssoUserRepository;
_globalSettings = globalSettings;
@@ -718,32 +717,6 @@ public class OrganizationService : IOrganizationService
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) =>
await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization));
@@ -862,9 +835,8 @@ public class OrganizationService : IOrganizationService
}
// Make sure the organization has the policy enabled
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword);
if (!resetPasswordPolicy.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.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
@@ -30,7 +31,8 @@ public static class PolicyDataValidator
switch (policyType)
{
case PolicyType.MasterPassword:
CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
var masterPasswordData = CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
ValidateModel(masterPasswordData, policyType);
break;
case PolicyType.SendOptions:
CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(json);
@@ -44,11 +46,24 @@ public static class PolicyDataValidator
}
catch (JsonException ex)
{
var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : "";
var fieldName = !string.IsNullOrEmpty(ex.Path) ? ex.Path.TrimStart('$', '.') : null;
var fieldInfo = !string.IsNullOrEmpty(fieldName) ? $": {fieldName} has an invalid value" : "";
throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}.");
}
}
private static void ValidateModel(object model, PolicyType policyType)
{
var validationContext = new ValidationContext(model);
var validationResults = new List<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>
/// Validates and deserializes policy metadata based on the policy type.
/// </summary>

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
using Bit.Core.Utilities;
@@ -14,8 +11,8 @@ public class EmergencyAccess : ITableObject<Guid>
public Guid GrantorId { get; set; }
public Guid? GranteeId { get; set; }
[MaxLength(256)]
public string Email { get; set; }
public string KeyEncrypted { get; set; }
public string? Email { get; set; }
public string? KeyEncrypted { get; set; }
public EmergencyAccessType Type { get; set; }
public EmergencyAccessStatusType Status { get; set; }
public short WaitTimeDays { get; set; }

View File

@@ -19,7 +19,7 @@ public enum EmergencyAccessStatusType : byte
/// </summary>
RecoveryInitiated = 3,
/// <summary>
/// The grantee has excercised their emergency access.
/// The grantee has exercised their emergency access.
/// </summary>
RecoveryApproved = 4,
}

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