1
0
mirror of https://github.com/bitwarden/server synced 2026-02-12 14:33:49 +00:00

Merge branch 'main' into auth/pm-30810/http-redirect-cloud

This commit is contained in:
Patrick-Pimentel-Bitwarden
2026-02-10 14:23:16 -05:00
committed by GitHub
459 changed files with 51296 additions and 4110 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

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

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

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

View File

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

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

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

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

@@ -0,0 +1,119 @@
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
/// <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

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

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

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

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

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

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

View File

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

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

View File

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

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

View File

@@ -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,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,6 +18,7 @@ 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.AddPolicyValidators();

View File

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

View File

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

View File

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

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