Compare commits

...
18 Commits
Author SHA1 Message Date
Daniel García f4b2b64335 List attachments in sync with a stable URL
Sync and the cipher responses list each attachment with an unsigned URL of
upstream's form, which no longer changes on every sync nor costs a presign per
attachment on S3, and which isn't served without a token. The attachment routes
sign it: the cipher route and its admin variant, and the emergency access view's
route the web vault downloads through, which is added. The admin variant also
signs it for a member who can open the item themselves, because the web vault's
fallback to the listed URL no longer works.
2026-11-14 14:47:56 +01:00
Daniel García 651ddbc2a1 Check membership for the organization public key in the handler
The public key routes serve the placeholder SSO organization's key to users
without an organization, so they couldn't require the member guard, but it
still ran for every request: it logged a membership error for each legitimate
v2 JIT password setup and authenticated the request twice. The routes now
authenticate once, return the placeholder key first, and check the membership
themselves, with the same rule as the guard.
2026-11-14 14:47:56 +01:00
Daniel García 59fb09544c Add the account recovery V2 upgrade routes
GET /organizations/{orgId}/users/keys/pending-v2-upgrades lists, a page at a
time, the memberships whose account recovery key still wraps the member's V1
user key after an upgrade to V2 encryption, with the upgrade token the admin
re-wraps it from. POST .../v2-upgrades stores the re-wrapped keys in one
transaction, or unenrolls the members whose token holds no usable key. Only
Owners and Admins, an Admin never for Owners, and a membership that changed
since the read is skipped. GET /organizations/{orgId}/private-key gives the
organization's private key these need, to everyone with Manage users as
upstream. The SDK's encrypted migrator calls all three.

Enrolling in or withdrawing from account recovery drops a leftover upgrade
token, which would otherwise get a member who re-enrolled after the upgrade
unenrolled.
2026-11-14 14:47:56 +01:00
Tom e03151d307 [PR 4] Add Manage Users permission for Custom Roles (#7960) 2026-11-14 13:57:32 +01:00
Daniel García 644ddfa000 Speed up the Build workflow (#7961)
- Pass the MariaDB client library to mysqlclient-sys through its environment variables, so a cached build no longer recompiles mysqlclient-sys, diesel and diesel_migrations in every MySQL build. The cache prefix changes once, because the old caches would keep rebuilding it.
- Drop the sqlite,mysql,postgresql,enable_mimalloc test: enable_mimalloc is still tested on and off.
- Test and lint unsafe_test_mode in the all-features runs instead of separate SQLite-only ones: it only adds the test seeder.
- Only save caches on main, in the Build and SDK live tests workflows, to save cache space.
2026-11-14 10:53:09 +01:00
Daniel García df1e918c79 Define threat model (#7957)
* Define threat model

* Cover logout tokens, SSO identities and missing proxies in the threat model
2026-11-14 00:14:54 +01:00
StarHzjandDaniel García 258b7f0254 Add macOS native credential sync to supported feature flags (#7942)
* Add macOS native credential sync to supported feature flags

* Document the macOS native credential sync flag in .env.template

* Align the macOS passkey provider description with Windows

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-11-13 20:38:00 +01:00
Tom a8de8c7820 [PR 3] Add collection permissions for Custom Roles (#7951)
* Add Custom Roles collection permissions

* Return NotFound for denied collection access

* Grant Manage on imported collections to every Custom member
2026-11-13 20:24:59 +01:00
Daniel García d2d1294098 Run the Bitwarden SDK's live end-to-end tests against Vaultwarden (#7953)
* Add a test seeder behind the unsafe_test_mode feature

For the end-to-end tests: with the feature, /test/seeder answers the calls the
SDK's live tests make to a seeder API (POST /seed with SingleUserScene or
TestVectorUserScene, DELETE /seed/<playId>), behind HTTP basic auth from
TEST_SEEDER_USERNAME and TEST_SEEDER_PASSWORD, and 404 without them.

It creates accounts without any checks, so no release build has the feature.
CI builds and tests it with sqlite.

* Run the SDK live tests against the test seeder

tools/sdk-live-tests/run.sh builds the server with unsafe_test_mode and runs
the live tests of the SDK's e2e/wasm suite. For SDK versions whose tests log in
to one given account, each user test vector with a master password is seeded
with its vault on a fresh server; for versions that seed their own accounts,
the tests get the seeder's address. Every vector runs, then a summary.

The workflow runs it by hand, and on changes to the seeder or the runner.

* Run the SDK live tests daily

* Test the published SDK package by default in the SDK live tests
2026-11-13 19:48:10 +01:00
Daniel García 730048e3c6 Move the cipher content mapping into its own function (#7952)
save_cipher_data copies the request's type data or blob, name, notes, fields,
password history, key and reprompt onto the cipher through set_cipher_content
now, so code that stores a cipher without going through a request can reuse
it. No behaviour change.
2026-11-13 18:59:10 +01:00
Tom 1430a64547 [PR 2] Add administrative cipher access for Custom Roles (#7938)
* Separate administrative cipher access

* Return NotFound for admin attachments and ungate assigned ciphers
2026-11-13 14:31:57 +01:00
Daniel García 7c82fbed0c Implement V2 key rotation support (#7766)
* Implement V2 key rotation support

* Clear user key ids left stale by key rotations

* Refuse key ids that aren't 32 lowercase hex characters, like upstream

* Save the signature key pair before the v2 account state

* Gate v2 logins on upstream's v2 encryption check

* Document the v2 encryption feature flags

* Remove the v2 upgrade feature flags

With force-upgrade-v2-encryption on, the clients loop on any v1 account that stays v1 on a vault route: the migration check runs fullSync(false), which writes lastSync, which triggers the scheduler again. No server response stops it, so don't serve the flag until the clients fix it.

pm-30144-sdk-key-rotation and pm-31050-no-logout-key-upgrade-rotation only serve that upgrade, so they go too.

* Move the v2 upgrade token migration to its own version

Diesel identifies a migration by the date in its folder name alone, and silently drops one of two folders that share it. The Custom Roles migration uses the same date.

* Refuse rotations from a replaced user key, like upstream

Both rotation endpoints accept the optional oldUserKeyId. When it and the
stored key id are both set and differ, the rotation is refused with a 409
asking the client to sync, before the account keys are checked or anything
is written.

Also correct the comment on saving the signature key pair first: by then the
vault data is already re-encrypted, so a failure there doesn't keep the
account usable.

* Update the v2 client minimum in .env.template and the key pair comments

V2 accounts need 2026.10.1, or 2026.10.2 for the desktop app, so the per-flag versions are only enough to create one. The comments on saving the signature key pair now cover the rotation of an account that is already v2.
2026-11-12 22:30:39 +01:00
Daniel García d28c8339fb Implement V2 registration support (#7765)
* Implement V2 registration support

* Require the client version header on every login, like upstream

Every user login (password, user API key, SSO, including a first SSO login)
now needs the Bitwarden-Client-Version header, for V1 accounts too. V2
accounts need 2026.10.1, or 2026.10.2 for the desktop app, upstream's
current minimum.

* Escape the email in the client version log line

On a first SSO login the email comes from the identity provider and isn't validated yet, so a newline in it could add a line to the log.
2026-11-12 22:30:20 +01:00
Daniel García 5ad0c6d41a Support blob-encrypted ciphers and match upstream's key id checks (#7764) 2026-11-12 22:30:00 +01:00
miquno 17b159beff Don't let OpenDAL create a missing config folder (#7935)
The Fs operator creates its root folder if it doesn't exist, so loading
the config created the data folder (or failed with a misleading invalid
config.json error when it couldn't). Skip building the operator if the
local config folder doesn't exist, so a missing data folder is reported
again like before.
2026-11-04 22:17:54 +01:00
Tom 904b80115d [PR 1] Add experimental Custom Roles foundation (#7847) 2026-11-04 22:11:52 +01:00
Daniel García 82fc47e83e Redact query values in request logs (#7929) 2026-11-03 00:12:56 +01:00
Tom 286499781a Add Fill Assist feature flag in .env.template (#7928) 2026-11-02 20:28:51 +01:00
71 changed files with 6168 additions and 988 deletions

No files matched your search

+14
View File
@@ -396,6 +396,7 @@
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
## ##
## The following flags are available: ## The following flags are available:
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0) ## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
@@ -410,6 +411,10 @@
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0) ## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers. ## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet. ## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
## - "macos-native-credential-sync": Enable the desktop app as a passkey provider on macOS, so passkeys in the vault can be used by other apps and browsers.
## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1)
## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0)
## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one.
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS= # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
@@ -496,6 +501,15 @@
## KNOW WHAT YOU ARE DOING! ## KNOW WHAT YOU ARE DOING!
# ORG_GROUPS_ENABLED=false # ORG_GROUPS_ENABLED=false
## EXPERIMENTAL FEATURE: Custom roles
## Enables the Custom member role with granular permissions for organizations.
## This is still being developed: only the permissions this version enforces can be granted.
## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS.
## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled.
## Disabled by default because this is an experimental feature!
## KNOW WHAT YOU ARE DOING!
# EXPERIMENTAL_CUSTOM_ROLES=false
## Increase secure note size limit (Know the risks!) ## Increase secure note size limit (Know the risks!)
## Sets the secure note size limit to 100_000 instead of the default 10_000. ## Sets the secure note size limit to 100_000 instead of the default 10_000.
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
+1
View File
@@ -4,3 +4,4 @@
/.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex /.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex
/.github/workflows/** @dani-garcia @BlackDex /.github/workflows/** @dani-garcia @BlackDex
/SECURITY.md @dani-garcia @BlackDex /SECURITY.md @dani-garcia @BlackDex
/THREAT_MODEL.md @dani-garcia @BlackDex
+27 -16
View File
@@ -57,7 +57,23 @@ jobs:
steps: steps:
# Install dependencies # Install dependencies
- name: "Install dependencies Ubuntu" - name: "Install dependencies Ubuntu"
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config
# Pass the MariaDB client library to mysqlclient-sys through its environment variables.
# When it finds the library with pkg-config, its build script makes cargo rerun it whenever
# anything in the library directory changes, and the install above changes that directory on
# every run. So every MySQL build recompiled mysqlclient-sys, diesel and diesel_migrations,
# even with a cached build. Cargo compares environment variables by their value instead.
lib_dir="$(pkg-config --variable=libdir mysqlclient)"
version="$(pkg-config --modversion mysqlclient)"
{
echo "MYSQLCLIENT_NO_PKG_CONFIG=1"
echo "LIBMARIADB_NO_PKG_CONFIG=1"
echo "MYSQLCLIENT_LIB_DIR=${lib_dir}"
echo "MYSQLCLIENT_VERSION=${version}"
} | tee -a "${GITHUB_ENV}"
# End Install dependencies # End Install dependencies
# Checkout the repo # Checkout the repo
@@ -118,22 +134,19 @@ jobs:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example. # Like changing the build host from Ubuntu 20.04 to 22.04 for example.
# Only update when really needed! Use a <year>.<month>[.<inc>] format. # Only update when really needed! Use a <year>.<month>[.<inc>] format.
prefix-key: "v2025.09-rust" prefix-key: "v2026.11-rust"
# Only save caches on main to save cache space.
save-if: ${{ github.ref == 'refs/heads/main' }}
# End Enable Rust Caching # End Enable Rust Caching
# Run cargo tests # Run cargo tests
# First test all features together, afterwards test them separately. # First test all features together, afterwards test them separately.
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3" # enable_mimalloc and unsafe_test_mode are additive, so we test them once here.
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3,unsafe_test_mode"
id: test_sqlite_mysql_postgresql_mimalloc_s3 id: test_sqlite_mysql_postgresql_mimalloc_s3
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
run: | run: |
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3 cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3,unsafe_test_mode
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
id: test_sqlite_mysql_postgresql_mimalloc
if: ${{ !cancelled() }}
run: |
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc
- name: "test features: sqlite,mysql,postgresql" - name: "test features: sqlite,mysql,postgresql"
id: test_sqlite_mysql_postgresql id: test_sqlite_mysql_postgresql
@@ -162,11 +175,11 @@ jobs:
# Run cargo clippy, and fail on warnings # Run cargo clippy, and fail on warnings
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc,s3" - name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc,s3,unsafe_test_mode"
id: clippy id: clippy
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }} if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
run: | run: |
cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3 cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3,unsafe_test_mode
# End Run cargo clippy # End Run cargo clippy
@@ -185,7 +198,6 @@ jobs:
if: ${{ failure() }} if: ${{ failure() }}
env: env:
TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }} TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }}
TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }} TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
TEST_SQLITE: ${{ steps.test_sqlite.outcome }} TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
TEST_MYSQL: ${{ steps.test_mysql.outcome }} TEST_MYSQL: ${{ steps.test_mysql.outcome }}
@@ -197,13 +209,12 @@ jobs:
echo "" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}"
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}" echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}" echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3,unsafe_test_mode)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}" echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3,unsafe_test_mode)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}" echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
echo "" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}"
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}" echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
+166
View File
@@ -0,0 +1,166 @@
name: SDK live tests
permissions: {}
# Runs the live-server end-to-end tests of bitwarden/sdk-internal against this branch, see tools/sdk-live-tests/run.sh.
# By default it tests the latest @bitwarden/sdk-internal on npm, which the SDK publishes for every commit to its main
# branch, with the e2e tests from that commit. A manual run can name an npm version instead, or a branch, tag or commit
# to build the SDK from. It runs daily, by hand, and on changes to the test seeder or the runner.
on:
schedule:
# 04:15 UTC, off the top of the hour, when GitHub may delay or drop scheduled runs
- cron: "15 4 * * *"
workflow_dispatch:
inputs:
sdk_npm_version:
description: "Version of @bitwarden/sdk-internal on npm to test (empty for the latest)"
required: false
default: ""
sdk_ref:
description: "Or a branch, tag or commit of bitwarden/sdk-internal to build and test (slower)"
required: false
default: ""
pull_request:
paths:
- ".github/workflows/sdk-live-tests.yml"
- "src/api/test_seeder.rs"
- "tools/sdk-live-tests/**"
defaults:
run:
shell: bash
jobs:
sdk-live-tests:
name: SDK live tests
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
# Less to compile and link, and a smaller cache. Panics still show file and line.
CARGO_PROFILE_DEV_DEBUG: "0"
steps:
- name: "Install dependencies Ubuntu"
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential libssl-dev pkg-config
- name: "Checkout"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# A published package (npm) or a build from a git ref (git), and the sdk-internal commit to check out
- name: "Pick the SDK"
id: sdk
env:
NPM_VERSION: ${{ inputs.sdk_npm_version }}
GIT_REF: ${{ inputs.sdk_ref }}
run: |
if [[ -n "${NPM_VERSION}" && -n "${GIT_REF}" ]]; then
echo "::error::Give an npm version or a git ref, not both"
exit 1
fi
if [[ -n "${GIT_REF}" ]]; then
{
echo "source=git"
echo "ref=${GIT_REF}"
} >> "${GITHUB_OUTPUT}"
echo "SDK: bitwarden/sdk-internal \`${GIT_REF}\`, built from source" >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
spec="${NPM_VERSION:-latest}"
if [[ ! "${spec}" =~ ^[0-9A-Za-z.+-]+$ ]]; then
echo "::error::That is not an npm version or tag"
exit 1
fi
manifest="$(curl -fsS --retry 3 "https://registry.npmjs.org/@bitwarden%2Fsdk-internal/${spec}")"
version="$(jq -r .version <<< "${manifest}")"
commit="$(jq -r .gitHead <<< "${manifest}")"
if [[ ! "${commit}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::@bitwarden/sdk-internal@${version} doesn't name the commit it was built from"
exit 1
fi
{
echo "source=npm"
echo "version=${version}"
echo "ref=${commit}"
} >> "${GITHUB_OUTPUT}"
echo "SDK: @bitwarden/sdk-internal ${version} from npm, e2e tests at ${commit}" >> "${GITHUB_STEP_SUMMARY}"
- name: "Checkout sdk-internal"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: bitwarden/sdk-internal
ref: ${{ steps.sdk.outputs.ref }}
path: sdk-internal
persist-credentials: false
# Each checkout pins its own toolchain in rust-toolchain.toml. The SDK's is only needed to build it.
- name: "Install toolchains"
env:
SDK_SOURCE: ${{ steps.sdk.outputs.source }}
run: |
rustup toolchain install
if [[ "${SDK_SOURCE}" == git ]]; then
cd sdk-internal
rustup toolchain install
rustup target add wasm32-unknown-unknown
rustup component add rust-src
fi
# One cache per SDK source, so that neither restores the other's.
# Only save caches on main to save cache space.
- name: "Cache Rust builds"
if: ${{ steps.sdk.outputs.source == 'npm' }}
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: sdk-live-tests-npm
add-job-id-key: false
workspaces: ". -> target"
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: "Cache Rust builds (with the SDK)"
if: ${{ steps.sdk.outputs.source == 'git' }}
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: sdk-live-tests-sdk-build
add-job-id-key: false
workspaces: |
. -> target
sdk-internal -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: "Setup Node"
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: sdk-internal/.nvmrc
# Where the SDK's build would put it
- name: "Download the SDK package"
if: ${{ steps.sdk.outputs.source == 'npm' }}
env:
SDK_VERSION: ${{ steps.sdk.outputs.version }}
SDK_COMMIT: ${{ steps.sdk.outputs.ref }}
run: |
# npm checks the tarball against the registry's checksum
tarball="$(npm pack "@bitwarden/sdk-internal@${SDK_VERSION}" --pack-destination "${RUNNER_TEMP}" --json | jq -r '.[0].filename')"
package_dir=sdk-internal/crates/bitwarden-wasm-internal/npm
tar -xzf "${RUNNER_TEMP}/${tarball}" --strip-components=1 -C "${package_dir}"
if [[ "$(cat "${package_dir}/VERSION")" != "${SDK_COMMIT}" ]]; then
echo "::error::The package's VERSION doesn't match its commit ${SDK_COMMIT}"
exit 1
fi
- name: "Run the SDK live tests"
env:
SDK_SOURCE: ${{ steps.sdk.outputs.source }}
run: |
args=(--sdk-dir sdk-internal)
if [[ "${SDK_SOURCE}" == npm ]]; then
args+=(--skip-sdk-build)
fi
tools/sdk-live-tests/run.sh "${args[@]}"
- name: "Upload logs"
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: sdk-live-tests-logs
path: target/sdk-live-tests/**/*.log
+2 -3
View File
@@ -14,9 +14,8 @@ extend-ignore-re = [
# In SMTP it's called HELO, so ignore it # In SMTP it's called HELO, so ignore it
"(?i)helo_name", "(?i)helo_name",
"Server name sent during.+HELO", "Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items # COSE Is short for CBOR Object Signing and Encryption
"COSEKey", "(?i)cose",
"COSEAlgorithm",
# Ignore this specific string as it's valid # Ignore this specific string as it's valid
"Ensure they are valid OTPs", "Ensure they are valid OTPs",
# This word is misspelled upstream # This word is misspelled upstream
Generated
+1
View File
@@ -6270,6 +6270,7 @@ dependencies = [
"rustls", "rustls",
"semver", "semver",
"serde", "serde",
"serde_cbor_2",
"serde_json", "serde_json",
"subtle", "subtle",
"svg-hush", "svg-hush",
+5 -1
View File
@@ -57,6 +57,9 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
# Currently only used to enable rusts official ip support # Currently only used to enable rusts official ip support
unstable = [] unstable = []
# Enables special test mode features. This should NEVER be enabled in production
unsafe_test_mode = ["dep:serde_cbor_2"]
[target."cfg(unix)".dependencies] [target."cfg(unix)".dependencies]
nix = { version = "0.31.3", features = ["fs"] } nix = { version = "0.31.3", features = ["fs"] }
# Logging # Logging
@@ -107,7 +110,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154" serde_json = "1.0.154"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] } diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [ derive_more = { version = "2.1.1", features = [
@@ -157,6 +160,7 @@ yubico_ng = { version = "1.0.0", default-features = false }
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs-proto = "0.5.5" webauthn-rs-proto = "0.5.5"
webauthn-rs-core = "0.5.5" webauthn-rs-core = "0.5.5"
serde_cbor_2 = { version = "0.13.0", optional = true }
# Handling of URL's for WebAuthn and favicons # Handling of URL's for WebAuthn and favicons
url = "2.5.8" url = "2.5.8"
+24 -35
View File
@@ -1,49 +1,38 @@
Vaultwarden tries to prevent security issues but there could always slip something through. Vaultwarden tries to prevent security issues, but something could always slip through. If you believe you've found a security issue in our application, we encourage you to notify us. We welcome working with you to resolve the issue promptly. Thanks in advance!
If you believe you've found a security issue in our application, we encourage you to
notify us. We welcome working with you to resolve the issue promptly. Thanks in advance!
# Disclosure Policy # Scope
- Let us know as soon as possible upon discovery of a potential security issue, and we'll make every Security issues in the current release of Vaultwarden and in the `main` branch (the `testing` image) are in scope. Our [threat model](THREAT_MODEL.md) describes who is trusted with what, what is out of scope, and how we rate severity. Please read it before reporting.
effort to quickly resolve the issue.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
third-party. We may publicly disclose the issue before resolving it, if appropriate.
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
degradation of our service. Only interact with accounts you own or with explicit permission of the
account holder.
# In-scope # How to report a security issue
- Security issues in any current release of Vaultwarden. Source code is available at https://github.com/dani-garcia/vaultwarden. This includes the current `latest` release and `main / testing` release. - Let us know as soon as possible upon discovery of a potential security issue, and we'll make every effort to quickly resolve the issue.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a third party. We may publicly disclose the issue before resolving it, if appropriate.
- Credit goes on the advisory and needs a GitHub account or email address. Reports of an issue whose fix is already public, from the moment its pull request is opened, are normally not credited.
- Vaultwarden is an open source project and does not offer rewards.
# Exclusions Vaultwarden has no hosted service. Test only on instances you run yourself, and make a good-faith effort to avoid privacy violations, destruction of data, and interruption or degradation of other people's servers. Only interact with accounts you own or with the explicit permission of the account holder.
The following bug classes are out-of scope:
- Bugs that are already reported on Vaultwarden's issue tracker (https://github.com/dani-garcia/vaultwarden/issues)
- Bugs that are not part of Vaultwarden, like on the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated
- Issues in an upstream software dependency (ex: Rust, or External Libraries) which are already reported to the upstream maintainer
- Attacks requiring physical access to a user's device
- Issues related to software or protocols not under Vaultwarden's control
- Vulnerabilities in outdated versions of Vaultwarden
- Missing security best practices that do not directly lead to a vulnerability (You may still report them as a normal issue)
- Issues that do not have any impact on the general public
While researching, we'd like to ask you to refrain from: While researching, we'd like to ask you to refrain from:
- Denial of service - Denial of service against servers you do not run. You can still report availability issues you reproduce on your own instance.
- Spamming - Spamming.
- Social engineering (including phishing) of Vaultwarden developers, contributors or users - Social engineering (including phishing) of Vaultwarden developers, contributors or users.
Thank you for helping keep Vaultwarden and our users safe! A good report covers one root cause and states the version or commit you tested, the lowest privileges needed, what the attacker gains, any non-default settings, the steps to reproduce it, and whether you ran them or only read the code.
If you use AI tools, you remain responsible for what you send. Reproduce the issue on your own instance before reporting it, check its scope and severity against our threat model rather than the tool's assessment, and include the requests, responses or logs that show it.
# How to contact us # How to contact us
- You can contact us on Matrix https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`) - You can open a [private vulnerability report](https://github.com/dani-garcia/vaultwarden/security/advisories/new) on GitHub.
- You can send an ![security-contact](/.github/security-contact.gif) to report a security issue.<br> - You can send an ![security-contact](/.github/security-contact.gif) to report a security issue.<br>
If you want to send an encrypted email you can use the following GPG key: 13BB3A34C9E380258CE43D595CB150B31F6426BC<br> If you want to send an encrypted email, you can use the following GPG key: `13BB3A34C9E380258CE43D595CB150B31F6426BC`<br>
It can be found on several public GPG key servers.<br> It can be found on several public GPG key servers.<br>
* https://keys.openpgp.org/search?q=security%40vaultwarden.org - https://keys.openpgp.org/search?q=security%40vaultwarden.org
* https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org - https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org
* https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index - https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
* https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index - https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
- You can contact us on Matrix for questions, without vulnerability details: https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`).
Thank you for helping keep Vaultwarden and our users safe!
+148
View File
@@ -0,0 +1,148 @@
# Vaultwarden threat model
To report a security issue, see [SECURITY.md](SECURITY.md).
## Overview
Vaultwarden is an unofficial, self-hosted server for the Bitwarden clients, written in Rust. It stores encrypted vaults, handles login, two-factor authentication, organizations, file and text sharing through Send, and emergency access, and provides an admin panel for the operator. It is not affiliated with Bitwarden. The browser, desktop, mobile and CLI clients come from Bitwarden. Our container images include a web vault built from Bitwarden's web client with our own patches.
## Security expectations
### Deployment
We expect a TLS-terminating reverse proxy in front of the server, `DOMAIN` set to its public URL, and an operator who keeps the host secure. This model covers Vaultwarden itself. Third-party software that consumes its output or sits in front of it, such as log parsers and reverse proxies, is the operator's to configure and secure.
### Encryption
Clients encrypt vault contents end to end. The server stores and serves ciphertext it cannot decrypt, and it never sees the master password. The server enforces access to data and actions itself, without relying on a client to behave. A few settings rely on the client for part of their effect, listed under [Out of scope](#out-of-scope).
A member receives the organization key when they are confirmed. Invited and accepted members do not have it. A revoked member who was once confirmed may still have it. Every confirmed member holds the same key for the whole organization, so only the server separates collections.
The server can read everything that is not end-to-end encrypted, such as user, group, device and organization names, email addresses, who owns what, item types and timestamps, attachment sizes, password hints, the stored master password hash and KDF settings, the TOTP and other 2FA secrets that protect login to the account, API keys, refresh tokens, security stamps, the JWT signing key, and SMTP and push credentials. TOTP secrets that users store in vault items are end-to-end encrypted with the rest of the item.
Someone who controls the server can also serve a modified web vault, so end-to-end encryption does not protect web vault users from them.
### Trust
Registration is open by default (`SIGNUPS_ALLOWED=true`), but many personal and small-team instances turn it off, so holding an account usually implies a minimal level of trust. Account holders are still never trusted with anyone else's data. Members of an organization have some trust in each other.
| Actor | Can reach | Must not reach |
|---|---|---|
| Anonymous internet | Login, prelogin, registration, public Send access, icons, the anonymous notifications hub, static files | Any vault, organization or member data; Sends they have no link to; the admin panel; the internal network through outbound requests |
| Websites and icon sources | The icon fetcher, through any response, redirect or DNS answer; the server, through cross-site requests from a visitor's browser | The internal network through outbound requests; script on the vault origin; acting through a visitor's session |
| Registered user | Their own vault, Sends, devices and settings; organizations they belong to | Another user's data; any other organization; raising their own role or status |
| Invited or accepted member | Accepting the invitation, enrolling in account recovery, and the organization's name, public key and policies | Items, collections, the member list, the organization key, any other privileged action before confirmation |
| Revoked member | Nothing in that organization | Any organization data from the server, even data they could once read |
| Organization User | Items in assigned collections, with `read_only` and `manage` enforced by the server | Other collections, write access to read-only collections, Admin-level metadata |
| Organization Manager (a legacy role, shown as Custom in clients) | The above, plus managing collections where they have `manage`. With "Manage all collections" (`access_all`), every collection and full member and group details | Without `access_all`: managing other collections, full member and group details. Always: changing policies or roles |
| Organization Admin | All data in that organization, members below Owner (some actions on Managers and Admins are limited to Owners), policies, events, account recovery for enrolled members below Owner | Owners, other organizations, the personal vault of a member who is not enrolled in recovery, the organization API key |
| Organization Owner | The whole organization, including account recovery for enrolled members | Other organizations, the personal vault of a member who is not enrolled in recovery |
| Organization API key holder (Owner-issued) | Directory import into its own organization | Other organizations, item content, Owners |
| Send recipient | One Send, within its limits | Other Sends, access beyond the configured limits, the owner's vault |
| Emergency access grantee | After approval or the wait time: the access the grantor configured, which can be full takeover by design | Anything before that, or more than the access the grantor configured |
| Admin panel (`ADMIN_TOKEN`) | All users, organizations and settings | No limit once authenticated |
| Host operator, database and data folder access | Everything | No limit |
| Services the operator configures (SSO provider, SMTP server, push relay, S3 endpoint, Yubico) | What they need for their function | Vault contents |
### Configuration
Optional features that operators turn on, other than experimental and beta ones, carry the same expectations as the defaults, such as SSO, organization events, anything needing SMTP (email 2FA, invitations), push, S3 storage, the admin panel, and the MySQL and PostgreSQL backends.
Some settings remove a protection on purpose, and using them is the operator's choice: `DISABLE_ADMIN_TOKEN`, a plain-text `ADMIN_TOKEN`, `IP_HEADER_TRUSTED_PROXIES=all` on a directly exposed instance, turning off `HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS`, `SSO_DEBUG_TOKENS`, `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`, `SSO_PKCE=false`, `SHOW_PASSWORD_HINT`, `SMTP_SECURITY=off`, `SMTP_ACCEPT_INVALID_CERTS`, `SMTP_ACCEPT_INVALID_HOSTNAMES`, `SMTP_DEBUG`, `ALLOWED_IFRAME_ANCESTORS`, `ALLOWED_CONNECT_SRC`, and `LOG_LEVEL` set to `debug` or `trace`. Issues that need one of them are out of scope.
### Upstream Bitwarden
Where this document does not cover a behavior, we follow upstream Bitwarden by default.
## Out of scope
These are intended behavior or outside this model. Several match upstream Bitwarden.
- What an admin token holder or the host operator can do, including running code on the server. We expect them to have admin access to the machine.
- Organization Owners and Admins reading all organization data, and account recovery of enrolled members.
- Organization Managers listing basic member details, such as names and emails. Upstream allows this for all members.
- Settings the client applies: `hide_passwords` ("hidden passwords" in the clients), master password reprompt, and organization policies on values the server never sees, such as master password complexity and password generator rules. The client already holds the data.
- Bugs in Bitwarden's client code, including in the web vault we bundle, such as CSV formula injection (exports are built in the browser) or a client failing on a malformed stored value. The advisory and CVE for those belong to Bitwarden. Report them through [Bitwarden's HackerOne program](https://hackerone.com/bitwarden/). When the bundled web vault is affected, we ship a security release that references theirs. Bugs that our own web vault patches add are ours.
- The server not decrypting or authenticating ciphertext. It holds no key.
- `/identity/accounts/prelogin` and `/identity/accounts/prelogin/password` returning KDF settings for any email, with deterministic fake settings for unknown ones.
- Login timing not being equalized.
- Probing whether an icon is already cached. Setting `ICON_SERVICE` to an external service avoids it.
- Download link replay: an attachment token or Send access token being reusable by its holder until it expires (1 and 2 minutes), and object storage presigned URLs that we cannot count or revoke. The holder already has the content.
- Short-lived tokens that stay valid after an authorization change elsewhere until they expire, such as an organization API token (1 hour) after its key is rotated. This includes connections the notifications WebSocket accepted before the change, which stay open and carry change notifications and device login requests, not vault data.
- The access token in the notifications WebSocket's `access_token` query parameter, which reverse proxies may log. Clients send it that way, and our [hardening guide](https://github.com/dani-garcia/vaultwarden/wiki/Hardening-Guide#access-logs-contain-access_token-parameter) covers redacting it.
- A client's tokens staying valid after it logs out. Logging out discards them on that device, as upstream, and deauthorizing sessions or changing the password ends them.
- The personal API key skipping 2FA, and working after a password change until the user rotates it. It is a separate credential.
- A linked SSO identity still logging in after a master password change, and the SSO provider deciding how long a session lasts (`SSO_AUTH_ONLY_NOT_SESSION` turns that off). The provider manages that credential, and it doesn't unlock the vault.
- A revoked or removed member's login session staying valid. It belongs to their own account, and the server checks membership on every request.
- A revoked or removed member keeping what their client already synced, and the organization key not being rotated when a member leaves.
- Unauthenticated requests to `/api/devices/identifier/<id>/clear-token`. The impact is delayed push delivery.
- Limits we leave out or keep simple by design: no cap on the number of items a user stores, no cleanup of attachments reserved but never uploaded, and rate limits kept in memory per process and IP, which reset on restart and are shared by users behind one address.
- Log injection. It only affects software that parses our logs, such as Fail2Ban with our community wiki's filters.
- Attacks that need a missing or misconfigured reverse proxy, such as one not sending `X-Forwarded-Proto` (it decides the admin cookie's `Secure` flag).
- Attacks that need another component to break its own specification, such as a NAT64 translator that forwards the `64:ff9b::/96` prefix to private addresses, against RFC 6052.
- Outbound requests to the SSO provider, Yubico server or S3 endpoint the operator configures, which skip the outbound filter and can be on the internal network.
- File permissions in the data folder. Host security belongs to the operator.
- Attacks that need physical access to a user's device.
- Self-exploitation, where someone can only affect their own account, such as self-XSS or weak KDF settings they choose.
- Text that users choose showing up in emails or pages, such as an organization, user or device name, as long as it can't cause XSS or modify the page.
- Dependency and base image CVEs without a path reachable through Vaultwarden.
- Packages, images and deployment templates that we don't publish. Report those to their maintainers.
- Missing best practices with no demonstrated impact, such as security headers, `style-src 'unsafe-inline'` (the web vault needs it) or version disclosure, and design recommendations without a concrete issue.
- Bugs already reported on our [issue tracker](https://github.com/dani-garcia/vaultwarden/issues).
- Experimental and beta features. They are opt-in and outside this model until they are stable.
- Issues that only affect outdated versions.
## Severity
We rate issues by what the attacker ends up with, such as decrypted data, ciphertext, or the power to change or delete data, and by the access they needed first. An attack that needs no account usually rates higher than one that does. Our ratings can differ from CVSS 3.1, which doesn't translate well to an end-to-end encrypted system. Older advisories may be rated differently.
Denial of service and resource exhaustion only count if they still apply with a reverse proxy in front. Races, denial of service and resource exhaustion are never rated above Medium.
Secrets written to logs at the default log level are rated by what they give someone who can read the logs but not the host.
### Critical
- Reading another account's data decrypted, with or without an account, such as through script on the vault origin or exposed key material.
- Code execution on the server by anyone other than an admin token holder.
- Changing released builds or images, including through our CI workflows or their secrets.
- Anyone other than the named grantee reaching an emergency access outcome.
- With no account: forging login or admin tokens, reading the JWT signing key, reaching the admin panel without the token, or taking over accounts with no action by the victim.
### High
- Decrypted reads within the same organization, such as a member reaching collections they are not assigned to.
- Changing or deleting another user's or organization's existing data.
- Reading another user's server-held secrets, such as their 2FA secrets, API keys, stored master password hash or encrypted user key.
- SSRF that returns the response to the attacker.
- Escalating within an organization.
- Account takeover through SSO.
- The named emergency access grantee reaching the outcome early.
- Script or a cross-site request acting through an admin panel session.
- Script on the vault origin that needs unusual victim interaction.
### Medium
- Ciphertext read by someone with no key.
- Defeating login brute-force protection.
- 2FA bypass that needs the password, such as a remembered device still skipping 2FA after the method is removed.
- A refresh token still working after a password change or "Deauthorize sessions".
- Getting past an organization policy the server enforces.
- Adding data to another user's or organization's space without permission.
- Data loss for another user or organization that the attacker cannot reliably trigger or aim.
- SSRF where the response is filtered or blind.
- Reading a Send that is disabled or expired.
- Denial of service that lasts until someone restarts the server or repairs data, from one request with no account in the default configuration.
### Low
These may be handled as ordinary hardening.
- Behavior that differs from upstream Bitwarden with low impact.
- Bypassing a non-default limit, such as `USER_ATTACHMENT_LIMIT`.
- Metadata across a boundary, such as who is assigned to which collection.
- Exceeding a Send's access limits when the attacker already holds the link.
- A missing rate limit outside login.
- Forging organization audit events.
- Other ways to tell whether an email has an account.
- Denial of service that lasts until someone acts, from one operation that needs an account or a non-default setting.
- Denial of service that lasts only while the attacker keeps going or that recovers by itself, such as a crash the container restarts from.
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,14 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
@@ -0,0 +1,22 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything
-- is dropped: change their role first, or restore the backup taken before the upgrade.
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TEMPORARY TABLE custom_role_downgrade;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;
@@ -0,0 +1,13 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
Whitespace-only changes.
@@ -0,0 +1,4 @@
-- Key rotations before this kept the id of the old user key, so every new
-- cipher was refused from then on. Stale ids can't be told apart, so clear
-- all of them; clients record their current key's id again when it's empty.
UPDATE users SET key_id = NULL;
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- TEXT has no size limit here, only MySQL needed the change.
-- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op.
SELECT 1;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
@@ -0,0 +1,20 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TABLE custom_role_downgrade;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;
@@ -0,0 +1,13 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
Whitespace-only changes.
@@ -0,0 +1,4 @@
-- Key rotations before this kept the id of the old user key, so every new
-- cipher was refused from then on. Stale ids can't be told apart, so clear
-- all of them; clients record their current key's id again when it's empty.
UPDATE users SET key_id = NULL;
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
@@ -0,0 +1,19 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TABLE custom_role_downgrade;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;
@@ -0,0 +1,11 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE
Whitespace-only changes.
@@ -0,0 +1,4 @@
-- Key rotations before this kept the id of the old user key, so every new
-- cipher was refused from then on. Stale ids can't be told apart, so clear
-- all of them; clients record their current key's id again when it's empty.
UPDATE users SET key_id = NULL;
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN v2_upgrade_token;
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
+35 -7
View File
@@ -1,4 +1,4 @@
use std::{env, sync::LazyLock}; use std::{collections::HashMap, env, sync::LazyLock};
use reqwest::Method; use reqwest::Method;
use rocket::{ use rocket::{
@@ -26,6 +26,7 @@ use crate::{
models::{ models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
custom_roles_enabled,
}, },
}, },
error::{Error, MapResult}, error::{Error, MapResult},
@@ -256,6 +257,7 @@ struct AdminTemplateData {
logged_in: bool, logged_in: bool,
urlpath: String, urlpath: String,
sso_enabled: bool, sso_enabled: bool,
custom_roles_enabled: bool,
} }
impl AdminTemplateData { impl AdminTemplateData {
@@ -266,6 +268,7 @@ impl AdminTemplateData {
logged_in: true, logged_in: true,
urlpath: CONFIG.domain_path(), urlpath: CONFIG.domain_path(),
sso_enabled: CONFIG.sso_enabled(), sso_enabled: CONFIG.sso_enabled(),
custom_roles_enabled: custom_roles_enabled(),
} }
} }
@@ -390,6 +393,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string())); usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
.await
.into_iter()
.map(|member| (member.org_uuid.to_string(), member.atype))
.collect();
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
org["type"] = json!(atype);
}
}
users_json.push(usr); users_json.push(usr);
} }
@@ -430,7 +445,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notif
if res.is_ok() { if res.is_ok() {
nt.send_logout(&user, None, &conn).await; nt.send_logout(&user, None, &conn).await;
if has_push_device { if has_push_device {
push_logout_to_relay(&user, None); push_logout_to_relay(&user, None, None);
} }
} }
@@ -565,10 +580,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32 // Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
} else { // are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
err!("Invalid type") Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
MembershipType::Manager
}
Some(new_type) => new_type,
None => err!("Invalid type"),
}; };
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
@@ -578,7 +597,16 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
} }
} }
member_to_edit.atype = new_type; // A Manager only becomes a Custom member if that changes nothing they may do. Here they would keep the permissions
// they are shown with, the admin panel can't give up their access to all collections, so that is refused.
if new_type == MembershipType::Custom
&& let Some(loss) =
member_to_edit.custom_role_conversion_loss(&member_to_edit.legacy_manager_permissions(), &conn).await
{
err!(loss)
}
member_to_edit.set_type(new_type);
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
+967 -168
View File
File diff suppressed because it is too large. Load diff
+920 -305
View File
File diff suppressed because it is too large. Load diff
+44 -2
View File
@@ -15,8 +15,9 @@ use crate::{
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, Attachment, AttachmentId, Cipher, CipherId, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess,
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType,
OrgPolicy, TwoFactor, User, UserId,
}, },
}, },
mail, mail,
@@ -42,6 +43,7 @@ pub fn routes() -> Vec<Route> {
takeover_emergency_access, takeover_emergency_access,
password_emergency_access, password_emergency_access,
view_emergency_access, view_emergency_access,
get_emergency_access_attachment,
policies_emergency_access, policies_emergency_access,
] ]
} }
@@ -616,6 +618,46 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con
}))) })))
} }
/// An attachment of the grantor's for a grantee with approved view access, with a `url` to download it from: the view
/// lists the attachments with one that only names the file. Only the grantor's own ciphers, the ones the view lists.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Auth/Controllers/EmergencyAccessController.cs#L219-L226>
#[get("/emergency-access/<emer_id>/<cipher_id>/attachment/<attachment_id>")]
async fn get_emergency_access_attachment(
emer_id: EmergencyAccessId,
cipher_id: CipherId,
attachment_id: AttachmentId,
headers: Headers,
conn: DbConn,
) -> JsonResult {
check_emergency_access_enabled()?;
let Some(emergency_access) =
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await
else {
err!("Emergency access not valid.")
};
if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) {
err!("Emergency access not valid.")
}
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
err!("Cipher doesn't exist")
};
if cipher.user_uuid.as_ref() != Some(&emergency_access.grantor_uuid) || cipher.organization_uuid.is_some() {
err!("Cipher is not accessible")
}
match Attachment::find_by_id(&attachment_id, &conn).await {
Some(attachment) if attachment.cipher_uuid == cipher_id => {
Ok(Json(attachment.to_json_for_download(&headers.host).await?))
}
Some(_) => err!("Attachment doesn't belong to cipher"),
None => err!("Attachment doesn't exist"),
}
}
#[post("/emergency-access/<emer_id>/takeover")] #[post("/emergency-access/<emer_id>/takeover")]
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
check_emergency_access_enabled()?; check_emergency_access_enabled()?;
+4 -2
View File
@@ -10,7 +10,9 @@ use crate::{
auth::{AdminHeaders, Headers}, auth::{AdminHeaders, Headers},
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, models::{
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
},
}, },
error::MapResult, error::MapResult,
util::parse_date, util::parse_date,
@@ -220,7 +222,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
// user can actually access it instead of trusting the provided cipher uuid. // user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id if let Some(cipher_uuid) = &event.cipher_id
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await && cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await
&& let Some(org_id) = cipher.organization_uuid && let Some(org_id) = cipher.organization_uuid
{ {
log_event_impl( log_event_impl(
+2
View File
@@ -14,6 +14,8 @@ pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciph
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
pub use events::{event_cleanup_job, log_event, log_user_event}; pub use events::{event_cleanup_job, log_event, log_user_event};
pub use sends::purge_sends; pub use sends::purge_sends;
#[cfg(feature = "unsafe_test_mode")]
pub use {ciphers::seed_cipher, sends::seed_send};
use reqwest::Method; use reqwest::Method;
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
File diff suppressed because it is too large. Load diff
+17
View File
@@ -126,6 +126,23 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c
Ok(()) Ok(())
} }
/// Stores a text Send that is already encrypted, as [`create_send`] does, for the test seeder (`unsafe_test_mode`).
/// Its deletion date becomes a week from now: a recorded one can be years ahead, more than a Send may be.
#[cfg(feature = "unsafe_test_mode")]
pub async fn seed_send(mut encrypted: Value, user_id: &UserId, conn: &DbConn) -> ApiResult<SendId> {
let Some(fields) = encrypted.as_object_mut() else {
err!("A seeded Send has to be a JSON object")
};
fields.insert("deletionDate".to_owned(), serde_json::to_value(Utc::now() + TimeDelta::try_days(7).unwrap())?);
let data: SendData = serde_json::from_value(encrypted)?;
if data.r#type != SendType::Text as i32 {
err!("Only text Sends can be seeded: a file Send needs its file")
}
let mut send = create_send(data, user_id.clone())?;
send.save(conn).await?;
Ok(send.uuid)
}
fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> { fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
let data_val = if data.r#type == SendType::Text as i32 { let data_val = if data.r#type == SendType::Text as i32 {
data.text data.text
+133 -35
View File
@@ -29,7 +29,7 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError,
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
TwoFactorType, User, UserId, TwoFactorType, User, UserId,
}, },
@@ -77,14 +77,14 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
password_login(data, &mut user_id, &conn, &client_header.ip).await password_login(data, &mut user_id, &conn, &client_header).await
} }
"client_credentials" => { "client_credentials" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?; check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?; check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
api_key_login(data, &mut user_id, &conn, &client_header.ip).await api_key_login(data, &mut user_id, &conn, &client_header).await
} }
"authorization_code" if CONFIG.sso_enabled() => { "authorization_code" if CONFIG.sso_enabled() => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
@@ -95,7 +95,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
sso_login(data, &mut user_id, &conn, &client_header.ip).await sso_login(data, &mut user_id, &conn, &client_header).await
} }
"authorization_code" => err!("SSO sign-in is not available"), "authorization_code" => err!("SSO sign-in is not available"),
"send_access" => { "send_access" => {
@@ -179,7 +179,13 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
} }
// After exchanging the code we need to check first if 2FA is needed before continuing // After exchanging the code we need to check first if 2FA is needed before continuing
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn sso_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
client_header: &ClientHeaders,
) -> JsonResult {
let ip = &client_header.ip;
AuthMethod::Sso.check_scope(data.scope.as_ref())?; AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login // Ratelimit the login
@@ -317,6 +323,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
} }
let mut user = User::new(&user_infos.email, user_infos.user_name.clone()); let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
// Before saving, so that a client that can't log in doesn't create the account
check_client_version(&user, client_header)?;
user.verified_at = Some(now); user.verified_at = Some(now);
user.save(conn).await?; user.save(conn).await?;
@@ -325,6 +333,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
(user, device, None, None) (user, device, None, None)
} }
Some((user, _)) if !user.enabled => { Some((user, _)) if !user.enabled => {
// Set in both arms, not only after the match, so that their failed logins are logged
*user_id = Some(user.uuid.clone());
err!( err!(
"This user has been disabled", "This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.email), format!("IP: {}. Username: {}.", ip.ip, user.email),
@@ -334,6 +344,9 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
) )
} }
Some((mut user, sso_user)) => { Some((mut user, sso_user)) => {
*user_id = Some(user.uuid.clone());
check_client_version(&user, client_header)?;
let mut device = get_device(&data, conn, &user).await?; let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
@@ -368,7 +381,13 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn password_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
client_header: &ClientHeaders,
) -> JsonResult {
let ip = &client_header.ip;
// Validate scope // Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?; AuthMethod::Password.check_scope(data.scope.as_ref())?;
@@ -438,6 +457,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
) )
} }
check_client_version(&user, client_header)?;
// Change the KDF Iterations (only when not logging in with an auth request) // Change the KDF Iterations (only when not logging in with an auth request)
if data.auth_request.is_none() { if data.auth_request.is_none() {
kdf_upgrade(&mut user, password, conn).await?; kdf_upgrade(&mut user, password, conn).await?;
@@ -499,6 +520,66 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }
/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account
/// ([`User::has_v2_encryption`]) a client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the
/// credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2.
///
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L33-L79>
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Core/KeyManagement/Constants.cs#L5-L6>
fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult {
let Some((error, description)) = client_version_error(
user.has_v2_encryption(),
client_header.client_version.as_ref(),
client_header.device_type,
) else {
return Ok(());
};
Err(crate::error::Error::from((
// Escaped: on a first SSO login the email comes from the provider and isn't validated yet
format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()),
json!({
"error": error,
"error_description": description,
"ErrorModel": { "Message": description, "Object": "error" },
}),
))
.with_event(crate::error::ErrorEvent {
event: EventType::UserFailedLogIn,
}))
}
/// The error id and message of [`check_client_version`], `None` when the client may log in.
fn client_version_error(
is_v2: bool,
client_version: Option<&semver::Version>,
device_type: i32,
) -> Option<(&'static str, &'static str)> {
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1);
const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2);
let Some(version) = client_version else {
return Some((
"version_header_missing",
"No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"",
));
};
if !is_v2 {
return None;
}
// Upstream's desktop device types
// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Libraries/Clients/DeviceTypes.cs#L14-L20>
let is_desktop = matches!(
DeviceType::from_i32(device_type),
DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp
);
let min_version = if is_desktop {
&MIN_V2_DESKTOP_CLIENT
} else {
&MIN_V2_CLIENT
};
(version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
}
async fn authenticated_response( async fn authenticated_response(
user: &User, user: &User,
device: &mut Device, device: &mut Device,
@@ -546,24 +627,13 @@ async fn authenticated_response(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey, "MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey,
"Salt": user.email "Salt": user.master_password_salt()
}) })
} else { } else {
Value::Null Value::Null
}; };
let account_keys = if user.private_key.is_some() { let account_keys = user.account_keys_json(conn).await;
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let mut result = json!({ let mut result = json!({
"access_token": auth_tokens.access_token(), "access_token": auth_tokens.access_token(),
@@ -598,7 +668,13 @@ async fn authenticated_response(
Ok(Json(result)) Ok(Json(result))
} }
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn api_key_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
client_header: &ClientHeaders,
) -> JsonResult {
let ip = &client_header.ip;
// Ratelimit the login // Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?; crate::ratelimit::check_limit_login(&ip.ip)?;
@@ -610,7 +686,7 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
user_api_key_login(data, user_id, conn, ip).await user_api_key_login(data, user_id, conn, client_header).await
} }
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await, Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
_ => err!("Scope not supported"), _ => err!("Scope not supported"),
@@ -621,8 +697,9 @@ async fn user_api_key_login(
data: ConnectData, data: ConnectData,
user_id: &mut Option<UserId>, user_id: &mut Option<UserId>,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, client_header: &ClientHeaders,
) -> JsonResult { ) -> JsonResult {
let ip = &client_header.ip;
// Get the user via the client_id // Get the user via the client_id
let client_id = data.client_id.as_ref().unwrap(); let client_id = data.client_id.as_ref().unwrap();
let Some(client_user_id) = client_id.strip_prefix("user.") else { let Some(client_user_id) = client_id.strip_prefix("user.") else {
@@ -659,6 +736,8 @@ async fn user_api_key_login(
) )
} }
check_client_version(&user, client_header)?;
let mut device = get_device(&data, conn, &user).await?; let mut device = get_device(&data, conn, &user).await?;
if CONFIG.mail_enabled() && device.is_new() { if CONFIG.mail_enabled() && device.is_new() {
@@ -703,24 +782,13 @@ async fn user_api_key_login(
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"MasterKeyEncryptedUserKey": user.akey, "MasterKeyEncryptedUserKey": user.akey,
"MasterKeyWrappedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey,
"Salt": user.email "Salt": user.master_password_salt()
}) })
} else { } else {
Value::Null Value::Null
}; };
let account_keys = if user.private_key.is_some() { let account_keys = user.account_keys_json(conn).await;
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
// Note: No refresh_token is returned. The CLI just repeats the // Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires. // client_credentials login flow when the existing token expires.
@@ -1351,3 +1419,33 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
Ok(Redirect::temporary(String::from(auth_url))) Ok(Redirect::temporary(String::from(auth_url)))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_version_error() {
let version = |v| semver::Version::parse(v).unwrap();
let web = DeviceType::ChromeBrowser as i32;
let desktop = DeviceType::WindowsDesktop as i32;
// Every login needs the header
assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing"));
assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing"));
// V1 accounts log in from any version
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None);
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None);
// V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app
assert_eq!(
client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0),
Some("invalid_client_version")
);
assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None);
assert_eq!(
client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0),
Some("invalid_client_version")
);
assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None);
assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None);
}
}
+6 -1
View File
@@ -4,8 +4,13 @@ mod icons;
mod identity; mod identity;
mod notifications; mod notifications;
mod push; mod push;
#[cfg(feature = "unsafe_test_mode")]
mod test_seeder;
mod web; mod web;
#[cfg(feature = "unsafe_test_mode")]
pub use test_seeder::routes as test_seeder_routes;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use serde_json::Value; use serde_json::Value;
@@ -23,7 +28,7 @@ pub use crate::api::{
icons::routes as icons_routes, icons::routes as icons_routes,
identity::routes as identity_routes, identity::routes as identity_routes,
notifications::routes as notifications_routes, notifications::routes as notifications_routes,
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
push::{ push::{
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update, push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
register_push_device, unregister_push_device, register_push_device, unregister_push_device,
+26 -6
View File
@@ -412,23 +412,35 @@ impl WebSocketUsers {
} }
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) { pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
self.send_logout_with_reason(user, acting_device, None, conn).await;
}
/// A logout that clients may handle differently depending on `reason`
pub async fn send_logout_with_reason(
&self,
user: &User,
acting_device: Option<&Device>,
reason: Option<LogOutReason>,
conn: &DbConn,
) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED { if *NOTIFICATIONS_DISABLED {
return; return;
} }
let acting_device_id = acting_device.map(|d| d.uuid.clone()); let acting_device_id = acting_device.map(|d| d.uuid.clone());
let data = create_update( let mut payload =
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
UpdateType::LogOut, if let Some(reason) = reason {
acting_device_id, payload.push(("Reason".into(), (reason as i32).into()));
); }
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
self.send_update(&user.uuid, &data).await; self.send_update(&user.uuid, &data).await;
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_logout(user, acting_device, conn).await; push_logout(user, acting_device, reason, conn).await;
} }
} }
@@ -692,6 +704,14 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
serialize(&value) serialize(&value)
} }
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Enums/PushNotificationLogOutReason.cs#L3-L7>
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum LogOutReason {
KeyRotation = 1,
}
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs // https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
#[derive(Copy, Clone, Eq, PartialEq)] #[derive(Copy, Clone, Eq, PartialEq)]
pub enum UpdateType { pub enum UpdateType {
+6 -5
View File
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ApiResult, EmptyResult, UpdateType}, api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
db::{ db::{
DbConn, DbConn,
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId}, models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
@@ -192,15 +192,15 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
} }
} }
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) { pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await { if Device::check_user_has_push_device(&user.uuid, conn).await {
push_logout_to_relay(user, acting_device); push_logout_to_relay(user, acting_device, reason);
} }
} }
// Sends the logout without checking for push devices first. // Sends the logout without checking for push devices first.
// Needed after deleting a user, when the devices are already gone from the database. // Needed after deleting a user, when the devices are already gone from the database.
pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) { pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>) {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid, "userId": user.uuid,
"organizationId": (), "organizationId": (),
@@ -209,7 +209,8 @@ pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) {
"type": UpdateType::LogOut as i32, "type": UpdateType::LogOut as i32,
"payload": { "payload": {
"userId": user.uuid, "userId": user.uuid,
"date": format_date(&user.updated_at) "date": format_date(&user.updated_at),
"reason": reason.map(|r| r as i32),
}, },
"clientType": null, "clientType": null,
"installationId": null "installationId": null
+511
View File
@@ -0,0 +1,511 @@
//! A seeder API for the end-to-end tests, only in builds with the `unsafe_test_mode` feature: it creates accounts with
//! no checks at all, so it must never be in a release or a deployment.
//!
//! It answers the calls the SDK's live tests make to upstream's `util/SeederApi` (`POST /seed` with a scene,
//! `DELETE /seed/{playId}`), under `/test/seeder`. Each request needs HTTP basic auth with `TEST_SEEDER_USERNAME` and
//! `TEST_SEEDER_PASSWORD` from the environment; without both, every route answers 404. Seeding needs an `X-Play-Id`
//! header, and destroying a play deletes the accounts seeded under it. Plays are kept in memory, which is enough for a
//! test server that starts on an empty database.
//!
//! Scenes:
//! - `SingleUserScene`: a V1 account for an email and password, under a mangled address like upstream's, with
//! PBKDF2 at upstream's default of 5000 iterations.
//! - `TestVectorUserScene`: one of the SDK's user test vectors (`test-vectors/users/*.json`), stored as it is: the
//! account and its vault are already encrypted. Unlike upstream, the address isn't mangled, since our master
//! password salt is the email, so a vector can be seeded once at a time. Text Sends are stored too.
//!
//! Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/SeederApi/Controllers/SeedController.cs#L11-L107>
//! Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/Seeder/Scenes/SingleUserScene.cs#L38-L154>
use std::{
collections::HashMap,
num::NonZeroU32,
sync::{LazyLock, Mutex},
};
use chrono::Utc;
use data_encoding::{BASE64, HEXLOWER};
use openssl::{
pkey::PKey,
rsa::Rsa,
symm::{Cipher, encrypt},
};
use ring::{hkdf, hmac, pbkdf2};
use rocket::{
Request, Route,
http::Status,
request::{FromRequest, Outcome},
serde::json::Json,
};
use serde_json::{Value, json};
use crate::{
api::{
ApiResult, JsonResult,
core::{seed_cipher, seed_send},
},
crypto,
db::{
DbConn,
models::{Folder, FolderId, KeyId, SignatureAlgorithm, User, UserId, UserKdfType, UserSignatureKeyPair},
},
};
pub fn routes() -> Vec<Route> {
routes![seed, destroy_play]
}
/// The accounts seeded under each play id.
static PLAYS: LazyLock<Mutex<HashMap<String, Vec<UserId>>>> = LazyLock::new(Mutex::default);
/// HTTP basic auth with `TEST_SEEDER_USERNAME` and `TEST_SEEDER_PASSWORD`.
struct SeederAuth;
#[rocket::async_trait]
impl<'r> FromRequest<'r> for SeederAuth {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let (Ok(username), Ok(password)) =
(std::env::var("TEST_SEEDER_USERNAME"), std::env::var("TEST_SEEDER_PASSWORD"))
else {
return Outcome::Error((Status::NotFound, "The test seeder is off"));
};
if username.is_empty() || password.is_empty() {
return Outcome::Error((Status::NotFound, "The test seeder is off"));
}
let expected = format!("Basic {}", BASE64.encode(format!("{username}:{password}").as_bytes()));
match request.headers().get_one("Authorization") {
Some(given) if crypto::ct_eq(given, expected) => Outcome::Success(Self),
_ => Outcome::Error((Status::Unauthorized, "Invalid test seeder credentials")),
}
}
}
/// The `X-Play-Id` header, which names the play a seeding belongs to.
struct PlayId(String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for PlayId {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.headers().get_one("X-Play-Id").map(str::trim) {
Some(play_id) if !play_id.is_empty() => Outcome::Success(Self(play_id.to_owned())),
_ => Outcome::Error((Status::BadRequest, "Seeding needs an X-Play-Id header")),
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SeedRequest {
template: String,
arguments: Option<Value>,
}
#[post("/seed", data = "<data>")]
async fn seed(data: Json<SeedRequest>, _auth: SeederAuth, play: PlayId, conn: DbConn) -> JsonResult {
let data = data.into_inner();
let arguments = data.arguments.unwrap_or(Value::Null);
let response = match data.template.as_str() {
"SingleUserScene" => single_user_scene(serde_json::from_value(arguments)?, &play.0, &conn).await?,
"TestVectorUserScene" => test_vector_user_scene(serde_json::from_value(arguments)?, &play.0, &conn).await?,
other => err_code!(format!("Unknown scene {}", other.escape_debug()), Status::NotFound.code),
};
Ok(Json(response))
}
/// Records an account in its play as soon as it's saved, so that destroying the play removes it even when the rest of
/// the seeding fails.
fn track(play: &str, user_id: &UserId) {
PLAYS.lock().unwrap().entry(play.to_owned()).or_default().push(user_id.clone());
}
/// Deletes the accounts seeded under `play_id`; an unknown play is a no-op, like upstream.
#[delete("/seed/<play_id>")]
async fn destroy_play(play_id: &str, _auth: SeederAuth, conn: DbConn) -> JsonResult {
let user_ids = PLAYS.lock().unwrap().remove(play_id).unwrap_or_default();
let mut remaining = Vec::new();
let mut error = None;
for user_id in &user_ids {
let Some(user) = User::find_by_uuid(user_id, &conn).await else {
continue;
};
if let Err(e) = user.delete(&conn).await {
remaining.push(user_id.clone());
error.get_or_insert(e);
}
}
if let Some(error) = error {
// Kept in the play, so that destroying it again retries them
PLAYS.lock().unwrap().entry(play_id.to_owned()).or_default().extend(remaining);
return Err(error);
}
Ok(Json(json!({ "userIds": user_ids })))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SingleUserRequest {
email: String,
password: String,
#[serde(default)]
email_verified: bool,
kdf_iterations: Option<u32>,
#[serde(default)]
enroll_in_email2_fa: bool,
#[serde(default)]
corrupt_private_key: bool,
}
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/util/Seeder/Models/UserSeed.cs#L55>
const DEFAULT_KDF_ITERATIONS: u32 = 5_000;
async fn single_user_scene(request: SingleUserRequest, play: &str, conn: &DbConn) -> ApiResult<Value> {
if request.enroll_in_email2_fa || request.corrupt_private_key {
err!("enrollInEmail2Fa and corruptPrivateKey aren't supported")
}
let iterations = request.kdf_iterations.unwrap_or(DEFAULT_KDF_ITERATIONS);
if !(5_000..=2_000_000).contains(&iterations) {
err!("kdfIterations must be between 5000 and 2000000")
}
let Some((local, domain)) = request.email.split_once('@') else {
err!("The email has no @")
};
// Like upstream's ManglerService: unique addresses for every seeding
let mangled = format!("{}+{local}@{domain}", HEXLOWER.encode(&crypto::get_random_bytes::<4>()));
let mut user = User::new(&mangled, None);
let keys = V1Keys::new(&request.password, &user.master_password_salt(), iterations)?;
user.client_kdf_type = UserKdfType::Pbkdf2 as i32;
user.client_kdf_iter = iterations.cast_signed();
user.client_kdf_memory = None;
user.client_kdf_parallelism = None;
user.private_key = Some(keys.private_key.clone());
user.public_key = Some(keys.public_key.clone());
if request.email_verified {
user.verified_at = Some(Utc::now().naive_utc());
}
user.set_password(&keys.master_password_hash, Some(keys.wrapped_user_key.clone()), false, None, conn).await?;
user.save(conn).await?;
track(play, &user.uuid);
let response = json!({
"mangleMap": mangle_map(&request.email, &user.email),
"result": {
"userId": user.uuid,
"kdf": "PBKDF2_SHA256",
"kdfIterations": iterations,
"key": keys.wrapped_user_key,
"publicKey": keys.public_key,
"privateKey": keys.private_key,
},
});
Ok(response)
}
/// What a V1 client makes when it registers, from the master password.
struct V1Keys {
/// The master password authentication hash, which the client logs in with.
master_password_hash: String,
/// The user key, wrapped by the stretched master key.
wrapped_user_key: String,
/// The PKCS#8 RSA private key, wrapped by the user key.
private_key: String,
/// The SPKI RSA public key.
public_key: String,
}
impl V1Keys {
fn new(password: &str, salt: &str, iterations: u32) -> ApiResult<Self> {
let master_key = pbkdf2_sha256(password.as_bytes(), salt.as_bytes(), iterations);
let master_password_hash = BASE64.encode(&pbkdf2_sha256(&master_key, password.as_bytes(), 1));
let stretched_enc = hkdf_expand(&master_key, b"enc");
let stretched_mac = hkdf_expand(&master_key, b"mac");
let user_key = crypto::get_random_bytes::<64>();
let wrapped_user_key = enc_string(&user_key, &stretched_enc, &stretched_mac)?;
let key_pair = PKey::from_rsa(Rsa::generate(2048)?)?;
let private_key = enc_string(&key_pair.private_key_to_pkcs8()?, &user_key[..32], &user_key[32..])?;
let public_key = BASE64.encode(&key_pair.public_key_to_der()?);
Ok(Self {
master_password_hash,
wrapped_user_key,
private_key,
public_key,
})
}
}
fn pbkdf2_sha256(secret: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] {
let mut out = [0u8; 32];
let iterations = NonZeroU32::new(iterations).expect("iterations aren't zero");
pbkdf2::derive(pbkdf2::PBKDF2_HMAC_SHA256, iterations, salt, secret, &mut out);
out
}
/// HKDF-Expand with `key` as the pseudorandom key, as clients stretch the master key.
fn hkdf_expand(key: &[u8; 32], info: &[u8]) -> [u8; 32] {
let mut out = [0u8; 32];
hkdf::Prk::new_less_safe(hkdf::HKDF_SHA256, key)
.expand(&[info], hkdf::HKDF_SHA256)
.and_then(|okm| okm.fill(&mut out))
.expect("32 bytes is a valid HKDF-SHA256 length");
out
}
/// An `AesCbc256_HmacSha256_B64` (type 2) EncString.
fn enc_string(plaintext: &[u8], enc_key: &[u8], mac_key: &[u8]) -> ApiResult<String> {
let iv = crypto::get_random_bytes::<16>();
let ciphertext = encrypt(Cipher::aes_256_cbc(), enc_key, Some(&iv), plaintext)?;
let mac = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, mac_key), &[iv.as_slice(), &ciphertext].concat());
Ok(format!("2.{}|{}|{}", BASE64.encode(&iv), BASE64.encode(&ciphertext), BASE64.encode(mac.as_ref())))
}
/// A user test vector; only what a server holds is read from it.
///
/// Ref: <https://github.com/bitwarden/sdk-internal/blob/ee307a55d5604e96b983595c6379cc9f76ca2f87/e2e/wasm/vectors/load.ts>
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TestVector {
name: String,
account: VectorAccount,
unlock_methods: Vec<Value>,
master_password_authentication_hash: Option<String>,
raw_cryptographic_state: VectorRawCryptographicState,
#[serde(default)]
vault: VectorVault,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct VectorAccount {
email: String,
security_version: Option<i32>,
account_cryptographic_state: Value,
}
/// The public halves the vector records; its private halves are never read.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct VectorRawCryptographicState {
public_key: String,
verifying_key: Option<String>,
user_key_id: Option<String>,
}
#[derive(Default, Deserialize)]
struct VectorVault {
#[serde(default)]
ciphers: Vec<VectorItem>,
#[serde(default)]
folders: Vec<VectorItem>,
#[serde(default)]
sends: Vec<VectorItem>,
}
#[derive(Deserialize)]
struct VectorItem {
id: String,
encrypted: Value,
}
/// Upstream's scene, not merged there yet:
/// <https://github.com/bitwarden/server/blob/9614f3f54f78ad57090a1fd5228f9c2fac5cbfe2/util/Seeder/Scenes/TestVectorUserScene.cs#L43-L361>
async fn test_vector_user_scene(vector: TestVector, play: &str, conn: &DbConn) -> ApiResult<Value> {
let Some(unlock) =
vector.unlock_methods.iter().find_map(|m| m.pointer("/masterPasswordUnlock/master_password_unlock"))
else {
err!(format!("Vector {} has no master password unlock method", vector.name.escape_debug()))
};
let Some(hash) = vector.master_password_authentication_hash.as_deref() else {
err!(format!("Vector {} records no master password authentication hash", vector.name.escape_debug()))
};
let (Some(wrapped_user_key), Some(salt)) = (unlock["masterKeyWrappedUserKey"].as_str(), unlock["salt"].as_str())
else {
err!("The master password unlock method has no wrapped user key or salt")
};
let mut user = User::new(&vector.account.email, Some(vector.name.clone()));
if user.master_password_salt() != salt {
err!("The vector's master password salt isn't its email, which is the only salt Vaultwarden supports")
}
if User::find_by_mail(&user.email, conn).await.is_some() {
err!("This vector is already seeded: destroy its play first, its address can't be mangled")
}
apply_kdf(&mut user, &unlock["kdf"])?;
let state = &vector.account.account_cryptographic_state;
let (v2, private_key) = match (state.get("V1"), state.get("V2")) {
(Some(v1), None) => (None, &v1["private_key"]),
(None, Some(v2)) => (Some(v2), &v2["private_key"]),
_ => err!("The account cryptographic state is neither V1 nor V2"),
};
user.private_key = Some(json_str(private_key, "private_key")?);
user.public_key = Some(vector.raw_cryptographic_state.public_key.clone());
let key_id = vector.raw_cryptographic_state.user_key_id.as_deref().or_else(|| unlock["containedKeyId"].as_str());
user.key_id = key_id.map(|id| serde_json::from_value::<KeyId>(json!(id))).transpose()?;
let signature_key_pair = match v2 {
Some(v2) => {
user.signed_public_key = Some(json_str(&v2["signed_public_key"], "signed_public_key")?);
user.security_state = Some(json_str(&v2["security_state"], "security_state")?);
user.security_version = Some(vector.account.security_version.unwrap_or(2));
let Some(verifying_key) = vector.raw_cryptographic_state.verifying_key.clone() else {
err!("A V2 vector needs its verifying key")
};
let algorithm = cose_signature_algorithm(&verifying_key)?;
Some((algorithm, json_str(&v2["signing_key"], "signing_key")?, verifying_key))
}
None => None,
};
user.verified_at = Some(Utc::now().naive_utc());
user.set_password(hash, Some(wrapped_user_key.to_owned()), false, None, conn).await?;
user.save(conn).await?;
track(play, &user.uuid);
if let Some((algorithm, signing_key, verifying_key)) = signature_key_pair {
UserSignatureKeyPair::new(user.uuid.clone(), algorithm, signing_key, verifying_key).save(conn).await?;
}
// The vault, already encrypted, under fresh ids
let mut folder_ids = HashMap::new();
let mut folder_ids_by_vector_id = serde_json::Map::new();
for folder in &vector.vault.folders {
let mut saved = Folder::new(user.uuid.clone(), json_str(&folder.encrypted["name"], "name")?);
saved.save(conn).await?;
folder_ids.insert(serde_json::from_value::<FolderId>(json!(folder.id))?, saved.uuid.clone());
folder_ids_by_vector_id.insert(folder.id.clone(), json!(saved.uuid));
}
let mut cipher_ids = serde_json::Map::new();
for cipher in vector.vault.ciphers {
let cipher_id = seed_cipher(cipher.encrypted, &user.uuid, &folder_ids, conn).await?;
cipher_ids.insert(cipher.id, json!(cipher_id));
}
for send in vector.vault.sends {
seed_send(send.encrypted, &user.uuid, conn).await?;
}
let response = json!({
"mangleMap": mangle_map(&vector.account.email, &user.email),
"result": {
"userId": user.uuid,
"email": user.email,
"cipherIds": cipher_ids,
"folderIds": folder_ids_by_vector_id,
},
});
Ok(response)
}
/// The vector's KDF: `{"pBKDF2": {"iterations"}}` or `{"argon2id": {"iterations", "memory", "parallelism"}}`.
fn apply_kdf(user: &mut User, kdf: &Value) -> ApiResult<()> {
let int = |value: &Value| value.as_i64().and_then(|v| i32::try_from(v).ok());
if let Some(iterations) = kdf.pointer("/pBKDF2/iterations").and_then(int) {
user.client_kdf_type = UserKdfType::Pbkdf2 as i32;
user.client_kdf_iter = iterations;
user.client_kdf_memory = None;
user.client_kdf_parallelism = None;
} else if let Some(argon2) = kdf.get("argon2id") {
let Some(iterations) = int(&argon2["iterations"]) else {
err!("The Argon2id KDF has no iterations")
};
user.client_kdf_type = UserKdfType::Argon2id as i32;
user.client_kdf_iter = iterations;
user.client_kdf_memory = int(&argon2["memory"]);
user.client_kdf_parallelism = int(&argon2["parallelism"]);
} else {
err!("Unknown KDF")
}
Ok(())
}
/// The signature algorithm a COSE verifying key names in its `alg` (label 3) entry.
fn cose_signature_algorithm(verifying_key: &str) -> ApiResult<SignatureAlgorithm> {
const ALG: i128 = 3;
const EDDSA: i128 = -8;
const ML_DSA_44: i128 = -48;
let Ok(bytes) = BASE64.decode(verifying_key.as_bytes()) else {
err!("The verifying key isn't base64")
};
let Ok(serde_cbor_2::Value::Map(entries)) = serde_cbor_2::from_slice::<serde_cbor_2::Value>(&bytes) else {
err!("The verifying key isn't a COSE key")
};
match entries.get(&serde_cbor_2::Value::Integer(ALG)) {
Some(serde_cbor_2::Value::Integer(EDDSA)) => Ok(SignatureAlgorithm::Ed25519),
Some(serde_cbor_2::Value::Integer(ML_DSA_44)) => Ok(SignatureAlgorithm::MlDsa44),
_ => err!("The verifying key's algorithm isn't Ed25519 or ML-DSA-44"),
}
}
/// Upstream's map from the address a scene was asked for to the one it stored.
fn mangle_map(requested: &str, stored: &str) -> Value {
let mut map = serde_json::Map::new();
map.insert(requested.to_owned(), json!(stored));
Value::Object(map)
}
fn json_str(value: &Value, field: &str) -> ApiResult<String> {
let Some(value) = value.as_str() else {
err!(format!("The vector's {field} is missing"))
};
Ok(value.to_owned())
}
#[cfg(test)]
mod tests {
use openssl::symm::decrypt;
use super::*;
// From sdk-internal's `test-vectors/users/v1-pbkdf2-min-iterations.json` (d9119f7af)
const EMAIL: &str = "v1-pbkdf2-min-iterations@test.bitwarden.com";
const PASSWORD: &str = "password-v1-pbkdf2-min-iterations";
const ITERATIONS: u32 = 5_000;
const AUTHENTICATION_HASH: &str = "4HcwTg4bQKEbuINCJGMqrij1yipl63Ugu/RazH2y7Qw=";
const WRAPPED_USER_KEY: &str = "2.tl+DrZdn+fVyBVu+0c/Gvg==|9ivOLNtKbvirNaUnkGsEtysp9de53ibTSu88HLkDOGLig3t8nhvK5JIqxSY1/ukcr2Y/Ly9OJ12tXUGjIlJ/PtIEIdYj3gZDwhAflPUwtW8=|FD5WuIvviib3yWYcI3slmqBSqMf38Z0CMwttPG1w5DY=";
const USER_KEY: &str = "49LghK4AVIybWSZFikb4nAn/BofxxBIoXv83EJWOP4taVMbR5XCfrbF2m/eUmfZdeFW7VybPYSGT/bmv5eeMBg==";
/// Decrypts a type 2 EncString, checking its MAC.
fn dec_string(enc_string: &str, enc_key: &[u8], mac_key: &[u8]) -> Vec<u8> {
let parts: Vec<Vec<u8>> = enc_string
.strip_prefix("2.")
.unwrap()
.split('|')
.map(|part| BASE64.decode(part.as_bytes()).unwrap())
.collect();
let [iv, ciphertext, mac] = parts.as_slice() else {
panic!("not three parts")
};
let key = hmac::Key::new(hmac::HMAC_SHA256, mac_key);
hmac::verify(&key, &[iv.as_slice(), ciphertext].concat(), mac).unwrap();
decrypt(Cipher::aes_256_cbc(), enc_key, Some(iv), ciphertext).unwrap()
}
#[test]
fn derives_the_vectors_keys() {
let master_key = pbkdf2_sha256(PASSWORD.as_bytes(), EMAIL.as_bytes(), ITERATIONS);
assert_eq!(BASE64.encode(&pbkdf2_sha256(&master_key, PASSWORD.as_bytes(), 1)), AUTHENTICATION_HASH);
let user_key =
dec_string(WRAPPED_USER_KEY, &hkdf_expand(&master_key, b"enc"), &hkdf_expand(&master_key, b"mac"));
assert_eq!(BASE64.encode(&user_key), USER_KEY);
}
#[test]
fn makes_keys_a_client_can_open() {
let keys = V1Keys::new(PASSWORD, EMAIL, ITERATIONS).unwrap();
assert_eq!(keys.master_password_hash, AUTHENTICATION_HASH);
let master_key = pbkdf2_sha256(PASSWORD.as_bytes(), EMAIL.as_bytes(), ITERATIONS);
let user_key =
dec_string(&keys.wrapped_user_key, &hkdf_expand(&master_key, b"enc"), &hkdf_expand(&master_key, b"mac"));
assert_eq!(user_key.len(), 64);
let pkcs8 = dec_string(&keys.private_key, &user_key[..32], &user_key[32..]);
let private_key = PKey::private_key_from_pkcs8(&pkcs8).unwrap();
assert_eq!(BASE64.encode(&private_key.public_key_to_der().unwrap()), keys.public_key);
}
}
+2 -1
View File
@@ -20,7 +20,7 @@ use crate::{
crypto::sha256_hex, crypto::sha256_hex,
db::{ db::{
DbConn, DbConn,
models::{AttachmentId, CipherId}, models::{AttachmentId, CipherId, custom_roles_enabled},
}, },
error::Error, error::Error,
util::{Cached, EtagCached}, util::{Cached, EtagCached},
@@ -90,6 +90,7 @@ fn vaultwarden_css() -> EtagCached<String> {
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({ let css_options = json!({
"custom_roles_enabled": custom_roles_enabled(),
"email_change_allowed": CONFIG.email_change_allowed(), "email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true, "load_user_scss": true,
+179 -53
View File
@@ -32,9 +32,9 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, AttachmentId, CipherId, Collection, CollectionId, CustomPermission, Device, DeviceId, DeviceType,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, EmergencyAccessId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId,
SendFileId, SendId, User, UserId, UserStampException, OrganizationId, SendFileId, SendId, User, UserId, custom_roles_enabled,
}, },
}, },
error::Error, error::Error,
@@ -632,6 +632,9 @@ impl<'r> FromRequest<'r> for Host {
pub struct ClientHeaders { pub struct ClientHeaders {
pub device_type: i32, pub device_type: i32,
pub ip: ClientIp, pub ip: ClientIp,
/// `Bitwarden-Client-Version`, `None` when missing or not a version. Unlike the [`ClientVersion`] guard, this
/// doesn't log an error, as not every client sends it.
pub client_version: Option<semver::Version>,
} }
#[rocket::async_trait] #[rocket::async_trait]
@@ -648,10 +651,13 @@ impl<'r> FromRequest<'r> for ClientHeaders {
.get_one("device-type") .get_one("device-type")
.and_then(|d| d.parse().ok()) .and_then(|d| d.parse().ok())
.unwrap_or(DeviceType::UnknownBrowser as i32); .unwrap_or(DeviceType::UnknownBrowser as i32);
let client_version =
request.headers().get_one("Bitwarden-Client-Version").and_then(|v| semver::Version::parse(v).ok());
Outcome::Success(ClientHeaders { Outcome::Success(ClientHeaders {
device_type, device_type,
ip, ip,
client_version,
}) })
} }
} }
@@ -707,31 +713,12 @@ impl<'r> FromRequest<'r> for Headers {
}; };
if user.security_stamp != claims.sstamp { if user.security_stamp != claims.sstamp {
if let Some(stamp_exception) =
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
{
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
err_handler!("Error getting current route for stamp exception") err_handler!("Error getting current route for stamp exception")
}; };
// Check if the stamp exception has expired first. // Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
// Then, check if the current route matches any of the allowed routes. if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
// After that check the stamp in exception matches the one in the claims.
if Utc::now().timestamp() > stamp_exception.expire {
// If the stamp exception has been expired remove it from the database.
// This prevents checking this stamp exception for new requests.
let mut user = user;
user.reset_stamp_exception();
if let Err(e) = user.save(&conn).await {
error!("Error updating user: {e:#?}");
}
err_handler!("Stamp exception is expired")
} else if !stamp_exception.routes.contains(&current_route.to_owned()) {
err_handler!("Invalid security stamp: Current route and exception route do not match")
} else if stamp_exception.security_stamp != claims.sstamp {
err_handler!("Invalid security stamp for matched stamp exception")
}
} else {
err_handler!("Invalid security stamp") err_handler!("Invalid security stamp")
} }
} }
@@ -770,6 +757,12 @@ impl OrgHeaders {
fn is_confirmed_and_owner(&self) -> bool { fn is_confirmed_and_owner(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner
} }
// A Custom member, while Custom roles are enabled. What they may do depends on their permissions.
fn is_confirmed_and_custom(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed
&& self.membership_type == MembershipType::Custom
&& custom_roles_enabled()
}
} }
// org_id is usually the second path param ("/organizations/<org_id>"), // org_id is usually the second path param ("/organizations/<org_id>"),
@@ -889,6 +882,51 @@ impl<'r> FromRequest<'r> for AdminHeaders {
} }
} }
/// Generates the request guard of the routes a Custom member may use with a permission. Like AdminHeaders it lets a
/// confirmed Owner or Admin through, and a confirmed Custom member holding the permission.
macro_rules! permission_headers {
($(#[$doc:meta])* $name:ident, $permission:expr) => {
$(#[$doc])*
pub struct $name {
pub device: Device,
pub user: User,
pub membership_type: MembershipType,
pub membership: Membership,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for $name {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.is_confirmed_and_admin() || headers.membership.has_custom_permission($permission) {
Outcome::Success(Self {
device: headers.device,
user: headers.user,
membership_type: headers.membership_type,
org_id: headers.membership.org_uuid.clone(),
membership: headers.membership,
ip: headers.ip,
})
} else {
// Forbidden as in Bitwarden's permission requirements
error!(target: "auth", "The current user is missing the permission for this endpoint");
Outcome::Error((rocket::http::Status::Forbidden, "You don't have permission to call this endpoint"))
}
}
}
};
}
permission_headers!(
/// For the routes that manage the members of an organization.
ManageUsersHeaders,
CustomPermission::ManageUsers
);
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"), // col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
// but there could be cases where it is a query value. // but there could be cases where it is a query value.
// First check the path, if this is not a valid uuid, try the query values. // First check the path, if this is not a valid uuid, try the query values.
@@ -908,10 +946,81 @@ fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
None None
} }
/// The ManagerHeaders are used to check if you are at least a Manager /// What a collection route does with its collection, which decides what a Custom member needs for it.
/// and have access to the specific collection provided via the <col_id>/collections/collectionId. #[derive(Clone, Copy)]
/// This does strict checking on the collection_id, ManagerHeadersLoose does not. pub enum CollectionAction {
pub struct ManagerHeaders { Edit,
Read,
ReadAccess,
Delete,
}
/// Whether `member`, a confirmed Custom member, may `action` the collection `col_id` of their organization.
///
/// Deleting needs Delete any collection: the clients are told collection deletion is limited, so the manage option
/// on a collection is no permission to delete it. Editing is open with Edit any collection, reading also with Delete
/// any collection, and otherwise both need the manage option on the collection itself. Neither the role nor any
/// `access_all` counts as that.
pub async fn custom_member_may(
action: CollectionAction,
member: &Membership,
col_id: &CollectionId,
conn: &DbConn,
) -> bool {
if !(member.has_type(MembershipType::Custom)
&& member.has_status(MembershipStatus::Confirmed)
&& custom_roles_enabled())
{
return false;
}
match action {
CollectionAction::Delete => member.has_custom_permission(CustomPermission::DeleteAnyCollection),
CollectionAction::Edit if member.can_edit_any_collection() => true,
// Individual access details may be read with Manage users. The separate /users route follows
// Bitwarden's ReadAccess operation, which does not grant that permission on its own.
CollectionAction::Read if member.can_view_all_collection_access_details() => true,
CollectionAction::ReadAccess if member.can_view_all_collections() => true,
CollectionAction::Edit | CollectionAction::Read | CollectionAction::ReadAccess => {
Collection::find_explicitly_manageable_by_user(&member.org_uuid, &member.user_uuid, conn)
.await
.contains(col_id)
}
}
}
/// Checks a collection route for the collection provided via the <col_id>/collections/collectionId: a Manager, Admin
/// or Owner needs to be able to manage the collection, a Custom member is checked for the `action`.
async fn collection_headers(request: &Request<'_>, action: CollectionAction) -> Outcome<OrgHeaders, &'static str> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
let is_custom = headers.is_confirmed_and_custom();
if !headers.is_confirmed_and_manager() && !is_custom {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
}
let Some(col_id) = get_col_id(request) else {
err_handler!("Error getting the collection id")
};
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
let allowed = if is_custom {
custom_member_may(action, &headers.membership, &col_id, &conn).await
} else {
Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
// NotFound as in Bitwarden, the web vault logs the user out on a 401
error!(target: "auth", "Collection not found. The current user isn't a manager for this collection");
return Outcome::Error((rocket::http::Status::NotFound, "Collection not found"));
}
Outcome::Success(headers)
}
macro_rules! collection_headers {
($(#[$doc:meta])* $name:ident, $action:expr) => {
$(#[$doc])*
pub struct $name {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
pub user: User, pub user: User,
@@ -920,24 +1029,11 @@ pub struct ManagerHeaders {
} }
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ManagerHeaders { impl<'r> FromRequest<'r> for $name {
type Error = &'static str; type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await); let headers = try_outcome!(collection_headers(request, $action).await);
if headers.is_confirmed_and_manager() {
if let Some(col_id) = get_col_id(request) {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
err_handler!("The current user isn't a manager for this collection")
}
} else {
err_handler!("Error getting the collection id")
}
Outcome::Success(Self { Outcome::Success(Self {
host: headers.host, host: headers.host,
device: headers.device, device: headers.device,
@@ -945,14 +1041,11 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
ip: headers.ip, ip: headers.ip,
org_id: headers.membership.org_uuid, org_id: headers.membership.org_uuid,
}) })
} else {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
}
} }
} }
impl From<ManagerHeaders> for Headers { impl From<$name> for Headers {
fn from(h: ManagerHeaders) -> Headers { fn from(h: $name) -> Headers {
Headers { Headers {
host: h.host, host: h.host,
device: h.device, device: h.device,
@@ -961,9 +1054,35 @@ impl From<ManagerHeaders> for Headers {
} }
} }
} }
};
}
collection_headers!(
/// The ManagerHeaders are used to check if you are at least a Manager
/// and have access to the specific collection provided via the <col_id>/collections/collectionId.
/// This does strict checking on the collection_id, ManagerHeadersLoose does not.
ManagerHeaders,
CollectionAction::Edit
);
collection_headers!(
/// The same as ManagerHeaders, for the routes which only read the collection and its access.
CollectionReadHeaders,
CollectionAction::Read
);
collection_headers!(
/// The collection's /users route has narrower access than its /details route.
CollectionUsersReadHeaders,
CollectionAction::ReadAccess
);
collection_headers!(
/// The same as ManagerHeaders, for the routes which delete the collection.
CollectionDeleteHeaders,
CollectionAction::Delete
);
/// The ManagerHeadersLoose is used when you at least need to be a Manager, /// The ManagerHeadersLoose is used when you at least need to be a Manager,
/// but there is no collection_id sent with the request (either in the path or as form data). /// but there is no collection_id sent with the request (either in the path or as form data).
/// A Custom member is let through as well, the routes check what their permissions allow.
pub struct ManagerHeadersLoose { pub struct ManagerHeadersLoose {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
@@ -978,7 +1097,7 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await); let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.is_confirmed_and_manager() { if headers.is_confirmed_and_manager() || headers.is_confirmed_and_custom() {
Outcome::Success(Self { Outcome::Success(Self {
host: headers.host, host: headers.host,
device: headers.device, device: headers.device,
@@ -1003,22 +1122,29 @@ impl From<ManagerHeadersLoose> for Headers {
} }
} }
impl ManagerHeaders { impl CollectionDeleteHeaders {
pub async fn from_loose( pub async fn from_loose(
h: ManagerHeadersLoose, h: ManagerHeadersLoose,
collections: &Vec<CollectionId>, collections: &Vec<CollectionId>,
conn: &DbConn, conn: &DbConn,
) -> Result<ManagerHeaders, Error> { ) -> Result<CollectionDeleteHeaders, Error> {
let is_custom = h.membership.has_type(MembershipType::Custom) && custom_roles_enabled();
for col_id in collections { for col_id in collections {
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() { if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
err!("Collection Id is malformed!"); err!("Collection Id is malformed!");
} }
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await { let allowed = if is_custom {
Collection::find_by_uuid_and_org(col_id, &h.membership.org_uuid, conn).await.is_some()
&& custom_member_may(CollectionAction::Delete, &h.membership, col_id, conn).await
} else {
Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await
};
if !allowed {
err!("Collection not found", "The current user isn't a manager for this collection") err!("Collection not found", "The current user isn't a manager for this collection")
} }
} }
Ok(ManagerHeaders { Ok(CollectionDeleteHeaders {
host: h.host, host: h.host,
device: h.device, device: h.device,
user: h.user, user: h.user,
+11
View File
@@ -261,6 +261,10 @@ macro_rules! make_config {
} }
async fn from_file() -> Result<Option<Self>, Error> { async fn from_file() -> Result<Option<Self>, Error> {
// The Fs operator would create the folder if it doesn't exist, which we never want
if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() {
return Ok(None);
}
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let config_bytes = match operator.read(&CONFIG_FILENAME).await { let config_bytes = match operator.read(&CONFIG_FILENAME).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
@@ -817,6 +821,10 @@ make_config! {
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false; org_groups_enabled: bool, false, def, false;
/// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations.
/// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled.
experimental_custom_roles: bool, false, def, false;
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
increase_note_size_limit: bool, true, def, false; increase_note_size_limit: bool, true, def, false;
@@ -1508,6 +1516,9 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Key Management Team // Key Management Team
"biometrics-sdk-ipc", "biometrics-sdk-ipc",
"windows-native-credential-sync", "windows-native-credential-sync",
"macos-native-credential-sync",
"enable-account-encryption-v2-jit-password-registration",
"pm-27278-v2-password-registration",
// Mobile Team // Mobile Team
"pm-34171-card-scanner", "pm-34171-card-scanner",
// Platform Team // Platform Team
+292 -2
View File
@@ -1,7 +1,11 @@
mod query_logger; mod query_logger;
use std::{ use std::{
sync::{Arc, OnceLock}, future::Future,
sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
},
time::Duration, time::Duration,
}; };
@@ -123,6 +127,7 @@ pub static ACTIVE_DB_TYPE: OnceLock<DbConnType> = OnceLock::new();
pub struct DbConn { pub struct DbConn {
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>, conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
permit: Option<OwnedSemaphorePermit>, permit: Option<OwnedSemaphorePermit>,
transaction_open: AtomicBool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -150,6 +155,7 @@ impl Drop for DbConn {
fn drop(&mut self) { fn drop(&mut self) {
let conn = Arc::clone(&self.conn); let conn = Arc::clone(&self.conn);
let permit = self.permit.take(); let permit = self.permit.take();
let transaction_open = self.transaction_open.load(Ordering::Acquire);
// Since connection can't be on the stack in an async fn during an // Since connection can't be on the stack in an async fn during an
// await, we have to spawn a new blocking-safe thread... // await, we have to spawn a new blocking-safe thread...
@@ -157,7 +163,13 @@ impl Drop for DbConn {
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion. // And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned()); let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
if let Some(conn) = conn.take() { if let Some(mut conn) = conn.take() {
if transaction_open {
// A cancelled request must not return an open transaction to the pool.
if let Err(error) = conn.batch_execute("ROLLBACK") {
error!("Failed to roll back a cancelled database transaction: {error}");
}
}
drop(conn); drop(conn);
} }
@@ -248,6 +260,7 @@ impl DbPool {
Ok(DbConn { Ok(DbConn {
conn: Arc::new(Mutex::new(Some(c))), conn: Arc::new(Mutex::new(Some(c))),
permit: Some(permit), permit: Some(permit),
transaction_open: AtomicBool::new(false),
}) })
} }
} }
@@ -320,6 +333,29 @@ impl DbConnType {
} }
impl DbConn { impl DbConn {
/// Run asynchronous model calls on this request's pooled connection in one transaction.
/// Send external notifications only after this returns successfully.
pub async fn transaction<F, Fut, T>(&self, operation: F) -> Result<T, Error>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<T, Error>>,
{
self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?;
self.transaction_open.store(true, Ordering::Release);
let result = operation().await;
if result.is_ok() {
let committed = self.run(|conn| conn.batch_execute("COMMIT")).await;
let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok();
if closed {
self.transaction_open.store(false, Ordering::Release);
}
committed.map_res("Error committing database transaction")?;
} else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() {
self.transaction_open.store(false, Ordering::Release);
}
result
}
pub async fn run<F, R>(&self, f: F) -> R pub async fn run<F, R>(&self, f: F) -> R
where where
F: FnOnce(&mut DbConnInner) -> R + Send, F: FnOnce(&mut DbConnInner) -> R + Send,
@@ -534,3 +570,257 @@ mod postgresql_migrations {
Ok(()) Ok(())
} }
} }
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
#[cfg(all(test, sqlite))]
pub mod test_db {
use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS};
use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection};
use diesel_migrations::MigrationHarness;
use std::sync::{Arc, atomic::AtomicBool};
use tokio::sync::Mutex;
pub struct TestDb {
pool: Pool<DbConnManager>,
// Keep the named in-memory database alive across pooled connections.
_keeper: SqliteConnection,
}
impl TestDb {
fn new() -> Self {
let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid());
let mut keeper = SqliteConnection::establish(&url).unwrap();
keeper.run_pending_migrations(MIGRATIONS).unwrap();
let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap();
Self {
pool,
_keeper: keeper,
}
}
pub fn conn(&self) -> DbConn {
DbConn {
conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))),
permit: None,
transaction_open: AtomicBool::new(false),
}
}
}
// CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized.
pub fn run(test: impl AsyncFnOnce(&TestDb)) {
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
.env("DATABASE_URL", "sqlite://:memory:")
.output()
.unwrap();
assert!(
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
// DbConn::run uses block_in_place, requiring a multi-threaded runtime.
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
super::models::test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
test(&TestDb::new()).await;
});
}
#[test]
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
use super::schema::users_organizations::dsl::*;
use diesel::{migration::MigrationSource, prelude::*};
let mut conn = SqliteConnection::establish(":memory:").unwrap();
for migration in conn.pending_migrations(MIGRATIONS).unwrap() {
if migration.name().version().to_string() == "20261010120000" {
break;
}
conn.run_migration(&migration).unwrap();
}
conn.batch_execute(
"PRAGMA foreign_keys=OFF;
INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b');
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES
('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1),
('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);",
)
.unwrap();
let rows = |conn: &mut SqliteConnection| {
users_organizations
.select((uuid, user_uuid, org_uuid, access_all, akey, status, atype))
.order(uuid)
.load::<(String, String, String, bool, String, i32, i32)>(conn)
.unwrap()
};
let before = rows(&mut conn);
conn.run_pending_migrations(MIGRATIONS).unwrap();
assert_eq!(rows(&mut conn), before);
let grants = |conn: &mut SqliteConnection| {
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>(
"(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies
OR create_new_collections OR edit_any_collection OR delete_any_collection
OR access_event_logs OR access_import_export OR access_reports)",
))
.get_result::<i64>(conn)
.unwrap()
};
assert_eq!(grants(&mut conn), 0);
let migration = MigrationSource::<diesel::sqlite::Sqlite>::migrations(&MIGRATIONS)
.unwrap()
.into_iter()
.find(|m| m.name().version().to_string() == "20261010120000")
.unwrap();
for member_status in [1, 2] {
conn.batch_execute(&format!(
"UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'"
))
.unwrap();
let custom = rows(&mut conn);
assert!(conn.revert_migration(&*migration).is_err());
assert_eq!(rows(&mut conn), custom);
assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::<bool>(&mut conn).unwrap());
}
conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'")
.unwrap();
conn.revert_migration(&*migration).unwrap();
assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err());
conn.run_pending_migrations(MIGRATIONS).unwrap();
assert_eq!(grants(&mut conn), 0);
assert_eq!(rows(&mut conn), before);
}
use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User};
pub async fn organization(conn: &DbConn) -> Organization {
let org = Organization::new("Org".into(), "billing@example.com", None, None);
org.save(conn).await.unwrap();
org
}
pub async fn member(
conn: &DbConn,
org: &OrganizationId,
role: MembershipType,
permissions: &[CustomPermission],
) -> (User, Membership) {
let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None);
user.password_hash = b"existing-account".to_vec();
user.save(conn).await.unwrap();
let mut member = Membership::new(user.uuid.clone(), org.clone(), None);
member.set_type(role);
member.status = 2;
member.set_custom_permissions(&permissions.iter().copied().collect());
member.save(conn).await.unwrap();
(user, member)
}
pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) {
let org = organization(conn).await;
let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await;
(org, user, member)
}
pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection {
let collection = Collection::new(org.clone(), "Collection".into(), None);
collection.save(conn).await.unwrap();
collection
}
#[test]
fn transactions_rollback_before_connection_reuse_and_commit_success() {
use crate::error::MapResult;
use diesel::RunQueryDsl;
run(async |db| {
let conn = db.conn();
conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap();
for (sql, expected) in [
("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64),
("INSERT INTO probe VALUES (1)", 1),
] {
let result: Result<(), super::Error> = conn
.transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed"))
.await;
assert_eq!(result.is_ok(), expected == 1);
let count: i64 = conn
.run(|c| {
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>("(SELECT COUNT(*) FROM probe)"))
.get_result(c)
})
.await
.unwrap();
assert_eq!(count, expected);
// The same live connection must accept another transaction, before DbConn::drop can help.
conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap();
}
});
}
#[test]
fn collection_and_cipher_access_requires_active_membership_and_enabled_groups() {
use super::models::{Cipher, CipherAccessScope, CollectionCipher, CollectionUser, test_flag};
use crate::{
api::core::{CipherSyncData, CipherSyncType},
auth::{CollectionAction, custom_member_may},
};
run(async |db| {
let conn = db.conn();
let (org, user, mut member) = custom_member(&conn, CustomPermission::EditAnyCollection).await;
let collection = collection(&conn, &org.uuid).await;
let mut cipher = Cipher::new(2);
cipher.name = "Cipher".into();
cipher.organization_uuid = Some(org.uuid.clone());
cipher.save(&conn).await.unwrap();
CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await.unwrap();
for (enabled, status, allowed) in [(true, 2, true), (false, 2, false), (true, 1, false)] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
member.status = status;
member.save(&conn).await.unwrap();
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::User, &conn).await);
assert_eq!(
cipher.is_write_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await,
allowed
);
}
test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
member.status = 2;
member.save(&conn).await.unwrap();
let foreign = organization(&conn).await;
cipher.organization_uuid = Some(foreign.uuid);
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await);
cipher.organization_uuid = Some(org.uuid.clone());
member.set_custom_permissions(&std::collections::HashSet::new());
member.save(&conn).await.unwrap();
let sql=format!("INSERT INTO groups (uuid,organizations_uuid,name,access_all,creation_date,revision_date)
VALUES ('g','{}','Group',FALSE,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP);
INSERT INTO groups_users (groups_uuid,users_organizations_uuid) VALUES ('g','{}');
INSERT INTO collections_groups (collections_uuid,groups_uuid,read_only,hide_passwords,manage) VALUES ('{}','g',FALSE,FALSE,TRUE);",
org.uuid,member.uuid,collection.uuid);
conn.run(move |c| c.batch_execute(&sql)).await.unwrap();
for status in [2, 1, -3, -126] {
member.status = status;
member.save(&conn).await.unwrap();
let allowed = status == 2 && crate::CONFIG.org_groups_enabled();
assert_eq!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await, allowed);
let sync = CipherSyncData::new(&user.uuid, CipherSyncType::User, &conn).await;
for cached in [None, Some(&sync)] {
assert_eq!(
cipher
.get_access_restrictions(&user.uuid, CipherAccessScope::User, cached, &conn)
.await
.is_some(),
allowed
);
}
}
member.status = 2;
member.save(&conn).await.unwrap();
CollectionUser::save(&user.uuid, &collection.uuid, false, false, true, &conn).await.unwrap();
assert!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await);
});
}
}
+21 -4
View File
@@ -65,16 +65,33 @@ impl Attachment {
} }
} }
pub async fn to_json(&self, host: &str) -> Result<Value, crate::Error> { /// The attachment as sync and the cipher responses list it. Its `url` is a leftover from the old download flow.
Ok(json!({ /// Clients get a signed URL from the attachment routes (`to_json_for_download`) instead, only the web vault and the
/// CLI read this one, as a fallback when those routes answer 404.
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Models/Response/AttachmentResponseModel.cs#L24-L29>
pub fn to_json(&self, host: &str) -> Value {
self.json_with_url(&format!("{host}/attachments/{}/{}", self.cipher_uuid, self.id))
}
/// The attachment with a `url` the client can download it from for a short while, for the attachment routes
/// (`GET /ciphers/{id}/attachment/{attachmentId}`, its admin variant, the emergency access one).
///
/// Ref: <https://github.com/bitwarden/server/blob/341acc9d3d8988cec8fe05debfa6d557cf5ab749/src/Api/Vault/Controllers/CiphersController.cs#L1699-L1717>
pub async fn to_json_for_download(&self, host: &str) -> Result<Value, crate::Error> {
Ok(self.json_with_url(&self.get_url(host).await?))
}
fn json_with_url(&self, url: &str) -> Value {
json!({
"id": self.id, "id": self.id,
"url": self.get_url(host).await?, "url": url,
"fileName": self.file_name, "fileName": self.file_name,
"size": self.file_size.to_string(), "size": self.file_size.to_string(),
"sizeName": crate::util::get_display_size(self.file_size), "sizeName": crate::util::get_display_size(self.file_size),
"key": self.akey, "key": self.akey,
"object": "attachment" "object": "attachment"
})) })
} }
} }
+277 -83
View File
@@ -1,4 +1,4 @@
use std::borrow::Cow; use std::{borrow::Cow, collections::HashSet};
use chrono::{NaiveDateTime, TimeDelta, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
@@ -19,13 +19,14 @@ use crate::{
}, },
}, },
error::MapResult, error::MapResult,
util::LowerCase, util::{LowerCase, convert_json_key_lcase_first},
}; };
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, MembershipType, OrganizationId, User, UserId, MembershipStatus, OrganizationId, User, UserId,
organization::{ORG_ADMIN_ATYPES, custom_edit_any_collection},
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -63,14 +64,68 @@ pub struct Cipher {
pub reprompt: Option<i32>, pub reprompt: Option<i32>,
} }
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType { pub enum RepromptType {
None = 0, None = 0,
Password = 1, Password = 1,
} }
/// Which access a cipher operation is authorized with.
///
/// The administrative cipher routes (`/ciphers/<id>/admin` and friends) are served by the same handlers as the
/// regular vault routes, so the handlers state which one applies. They only differ for a Custom member with Edit
/// any collection, who reaches every cipher of the organization on the administrative routes, but keeps their
/// own vault limited to the collections they are assigned to.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CipherAccessScope {
User,
OrganizationAdmin,
}
impl CipherAccessScope {
/// Whether `member` reaches every cipher of their organization with this access.
fn reaches_all_ciphers(self, member: &Membership) -> bool {
match self {
CipherAccessScope::User => member.has_full_access(),
CipherAccessScope::OrganizationAdmin => member.can_edit_any_collection(),
}
}
/// The access the v2 attachment upload asks for with `adminRequest`. It only picks which check runs, the
/// administrative one still needs a member who may edit any collection.
pub fn requested(admin_request: Option<bool>) -> Self {
if admin_request == Some(true) {
CipherAccessScope::OrganizationAdmin
} else {
CipherAccessScope::User
}
}
/// The access for a route that does not tell which one it is, from the member's own membership.
pub fn for_member(member: Option<&Membership>) -> Self {
if member.is_some_and(Membership::can_edit_any_collection) {
CipherAccessScope::OrganizationAdmin
} else {
CipherAccessScope::User
}
}
}
/// Local methods /// Local methods
impl Cipher { impl Cipher {
pub fn new(atype: i32, name: String) -> Self { /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
pub fn new(atype: i32) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
Self { Self {
@@ -84,7 +139,7 @@ impl Cipher {
key: None, key: None,
atype, atype,
name, name: String::new(),
notes: None, notes: None,
fields: None, fields: None,
@@ -110,6 +165,16 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
} }
let is_blob = cipher.is_blob();
if let Err(e) = cipher.validate_content(is_blob) {
validation_errors
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
}
if let Err(e) = cipher.validate_type_data(is_blob) {
validation_errors
.insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap());
}
// Validate the password history if it contains `null` values and if so, return a warning // Validate the password history if it contains `null` values and if so, return a warning
if let Some(Value::Array(password_history)) = &cipher.password_history { if let Some(Value::Array(password_history)) = &cipher.password_history {
for pwh in password_history { for pwh in password_history {
@@ -151,9 +216,53 @@ impl Cipher {
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
sync_type: CipherSyncType, sync_type: CipherSyncType,
conn: &DbConn, conn: &DbConn,
) -> Result<Value, crate::Error> {
self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await
}
/// [`Cipher::to_json`] for an administrative route, telling what the member may do with the cipher there.
pub async fn to_json_org_admin(
&self,
host: &str,
user_uuid: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn)
.await
}
pub async fn to_json_org_admin_collections(
&self,
host: &str,
user_uuid: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
self.to_json_scoped(
host,
user_uuid,
None,
CipherSyncType::Organization,
CipherAccessScope::OrganizationAdmin,
conn,
)
.await
}
async fn to_json_scoped(
&self,
host: &str,
user_uuid: &UserId,
cipher_sync_data: Option<&CipherSyncData>,
sync_type: CipherSyncType,
scope: CipherAccessScope,
conn: &DbConn,
) -> Result<Value, crate::Error> { ) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date}; use crate::util::{format_date, validate_and_format_date};
// Parsed once, since `data` can be large and this runs for every cipher in a sync
let type_data = serde_json::from_str::<Value>(&self.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null; let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -161,7 +270,7 @@ impl Cipher {
{ {
let mut attachments_json_vec = vec![]; let mut attachments_json_vec = vec![];
for attachment in attachments { for attachment in attachments {
attachments_json_vec.push(attachment.to_json(host).await?); attachments_json_vec.push(attachment.to_json(host));
} }
attachments_json = Value::Array(attachments_json_vec); attachments_json = Value::Array(attachments_json_vec);
} }
@@ -170,7 +279,7 @@ impl Cipher {
if !attachments.is_empty() { if !attachments.is_empty() {
let mut attachments_json_vec = vec![]; let mut attachments_json_vec = vec![];
for attachment in attachments { for attachment in attachments {
attachments_json_vec.push(attachment.to_json(host).await?); attachments_json_vec.push(attachment.to_json(host));
} }
attachments_json = Value::Array(attachments_json_vec); attachments_json = Value::Array(attachments_json_vec);
} }
@@ -179,7 +288,7 @@ impl Cipher {
// We don't need these values at all for Organizational syncs // We don't need these values at all for Organizational syncs
// Skip any other database calls if this is the case and just return false. // Skip any other database calls if this is the case and just return false.
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await {
(ro, hp, mn) (ro, hp, mn)
} else { } else {
error!("Cipher ownership assertion failure"); error!("Cipher ownership assertion failure");
@@ -250,63 +359,9 @@ impl Cipher {
}) })
.unwrap_or_default(); .unwrap_or_default();
// Get the type_data or a default to an empty json object '{}'. // Like upstream, fields and password history stored as NULL are sent as null, not `[]`
// If not passing an empty object, mobile clients will crash. let fields_json = self.fields.is_some().then_some(fields_json);
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data) let password_history_json = self.password_history.is_some().then_some(password_history_json);
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
@@ -315,7 +370,7 @@ impl Cipher {
Cow::from(Vec::new()) Cow::from(Vec::new())
} }
} else { } else {
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await) Cow::from(self.get_admin_collections(user_uuid.clone(), scope, conn).await)
}; };
// There are three types of cipher response models in upstream // There are three types of cipher response models in upstream
@@ -403,10 +458,84 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
}; };
json_object[key] = type_data_json; if is_blob_encrypted {
// Like upstream, sent as-is with the structured fields null
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
Ok(json_object) Ok(json_object)
} }
/// The per-type data of a legacy cipher, with fixups for values known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data {
convert_json_key_lcase_first(data)
} else {
warn!("Error parsing data field for {}", self.uuid);
Value::Object(serde_json::Map::new())
};
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
type_data_json
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> { pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
let mut user_uuids = Vec::new(); let mut user_uuids = Vec::new();
match self.user_uuid { match self.user_uuid {
@@ -562,20 +691,21 @@ impl Cipher {
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
} }
/// Returns whether this cipher is owned by an org in which the user has full access. /// Returns whether this cipher is owned by an org in which the user reaches every cipher with `scope`.
async fn is_in_full_access_org( async fn is_in_full_access_org(
&self, &self,
user_uuid: &UserId, user_uuid: &UserId,
scope: CipherAccessScope,
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn, conn: &DbConn,
) -> bool { ) -> bool {
if let Some(ref org_uuid) = self.organization_uuid { if let Some(ref org_uuid) = self.organization_uuid {
if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
return cached_member.has_full_access(); return scope.reaches_all_ciphers(cached_member);
} }
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
return member.has_full_access(); return scope.reaches_all_ciphers(&member);
} }
} }
false false
@@ -608,6 +738,7 @@ impl Cipher {
pub async fn get_access_restrictions( pub async fn get_access_restrictions(
&self, &self,
user_uuid: &UserId, user_uuid: &UserId,
scope: CipherAccessScope,
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn, conn: &DbConn,
) -> Option<(bool, bool, bool)> { ) -> Option<(bool, bool, bool)> {
@@ -615,7 +746,7 @@ impl Cipher {
// a collection that the user has full access to. If so, there are no // a collection that the user has full access to. If so, there are no
// access restrictions. // access restrictions.
if self.is_owned_by_user(user_uuid) if self.is_owned_by_user(user_uuid)
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
{ {
return Some((false, false, true)); return Some((false, false, true));
@@ -738,8 +869,13 @@ impl Cipher {
.await .await
} }
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_write_accessible_to_user(
match self.get_access_restrictions(user_uuid, None, conn).await { &self,
user_uuid: &UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> bool {
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
Some((read_only, _hide_passwords, manage)) => !read_only || manage, Some((read_only, _hide_passwords, manage)) => !read_only || manage,
None => false, None => false,
} }
@@ -747,15 +883,20 @@ impl Cipher {
// used for checking if collection can be edited (only if user has access to a collection they // used for checking if collection can be edited (only if user has access to a collection they
// can write to and also passwords are not hidden to prevent privilege escalation) // can write to and also passwords are not hidden to prevent privilege escalation)
pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_in_editable_collection_by_user(
match self.get_access_restrictions(user_uuid, None, conn).await { &self,
user_uuid: &UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> bool {
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
None => false, None => false,
} }
} }
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool {
self.get_access_restrictions(user_uuid, None, conn).await.is_some() self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some()
} }
// Returns whether this cipher is a favorite of the specified user. // Returns whether this cipher is a favorite of the specified user.
@@ -872,7 +1013,7 @@ impl Cipher {
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
); );
} }
@@ -907,7 +1048,7 @@ impl Cipher {
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
); );
} }
@@ -1071,7 +1212,12 @@ impl Cipher {
} }
} }
pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec<CollectionId> { pub async fn get_admin_collections(
&self,
user_uuid: UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> Vec<CollectionId> {
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
conn.run(move |conn| { conn.run(move |conn| {
ciphers_collections::table ciphers_collections::table
@@ -1111,7 +1257,9 @@ impl Cipher {
.or(collections_groups::collections_uuid .or(collections_groups::collections_uuid
.is_not_null() // Access via groups .is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false))) .and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or(custom_edit_any_collection()
.and::<_, diesel::sql_types::Bool>(scope == CipherAccessScope::OrganizationAdmin)),
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@@ -1140,7 +1288,9 @@ impl Cipher {
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or(custom_edit_any_collection()
.and::<_, diesel::sql_types::Bool>(scope == CipherAccessScope::OrganizationAdmin)),
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@@ -1182,7 +1332,7 @@ impl Cipher {
) )
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
.or_filter(users_organizations::access_all.eq(true)) // User has access all .or_filter(users_organizations::access_all.eq(true)) // User has access all
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group .or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
@@ -1193,6 +1343,50 @@ impl Cipher {
}) })
.await .await
} }
/// Every collection of every cipher of the organization, for the members who may see all of them.
pub async fn get_collections_with_cipher_by_organization(
org_uuid: &OrganizationId,
conn: &DbConn,
) -> Vec<(CipherId, CollectionId)> {
conn.run(move |conn| {
ciphers_collections::table
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.filter(collections::org_uuid.eq(org_uuid))
.select(ciphers_collections::all_columns)
.load::<(CipherId, CollectionId)>(conn)
.unwrap_or_default()
})
.await
}
/// The ciphers of the organization of `member` the Admin Console lists when they may not edit every cipher, like
/// Bitwarden does: the ones in the member's own vault, then, for a member who may edit any collection, the ones in
/// no collection at all.
pub async fn find_assigned_to_member(member: &Membership, conn: &DbConn) -> (Vec<Self>, Vec<Self>) {
let assigned: Vec<Self> = Self::find_by_user_visible(&member.user_uuid, conn)
.await
.into_iter()
.filter(|cipher| cipher.organization_uuid.as_ref() == Some(&member.org_uuid))
.collect();
if !member.can_edit_any_collection() {
return (assigned, Vec::new());
}
let listed: HashSet<CipherId> = assigned.iter().map(|cipher| cipher.uuid.clone()).collect();
let in_collections: HashSet<CipherId> =
Self::get_collections_with_cipher_by_organization(&member.org_uuid, conn)
.await
.into_iter()
.map(|(cipher, _)| cipher)
.collect();
let unassigned = Self::find_by_org(&member.org_uuid, conn)
.await
.into_iter()
.filter(|cipher| !in_collections.contains(&cipher.uuid) && !listed.contains(&cipher.uuid))
.collect();
(assigned, unassigned)
}
} }
#[derive( #[derive(
+48 -16
View File
@@ -18,7 +18,7 @@ use macros::UuidFromParam;
use super::{ use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId, User, UserId, custom_roles_enabled, organization::ORG_ADMIN_ATYPES,
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@@ -109,18 +109,22 @@ impl Collection {
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) => { Some(m) => {
// Only let a manager manage collections when the have full read/write access // Only let a manager manage collections when the have full read/write access
// A Custom member only manages a collection with the manage option
let is_manager = m.atype == MembershipType::Manager; let is_manager = m.atype == MembershipType::Manager;
let is_custom = m.has_type(MembershipType::Custom) && custom_roles_enabled();
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
( (
cu.read_only, cu.read_only,
cu.hide_passwords, cu.hide_passwords,
is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)), (is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)))
|| (is_custom && cu.manage),
) )
} else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) { } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) {
( (
cg.read_only, cg.read_only,
cg.hide_passwords, cg.hide_passwords,
is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)), (is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)))
|| (is_custom && cg.manage),
) )
} else { } else {
(false, false, false) (false, false, false)
@@ -134,6 +138,17 @@ impl Collection {
Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => { Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => {
(false, false, true) (false, false, true)
} }
// A Custom member reads and writes every collection with Edit any collection in the Admin Console, but
// only manages a collection with the manage option on it
Some(m) if m.has_type(MembershipType::Custom) && custom_roles_enabled() => {
let manage = self.is_explicitly_manageable_by_user(user_uuid, conn).await;
if manage || m.can_edit_any_collection() {
(false, false, manage)
} else {
let read_only = !self.is_writable_by_user(user_uuid, conn).await;
(read_only, self.hide_passwords_for_user(user_uuid, conn).await, false)
}
}
Some(m) => { Some(m) => {
let is_manager = m.atype == MembershipType::Manager; let is_manager = m.atype == MembershipType::Manager;
let read_only = !self.is_writable_by_user(user_uuid, conn).await; let read_only = !self.is_writable_by_user(user_uuid, conn).await;
@@ -375,7 +390,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@@ -412,7 +427,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
)) ))
.select(collections::all_columns) .select(collections::all_columns)
@@ -455,7 +470,7 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::access_all.eq(true)) // access_all via membership
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
@@ -489,7 +504,7 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::access_all.eq(true)) // access_all via membership
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
@@ -540,7 +555,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@@ -600,7 +615,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@@ -635,8 +650,23 @@ impl Collection {
user_uuid: &UserId, user_uuid: &UserId,
conn: &DbConn, conn: &DbConn,
) -> bool { ) -> bool {
!Self::find_explicitly_manageable_by_user(org_uuid, user_uuid, conn).await.is_empty()
}
pub async fn is_explicitly_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::find_explicitly_manageable_by_user(&self.org_uuid, user_uuid, conn).await.contains(&self.uuid)
}
// The collections of the org the user has manage access to by the manage option, directly or via a group.
// Unlike is_coll_manageable_by_user, neither the role nor any access_all counts.
pub async fn find_explicitly_manageable_by_user(
org_uuid: &OrganizationId,
user_uuid: &UserId,
conn: &DbConn,
) -> Vec<CollectionId> {
let org_uuid = org_uuid.to_string(); let org_uuid = org_uuid.to_string();
let user_uuid = user_uuid.to_string(); let user_uuid = user_uuid.to_string();
let groups_enabled = CONFIG.org_groups_enabled();
conn.run(move |conn| { conn.run(move |conn| {
collections::table collections::table
.left_join( .left_join(
@@ -658,14 +688,16 @@ impl Collection {
.filter(collections::org_uuid.eq(&org_uuid)) .filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
// Manage permission on a collection assigned directly or via a group. users_collections::manage.eq(true).or(users_collections::user_uuid
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)), .is_null()
.or(users_organizations::atype.eq(MembershipType::Manager as i32))
.and(collections_groups::manage.eq(true))
.and::<_, diesel::sql_types::Bool>(groups_enabled)),
) )
.count() .select(collections::uuid)
.first::<i64>(conn) .distinct()
.ok() .load::<CollectionId>(conn)
.unwrap_or(0) .unwrap_or_default()
!= 0
}) })
.await .await
} }
+8 -4
View File
@@ -17,11 +17,12 @@ mod two_factor;
mod two_factor_duo_context; mod two_factor_duo_context;
mod two_factor_incomplete; mod two_factor_incomplete;
mod user; mod user;
mod user_signature_key_pair;
pub use self::archive::Archive; pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId}; pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{ pub use self::emergency_access::{
@@ -32,13 +33,16 @@ pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
#[cfg(all(test, sqlite))]
pub(crate) use self::organization::test_flag;
pub use self::organization::{ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
OrganizationId, OrganizationApiKey, OrganizationId, custom_roles_enabled,
}; };
pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::send::{Send, SendFileId, SendId, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException}; pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
+621 -65
View File
@@ -1,4 +1,7 @@
use std::cmp::Ordering; use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
};
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
@@ -16,15 +19,27 @@ use crate::{
organizations, users, users_collections, users_organizations, organizations, users, users_collections, users_organizations,
}, },
}, },
error::MapResult, error::{Error, MapResult},
}; };
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, KeyId, OrgPolicy,
TwoFactor, User, UserId, OrgPolicyType, TwoFactor, User, UserId,
}; };
/// A membership whose account recovery key still wraps the member's V1 user key: an upgrade to V2 encryption can't
/// re-wrap it (that needs the member to trust the organization's key), so it left the token the admin re-wraps it from.
#[derive(Queryable)]
pub struct PendingV2Upgrade {
pub member_id: MembershipId,
/// The member's current user key id, which the re-wrapped key must be sent back with
pub user_key_id: KeyId,
/// The V1 user key, wrapped with the organization's public key
pub account_recovery_key: String,
pub v2_upgrade_token: String,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = organizations)] #[diesel(table_name = organizations)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
@@ -41,6 +56,7 @@ pub struct Organization {
#[diesel(table_name = users_organizations)] #[diesel(table_name = users_organizations)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))] #[diesel(primary_key(uuid))]
#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")]
pub struct Membership { pub struct Membership {
pub uuid: MembershipId, pub uuid: MembershipId,
pub user_uuid: UserId, pub user_uuid: UserId,
@@ -54,6 +70,20 @@ pub struct Membership {
pub atype: i32, pub atype: i32,
pub reset_password_key: Option<String>, pub reset_password_key: Option<String>,
pub external_id: Option<String>, pub external_id: Option<String>,
// The permissions of a Custom role, only accessed through `CustomPermission`.
manage_users: bool,
manage_groups: bool,
manage_policies: bool,
create_new_collections: bool,
edit_any_collection: bool,
delete_any_collection: bool,
access_event_logs: bool,
access_import_export: bool,
access_reports: bool,
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
pub v2_upgrade_token: Option<String>,
} }
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -89,42 +119,74 @@ impl MembershipStatus {
} }
} }
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] #[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)]
pub enum MembershipType { pub enum MembershipType {
Owner = 0, Owner = 0,
Admin = 1, Admin = 1,
User = 2, User = 2,
// The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead.
Manager = 3, Manager = 3,
// Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled.
Custom = 4,
} }
impl MembershipType { impl MembershipType {
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
#[expect(
clippy::match_same_arms,
reason = "Specifically define `4|Custom` since this is a hack, not a default"
)]
match s { match s {
"0" | "Owner" => Some(MembershipType::Owner), "0" | "Owner" => Some(MembershipType::Owner),
"1" | "Admin" => Some(MembershipType::Admin), "1" | "Admin" => Some(MembershipType::Admin),
"2" | "User" => Some(MembershipType::User), "2" | "User" => Some(MembershipType::User),
"3" | "Manager" => Some(MembershipType::Manager), "3" | "Manager" => Some(MembershipType::Manager),
// HACK: We convert the custom role to a manager role "4" | "Custom" => Some(MembershipType::Custom),
"4" | "Custom" => Some(MembershipType::Manager),
_ => None, _ => None,
} }
} }
/// The role a member invite or edit from the Bitwarden clients asks for, given the member's current
/// role (`None` for an invite).
///
/// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled, in which case
/// `custom_permissions` are the permissions requested along with it. An existing Manager or Custom member
/// keeps their role when the request repeats it, so an unrelated edit never turns one into the other. A
/// Manager only becomes a Custom member when the permissions they are shown with are changed.
pub fn requested(
requested: MembershipType,
current: Option<&Membership>,
custom_permissions: Option<&HashSet<CustomPermission>>,
) -> MembershipType {
if requested != MembershipType::Custom {
return requested;
}
match (current, custom_permissions) {
(Some(member), _) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
(Some(member), Some(permissions))
if member.has_type(MembershipType::Manager) && *permissions != member.legacy_manager_permissions() =>
{
MembershipType::Custom
}
(Some(member), _) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
(_, Some(_)) => MembershipType::Custom,
(_, None) => MembershipType::Manager,
}
}
/// The authority of a role, which is what roles are compared by. A Custom member gets its authority from
/// its permissions, never from its role, so the role itself ranks right above User and below Manager.
const fn rank(self) -> u8 {
match self {
MembershipType::User => 0,
MembershipType::Custom => 1,
MembershipType::Manager => 2,
MembershipType::Admin => 3,
MembershipType::Owner => 4,
}
}
} }
impl Ord for MembershipType { impl Ord for MembershipType {
fn cmp(&self, other: &MembershipType) -> Ordering { fn cmp(&self, other: &MembershipType) -> Ordering {
// For easy comparison, map each variant to an access level (where 0 is lowest). self.rank().cmp(&other.rank())
const ACCESS_LEVEL: [i32; 4] = [
3, // Owner
2, // Admin
0, // User
1, // Manager && Custom
];
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
} }
} }
@@ -180,6 +242,125 @@ impl PartialOrd<MembershipType> for i32 {
} }
} }
/// The granular permissions of the Custom role.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CustomPermission {
ManageUsers,
ManageGroups,
ManagePolicies,
CreateNewCollections,
EditAnyCollection,
DeleteAnyCollection,
AccessEventLogs,
AccessImportExport,
AccessReports,
}
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them.
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
CustomPermission::ManageUsers,
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
];
impl CustomPermission {
pub const ALL: [CustomPermission; 9] = [
CustomPermission::ManageUsers,
CustomPermission::ManageGroups,
CustomPermission::ManagePolicies,
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
CustomPermission::AccessEventLogs,
CustomPermission::AccessImportExport,
CustomPermission::AccessReports,
];
/// The key of the permission in the `permissions` object of the Bitwarden clients.
pub const fn json_key(self) -> &'static str {
match self {
CustomPermission::ManageUsers => "manageUsers",
CustomPermission::ManageGroups => "manageGroups",
CustomPermission::ManagePolicies => "managePolicies",
CustomPermission::CreateNewCollections => "createNewCollections",
CustomPermission::EditAnyCollection => "editAnyCollection",
CustomPermission::DeleteAnyCollection => "deleteAnyCollection",
CustomPermission::AccessEventLogs => "accessEventLogs",
CustomPermission::AccessImportExport => "accessImportExport",
CustomPermission::AccessReports => "accessReports",
}
}
pub fn is_supported(self) -> bool {
SUPPORTED_CUSTOM_PERMISSIONS.contains(&self)
}
/// The permissions a member invite or edit grants a Custom role.
///
/// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
/// permission this version does not enforce is rejected as well. The clients also send permissions
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, Error> {
let mut granted = HashSet::new();
for permission in CustomPermission::ALL {
match permissions.get(permission.json_key()) {
None | Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) if permission.is_supported() => {
granted.insert(permission);
}
Some(Value::Bool(true)) => {
err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key()))
}
Some(_) => {
err!(format!("The Custom role permission '{}' must be true or false", permission.json_key()))
}
}
}
Ok(granted)
}
}
/// The stored roles with authority over the whole organization. Queries match them by value, comparing
/// `atype <= Admin` would also match any unknown value below it.
pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32];
/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise.
pub fn custom_roles_enabled() -> bool {
#[cfg(test)]
if let Some(enabled) = test_flag::CUSTOM_ROLES_ENABLED.get() {
return enabled;
}
CONFIG.experimental_custom_roles()
}
type CustomEditAnyCollection = diesel::dsl::And<
diesel::dsl::And<
diesel::dsl::Eq<users_organizations::atype, i32>,
diesel::dsl::Eq<users_organizations::edit_any_collection, bool>,
>,
bool,
diesel::sql_types::Bool,
>;
/// SQL: a Custom member with Edit any collection, while Custom roles are enabled.
pub(super) fn custom_edit_any_collection() -> CustomEditAnyCollection {
users_organizations::atype
.eq(MembershipType::Custom as i32)
.and(users_organizations::edit_any_collection.eq(true))
.and::<_, diesel::sql_types::Bool>(custom_roles_enabled())
}
#[cfg(test)]
pub mod test_flag {
thread_local! {
/// Overrides EXPERIMENTAL_CUSTOM_ROLES for the tests running on this thread.
pub static CUSTOM_ROLES_ENABLED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
}
}
/// Local methods /// Local methods
impl Organization { impl Organization {
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self { pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
@@ -271,9 +452,154 @@ impl Membership {
atype: MembershipType::User as i32, atype: MembershipType::User as i32,
reset_password_key: None, reset_password_key: None,
external_id: None, external_id: None,
manage_users: false,
manage_groups: false,
manage_policies: false,
create_new_collections: false,
edit_any_collection: false,
delete_any_collection: false,
access_event_logs: false,
access_import_export: false,
access_reports: false,
v2_upgrade_token: None,
} }
} }
fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool {
match permission {
CustomPermission::ManageUsers => &mut self.manage_users,
CustomPermission::ManageGroups => &mut self.manage_groups,
CustomPermission::ManagePolicies => &mut self.manage_policies,
CustomPermission::CreateNewCollections => &mut self.create_new_collections,
CustomPermission::EditAnyCollection => &mut self.edit_any_collection,
CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection,
CustomPermission::AccessEventLogs => &mut self.access_event_logs,
CustomPermission::AccessImportExport => &mut self.access_import_export,
CustomPermission::AccessReports => &mut self.access_reports,
}
}
/// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are
/// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy
/// `access_all` flag, its reach only comes from its permissions.
pub fn set_type(&mut self, new_type: MembershipType) {
if self.atype != new_type as i32 {
for permission in CustomPermission::ALL {
*self.custom_permission_mut(permission) = false;
}
}
if new_type == MembershipType::Custom {
self.access_all = false;
}
self.atype = new_type as i32;
}
/// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a
/// permission this version does not enforce is kept as it is: it grants nothing here.
pub fn set_custom_permissions(&mut self, permissions: &HashSet<CustomPermission>) {
for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) {
*self.custom_permission_mut(permission) = permissions.contains(&permission);
}
}
/// Why this legacy Manager can't become a Custom member with `permissions` without losing what they may do now, if
/// they can't. A conversion must not silently change their access. A Custom role can't keep access to all
/// collections, which also puts every collection in the Manager's own vault, unless the permissions it is shown as
/// are given up too. And it only manages every collection with Edit any collection, not through a group with access
/// to all collections, which stays dynamic. The collections they manage with the manage permission stay managed.
pub async fn custom_role_conversion_loss(
&self,
permissions: &HashSet<CustomPermission>,
conn: &DbConn,
) -> Option<&'static str> {
if !self.has_type(MembershipType::Manager) {
return None;
}
if self.access_all && permissions.is_superset(&self.legacy_manager_permissions()) {
return Some(
"This Manager has access to all collections, which a Custom member can't have: the collections they \
aren't assigned to would disappear from their vault. Uncheck Manage all collections for them to give \
that access up, or keep the Manager role.",
);
}
if !permissions.contains(&CustomPermission::EditAnyCollection)
&& GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await
{
return Some(
"This Manager manages every collection through a group with access to all collections, which a Custom \
member only does with Edit any collection. Grant that permission, or remove them from the group \
first.",
);
}
None
}
fn custom_permission(&self, permission: CustomPermission) -> bool {
match permission {
CustomPermission::ManageUsers => self.manage_users,
CustomPermission::ManageGroups => self.manage_groups,
CustomPermission::ManagePolicies => self.manage_policies,
CustomPermission::CreateNewCollections => self.create_new_collections,
CustomPermission::EditAnyCollection => self.edit_any_collection,
CustomPermission::DeleteAnyCollection => self.delete_any_collection,
CustomPermission::AccessEventLogs => self.access_event_logs,
CustomPermission::AccessImportExport => self.access_import_export,
CustomPermission::AccessReports => self.access_reports,
}
}
/// Whether the role of this member grants `permission`. Only a Custom role grants any, only while Custom roles
/// are enabled, and only the permissions this version enforces.
fn grants_custom_permission(&self, permission: CustomPermission) -> bool {
custom_roles_enabled()
&& permission.is_supported()
&& self.has_type(MembershipType::Custom)
&& self.custom_permission(permission)
}
/// Whether this member may act with `permission`, granted by their Custom role. Like the authority of any
/// other role, it needs a confirmed membership.
pub fn has_custom_permission(&self, permission: CustomPermission) -> bool {
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
}
/// The permissions the "Manage all collections" a legacy Manager is shown with stands for: their `access_all`.
pub fn legacy_manager_permissions(&self) -> HashSet<CustomPermission> {
if self.has_type(MembershipType::Manager) && self.access_all {
HashSet::from([
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
])
} else {
HashSet::new()
}
}
/// The permissions the clients are shown, which a legacy Manager is shown as a Custom member with.
pub fn shown_custom_permissions(&self) -> HashSet<CustomPermission> {
if self.has_type(MembershipType::Manager) {
self.legacy_manager_permissions()
} else {
CustomPermission::ALL.into_iter().filter(|p| self.grants_custom_permission(*p)).collect()
}
}
fn custom_permissions_json(&self) -> Value {
let shown = self.shown_custom_permissions();
let mut permissions = json!({
"manageSso": false, // Not supported
"manageResetPassword": false,
"manageScim": false, // Not supported (Not AGPLv3 Licensed)
});
for permission in CustomPermission::ALL {
permissions[permission.json_key()] = json!(shown.contains(&permission));
}
permissions
}
pub fn restore(&mut self) -> bool { pub fn restore(&mut self) -> bool {
if self.status < MembershipStatus::Invited as i32 { if self.status < MembershipStatus::Invited as i32 {
self.status += ACTIVATE_REVOKE_DIFF; self.status += ACTIVATE_REVOKE_DIFF;
@@ -442,24 +768,8 @@ impl Membership {
// It will be converted back on other locations // It will be converted back on other locations
let membership_type = self.type_manager_as_custom(); let membership_type = self.type_manager_as_custom();
let permissions = json!({ // A Manager is shown with the 3 Collection permissions set, when they have the access_all permission
// TODO: Add full support for Custom User Roles let permissions = self.custom_permissions_json();
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": membership_type == 4 && self.access_all,
"editAnyCollection": membership_type == 4 && self.access_all,
"deleteAnyCollection": membership_type == 4 && self.access_all,
"manageGroups": false,
"managePolicies": false,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
});
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({ json!({
@@ -589,29 +899,11 @@ impl Membership {
// It will be converted back on other locations // It will be converted back on other locations
let membership_type = self.type_manager_as_custom(); let membership_type = self.type_manager_as_custom();
// HACK: Only return permissions if the user is of type custom and has access_all // Only return permissions if there are any, else Bitwarden will assume the defaults of all false
// Else Bitwarden will assume the defaults of all false let permissions = if self.shown_custom_permissions().is_empty() {
let permissions = if membership_type == 4 && self.access_all {
json!({
// TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": true,
"editAnyCollection": true,
"deleteAnyCollection": true,
"manageGroups": false,
"managePolicies": false,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
} else {
json!(null) json!(null)
} else {
self.custom_permissions_json()
}; };
json!({ json!({
@@ -712,6 +1004,12 @@ impl Membership {
} }
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
// The legacy `access_all` flag reaches every collection, which a Custom role may only do through its
// permissions, so it must never be stored on one. `set_type` takes care of this.
if self.has_type(MembershipType::Custom) && self.access_all {
err!("A Custom role membership can't have access to all collections")
}
User::update_uuid_revision(&self.user_uuid, conn).await; User::update_uuid_revision(&self.user_uuid, conn).await;
db_run! { conn: db_run! { conn:
@@ -786,6 +1084,25 @@ impl Membership {
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed) (self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
} }
/// Whether this member may edit every collection and the items in them in the Admin Console: every member with
/// full access, and a Custom member with Edit any collection. Unlike full access, the permission does not add
/// anything to the member's own vault.
pub fn can_edit_any_collection(&self) -> bool {
self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection)
}
/// Whether this member may see every collection and who has access to them in the Admin Console. Deleting any
/// collection needs that, but not the items in them.
pub fn can_view_all_collections(&self) -> bool {
self.can_edit_any_collection() || self.has_custom_permission(CustomPermission::DeleteAnyCollection)
}
/// Whether this member may read every collection's member and group assignments in the Admin Console.
/// Manage users grants this access without granting the plain all-collections listing or metadata editing.
pub fn can_view_all_collection_access_details(&self) -> bool {
self.can_view_all_collections() || self.has_custom_permission(CustomPermission::ManageUsers)
}
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok() users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
@@ -908,6 +1225,8 @@ impl Membership {
} }
// Get all users which are either owner or admin, or a manager which can manage/access all // Get all users which are either owner or admin, or a manager which can manage/access all
// A Custom member with Edit any collection is not listed: the Admin Console sends this list back when a collection
// is saved, which would assign them to it for good, while the others are skipped there for their access_all.
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> { pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
@@ -949,6 +1268,90 @@ impl Membership {
.await .await
} }
/// The most account recovery V2 upgrades one request reads or writes, like upstream's
/// `OrganizationUserV2UpgradesRequestModel.MaxUpgrades`.
pub const MAX_V2_UPGRADES: i64 = 100;
/// The next page of memberships of this organization whose account recovery key the admin still has to re-wrap
/// with the member's V2 user key: enrolled, carrying an upgrade token, of a user with a key id (the re-wrapped key
/// is checked against it), and Owners only for an Owner. Ordered by id, after `after`, at most `MAX_V2_UPGRADES`.
pub async fn find_pending_v2_upgrades(
org_uuid: &OrganizationId,
include_owners: bool,
after: Option<&MembershipId>,
conn: &DbConn,
) -> Vec<PendingV2Upgrade> {
conn.run(move |conn| {
let mut query = users_organizations::table
.inner_join(users::table)
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::v2_upgrade_token.is_not_null())
.filter(users_organizations::reset_password_key.is_not_null())
.filter(users::key_id.is_not_null())
.into_boxed();
if !include_owners {
query = query.filter(users_organizations::atype.ne(MembershipType::Owner as i32));
}
if let Some(after) = after {
query = query.filter(users_organizations::uuid.gt(after));
}
query
.order(users_organizations::uuid)
.limit(Self::MAX_V2_UPGRADES)
// The filters above make sure these are set
.select((
users_organizations::uuid,
users::key_id.assume_not_null(),
users_organizations::reset_password_key.assume_not_null(),
users_organizations::v2_upgrade_token.assume_not_null(),
))
.load(conn)
.unwrap_or_default()
})
.await
}
/// Replaces the account recovery key of a member who upgraded to V2 encryption with `account_recovery_key`, or
/// unenrolls them when it is `None`, and consumes the upgrade token, so it can't be replayed. Only while the
/// membership is still enrolled, carrying the token, not an Owner unless `include_owners`, and its user's key id is
/// still `user_key_id` (the key the new recovery key wraps). Returns whether it applied, otherwise the membership
/// stays pending. It runs inside a transaction, so it leaves the user's revision to the caller.
pub async fn apply_v2_upgrade(
&self,
include_owners: bool,
user_key_id: &KeyId,
account_recovery_key: Option<&str>,
conn: &DbConn,
) -> Result<bool, Error> {
// An empty list excludes no one
let excluded = if include_owners {
vec![]
} else {
vec![MembershipType::Owner as i32]
};
let rows: usize = conn
.run(move |conn| {
diesel::update(
users_organizations::table
.filter(users_organizations::uuid.eq(&self.uuid))
.filter(users_organizations::v2_upgrade_token.is_not_null())
.filter(users_organizations::reset_password_key.is_not_null())
.filter(users_organizations::atype.ne_all(excluded))
.filter(diesel::dsl::exists(
users::table.filter(users::uuid.eq(&self.user_uuid)).filter(users::key_id.eq(user_key_id)),
)),
)
.set((
users_organizations::reset_password_key.eq(account_recovery_key),
users_organizations::v2_upgrade_token.eq(None::<String>),
))
.execute(conn)
.map_res("Error saving membership")
})
.await?;
Ok(rows > 0)
}
pub async fn count_confirmed_by_org_and_type( pub async fn count_confirmed_by_org_and_type(
org_uuid: &OrganizationId, org_uuid: &OrganizationId,
atype: MembershipType, atype: MembershipType,
@@ -1228,13 +1631,166 @@ pub struct OrgApiKeyId(String);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use CustomPermission::*;
use MembershipType::{Admin, Custom, Manager, Owner, User};
fn member(role: MembershipType) -> Membership {
let mut member = Membership::new("user".into(), "org".into(), None);
member.atype = role as i32;
member.status = MembershipStatus::Confirmed as i32;
member
}
#[test] #[test]
#[allow(non_snake_case)] fn membership_type_authority() {
fn partial_cmp_MembershipType() { assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
assert!(MembershipType::Owner > MembershipType::Admin); assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
assert!(MembershipType::Admin > MembershipType::Manager); for raw in [99, -1] {
assert!(MembershipType::Manager > MembershipType::User); assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); }
for (value, expected) in [
("3", Some(Manager)),
("Manager", Some(Manager)),
("4", Some(Custom)),
("Custom", Some(Custom)),
("5", None),
] {
assert_eq!(MembershipType::from_str(value), expected);
}
}
#[test]
fn requested_membership_type() {
let none = HashSet::new();
let edit = HashSet::from([EditAnyCollection]);
let legacy = HashSet::from([CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
for (current, all, permissions, disabled, enabled) in [
(None, false, &none, Manager, Custom),
(Some(User), false, &edit, Manager, Custom),
(Some(Owner), false, &none, Manager, Custom),
(Some(Manager), false, &none, Manager, Manager),
(Some(Manager), true, &legacy, Manager, Manager),
(Some(Manager), false, &edit, Manager, Custom),
(Some(Manager), true, &none, Manager, Custom),
(Some(Manager), true, &edit, Manager, Custom),
(Some(Custom), false, &none, Custom, Custom),
] {
let current = current.map(|role| {
let mut m = member(role);
m.access_all = all;
m
});
for (permissions, expected) in [(None, disabled), (Some(permissions), enabled)] {
assert_eq!(MembershipType::requested(Custom, current.as_ref(), permissions), expected);
}
}
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), None), User);
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), None), User);
for requested in [Owner, Admin, Manager] {
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), None), requested);
}
}
#[test]
fn custom_permission_requests() {
let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap();
assert!(CustomPermission::requested(&ignored).unwrap().is_empty());
for permission in CustomPermission::ALL {
for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] {
let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]);
let result = CustomPermission::requested(&request);
if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) {
assert_eq!(result.unwrap(), HashSet::from([permission]));
} else {
assert!(result.is_err(), "{permission:?}: {value}");
}
}
}
assert_eq!(
SUPPORTED_CUSTOM_PERMISSIONS,
[ManageUsers, CreateNewCollections, EditAnyCollection, DeleteAnyCollection]
);
}
#[test]
fn role_changes_drop_custom_permissions() {
for (from, to) in [
(Custom, Owner),
(Custom, Admin),
(Custom, Manager),
(Custom, User),
(User, Custom),
(Manager, Custom),
(Custom, Custom),
(Admin, User),
] {
let mut member = member(from);
member.access_all = true;
for permission in CustomPermission::ALL {
*member.custom_permission_mut(permission) = true;
}
member.set_type(to);
for permission in CustomPermission::ALL {
assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}");
}
assert_eq!(member.access_all, to != Custom);
}
}
#[test]
fn permission_matrix_preserves_role_status_flag_and_collection_boundaries() {
let legacy = HashSet::from([CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
for (role, all) in
[(Owner, false), (Admin, false), (Manager, false), (Manager, true), (Custom, false), (User, false)]
{
for permission in CustomPermission::ALL {
let mut member = member(role);
member.access_all = all;
*member.custom_permission_mut(permission) = true;
for enabled in [false, true] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
let shown = if role == Manager && all {
legacy.clone()
} else if role == Custom && enabled && permission.is_supported() {
HashSet::from([permission])
} else {
HashSet::new()
};
assert_eq!(member.shown_custom_permissions(), shown);
for status in [-128, -127, -126, -3, -2, -1, 0, 1, 2] {
member.status = status;
let allowed = enabled && role == Custom && status == 2 && permission.is_supported();
let full = status == 2 && (role == Owner || role == Admin || all);
let edit = full || (allowed && permission == EditAnyCollection);
let view = full || (allowed && matches!(permission, EditAnyCollection | DeleteAnyCollection));
assert_eq!(
(
member.has_custom_permission(permission),
member.can_edit_any_collection(),
member.can_view_all_collections()
),
(allowed, edit, view),
"{role:?} {all} {enabled} {status} {permission:?}"
);
}
}
}
}
for raw in [99, -1] {
let mut member = member(Custom);
member.atype = raw;
for p in CustomPermission::ALL {
*member.custom_permission_mut(p) = true;
}
for enabled in [false, true] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
assert!(CustomPermission::ALL.into_iter().all(|p| !member.has_custom_permission(p)));
assert!(!member.can_edit_any_collection() && !member.can_view_all_collections());
}
}
let empty = member(Custom);
assert!(!empty.can_edit_any_collection() && !empty.can_view_all_collections());
assert!(empty.shown_custom_permissions().is_empty());
test_flag::CUSTOM_ROLES_ENABLED.set(None);
} }
} }
+202 -28
View File
@@ -6,6 +6,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::EmptyResult, api::EmptyResult,
auth::DEFAULT_ACCESS_VALIDITY,
crypto, crypto,
db::{ db::{
DbConn, DbConn,
@@ -20,7 +21,7 @@ use macros::UuidFromParam;
use super::{ use super::{
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete, TwoFactorIncomplete, UserSignatureKeyPair,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@@ -72,6 +73,13 @@ pub struct User {
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore. pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>, pub key_id: Option<KeyId>,
// The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2`
pub signed_public_key: Option<String>,
pub security_state: Option<String>,
pub security_version: Option<i32>,
/// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us.
pub v2_upgrade_token: Option<String>,
} }
#[derive(Identifiable, Queryable, Insertable)] #[derive(Identifiable, Queryable, Insertable)]
@@ -100,13 +108,28 @@ enum UserStatus {
_Disabled = 2, _Disabled = 2,
} }
/// A previous security stamp that is still accepted, until `expire`.
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct UserStampException { pub struct UserStampException {
pub routes: Vec<String>, /// The routes the stamp is still accepted on, or `None` for any route.
pub routes: Option<Vec<String>>,
pub security_stamp: String, pub security_stamp: String,
pub expire: i64, pub expire: i64,
} }
impl UserStampException {
pub fn is_expired(&self) -> bool {
Utc::now().timestamp() > self.expire
}
/// Whether this exception lets a token carrying `security_stamp` through on `route`.
pub fn allows(&self, security_stamp: &str, route: &str) -> bool {
!self.is_expired()
&& self.security_stamp == security_stamp
&& self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route))
}
}
/// Local methods /// Local methods
impl User { impl User {
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
@@ -159,9 +182,31 @@ impl User {
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None, key_id: None,
signed_public_key: None,
security_state: None,
security_version: None,
v2_upgrade_token: None,
} }
} }
pub fn is_v2(&self) -> bool {
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
}
/// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2.
/// Upstream only uses it to decide which accounts need a v2-capable client.
///
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/Entities/User.cs#L245-L278>
pub fn has_v2_encryption(&self) -> bool {
self.security_version == Some(2)
&& self
.private_key
.as_deref()
.and_then(|private_key| private_key.split_once('.'))
.is_some_and(|(enc_type, _)| enc_type.parse::<u8>() == Ok(7))
}
pub fn check_valid_password(&self, password: &str) -> bool { pub fn check_valid_password(&self, password: &str) -> bool {
crypto::verify_password_hash( crypto::verify_password_hash(
password.as_bytes(), password.as_bytes(),
@@ -171,6 +216,7 @@ impl User {
) )
} }
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
pub fn master_password_salt(&self) -> String { pub fn master_password_salt(&self) -> String {
self.email.trim().to_lowercase() self.email.trim().to_lowercase()
} }
@@ -225,10 +271,33 @@ impl User {
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult { pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid(); self.security_stamp = get_uuid();
// Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace
let mut exceptions = self.stamp_exceptions();
exceptions.retain(|e| e.routes.is_some() && !e.is_expired());
self.set_stamp_exceptions(&exceptions);
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?; Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
Ok(()) Ok(())
} }
/// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh.
pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult {
let previous_stamp = self.security_stamp.clone();
let mut grace: Vec<UserStampException> =
self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect();
self.reset_security_stamp(conn).await?;
grace.push(UserStampException {
routes: None,
security_stamp: previous_stamp,
expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(),
});
let mut exceptions = self.stamp_exceptions();
exceptions.extend(grace);
self.set_stamp_exceptions(&exceptions);
Ok(())
}
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
/// ///
/// # Arguments /// # Arguments
@@ -237,17 +306,30 @@ impl User {
/// After these 2 minutes this stamp will expire. /// After these 2 minutes this stamp will expire.
/// ///
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) { pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
let stamp_exception = UserStampException { self.set_stamp_exceptions(&[UserStampException {
routes: route_exception, routes: Some(route_exception),
security_stamp: self.security_stamp.clone(), security_stamp: self.security_stamp.clone(),
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(), expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
}; }]);
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
} }
/// Resets the stamp_exception to prevent re-use of the previous security-stamp /// The previous security stamps that are still accepted, expired ones included.
pub fn reset_stamp_exception(&mut self) { pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
self.stamp_exception = None; let Some(stored) = self.stamp_exception.as_deref() else {
return Vec::new();
};
// Before key rotations kept sessions alive, only a single route exception was stored.
serde_json::from_str::<Vec<UserStampException>>(stored)
.or_else(|_| serde_json::from_str::<UserStampException>(stored).map(|e| vec![e]))
.unwrap_or_default()
}
fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) {
self.stamp_exception = if exceptions.is_empty() {
None
} else {
Some(serde_json::to_string(&exceptions).unwrap_or_default())
};
} }
pub fn display_name(&self) -> &str { pub fn display_name(&self) -> &str {
@@ -266,18 +348,37 @@ impl User {
!CONFIG.mail_enabled() || self.verified_at.is_some() !CONFIG.mail_enabled() || self.verified_at.is_some()
} }
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
if !self.is_v2() {
return None;
}
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
}
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair /// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
pub fn account_keys_json(&self) -> Value { pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
if self.private_key.is_some() { if self.private_key.is_some() {
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (
json!(self.signed_public_key),
key_pair.to_json(),
json!({
"securityState": self.security_state,
"securityVersion": self.security_version,
}),
),
None => (Value::Null, Value::Null, Value::Null),
};
json!({ json!({
"publicKeyEncryptionKeyPair": { "publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key, "wrappedPrivateKey": self.private_key,
"publicKey": self.public_key, "publicKey": self.public_key,
"signedPublicKey": null, "signedPublicKey": signed_public_key,
"object": "publicKeyEncryptionKeyPair", "object": "publicKeyEncryptionKeyPair",
}, },
"securityState": null, "securityState": security_state,
"signatureKeyPair": null, "signatureKeyPair": signature_key_pair,
"object": "privateKeys" "object": "privateKeys"
}) })
} else { } else {
@@ -285,6 +386,24 @@ impl User {
} }
} }
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
None => (Value::Null, Value::Null),
};
json!({
"publicKey": self.public_key,
"signedPublicKey": signed_public_key,
"verifyingKey": verifying_key,
"object": "publicKeys"
})
}
pub fn v2_upgrade_token_json(&self) -> Option<Value> {
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok())
}
pub async fn to_json(&self, conn: &DbConn) -> Value { pub async fn to_json(&self, conn: &DbConn) -> Value {
let mut orgs_json = Vec::new(); let mut orgs_json = Vec::new();
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
@@ -305,7 +424,7 @@ impl User {
UserStatus::Enabled UserStatus::Enabled
}; };
let account_keys = self.account_keys_json(); let account_keys = self.account_keys_json(conn).await;
json!({ json!({
"_status": status as i32, "_status": status as i32,
@@ -382,6 +501,7 @@ impl User {
Device::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?;
TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
Invitation::take(&self.email, conn).await; // Delete invitation if any Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| { conn.run(move |conn| {
@@ -553,25 +673,26 @@ impl Invitation {
pub struct UserId(String); pub struct UserId(String);
#[derive( #[derive(
Clone, Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, AsRef, Deref, Display, From, UuidFromParam,
Debug,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
UuidFromParam,
)] )]
#[deref(forward)] #[deref(forward)]
#[from(forward)] #[from(forward)]
pub struct KeyId(String); pub struct KeyId(String);
/// Like upstream, a key id is 32 lowercase hex characters. Key ids are compared as strings, so one in any other
/// format would never match the id the clients send.
///
/// Ref: <https://github.com/bitwarden/server/blob/8da186ad73f80d37157fe6fd42323e4284e83ec1/src/Core/KeyManagement/Models/Data/KeyId.cs#L20-L42>
impl<'de> serde::Deserialize<'de> for KeyId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let key_id = String::deserialize(deserializer)?;
if key_id.len() != 32 || !key_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err(serde::de::Error::custom("Key id must be 32 lowercase hex characters"));
}
Ok(Self(key_id))
}
}
impl SsoUser { impl SsoUser {
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn: db_run! { conn:
@@ -625,3 +746,56 @@ impl SsoUser {
.await .await
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_id_format() {
let key_id: KeyId = serde_json::from_str(r#""0123456789abcdef0123456789abcdef""#).unwrap();
assert_eq!(key_id.as_ref(), "0123456789abcdef0123456789abcdef");
for invalid in ["", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef", "0123456789abcdef0123456789abcdeg"]
{
assert!(serde_json::from_str::<KeyId>(&format!("\"{invalid}\"")).is_err(), "{invalid:?}");
}
// An absent key id stays optional
assert_eq!(serde_json::from_str::<Option<KeyId>>("null").unwrap(), None);
}
#[test]
fn test_has_v2_encryption() {
// User::new loads CONFIG; isolate it from the DNS bootstrap test and filesystem configuration.
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
.env("DATABASE_URL", "sqlite://:memory:")
.output()
.unwrap();
assert!(
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
let user = |private_key: Option<&str>, security_version: Option<i32>| {
let mut user = User::new("user@example.com", None);
user.private_key = private_key.map(String::from);
user.security_version = security_version;
user
};
assert!(user(Some("7.cose"), Some(2)).has_v2_encryption());
assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption());
assert!(!user(Some("7.cose"), None).has_v2_encryption());
assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption());
assert!(!user(Some("7"), Some(2)).has_v2_encryption());
assert!(!user(None, Some(2)).has_v2_encryption());
}
}
+132
View File
@@ -0,0 +1,132 @@
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
db::{DbConn, schema::user_signature_key_pairs},
error::MapResult,
util::get_uuid,
};
use super::UserId;
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = user_signature_key_pairs)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct UserSignatureKeyPair {
pub uuid: UserSignatureKeyPairId,
pub user_uuid: UserId,
pub signature_algorithm: i32,
/// The signing (private) key, wrapped by the user key.
pub signing_key: String,
/// The COSE-encoded public verifying key.
pub verifying_key: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignatureAlgorithm {
Ed25519 = 0,
MlDsa44 = 1,
}
impl SignatureAlgorithm {
pub fn parse(algorithm: &str) -> Option<Self> {
match algorithm {
"ed25519" => Some(Self::Ed25519),
"mldsa44" => Some(Self::MlDsa44),
_ => None,
}
}
}
/// Local methods
impl UserSignatureKeyPair {
pub fn new(
user_uuid: UserId,
signature_algorithm: SignatureAlgorithm,
signing_key: String,
verifying_key: String,
) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid: UserSignatureKeyPairId(get_uuid()),
user_uuid,
signature_algorithm: signature_algorithm as i32,
signing_key,
verifying_key,
created_at: now,
updated_at: now,
}
}
pub fn to_json(&self) -> Value {
json!({
"wrappedSigningKey": self.signing_key,
"verifyingKey": self.verifying_key,
"object": "signatureKeyPair",
})
}
}
/// Database methods
impl UserSignatureKeyPair {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
mysql {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
postgresql, sqlite {
diesel::insert_into(user_signature_key_pairs::table)
.values(&*self)
.on_conflict(user_signature_key_pairs::user_uuid)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving user signature key pair")
}
}
}
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
user_signature_key_pairs::table
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
})
.await
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
.execute(conn)
.map_res("Error deleting user signature key pairs")
})
.await
}
}
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserSignatureKeyPairId(String);
+28
View File
@@ -219,6 +219,22 @@ table! {
avatar_color -> Nullable<Text>, avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
key_id -> Nullable<Text>, key_id -> Nullable<Text>,
signed_public_key -> Nullable<Text>,
security_state -> Nullable<Text>,
security_version -> Nullable<Integer>,
v2_upgrade_token -> Nullable<Text>,
}
}
table! {
user_signature_key_pairs (uuid) {
uuid -> Text,
user_uuid -> Text,
signature_algorithm -> Integer,
signing_key -> Text,
verifying_key -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
} }
} }
@@ -244,6 +260,16 @@ table! {
atype -> Integer, atype -> Integer,
reset_password_key -> Nullable<Text>, reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
manage_users -> Bool,
manage_groups -> Bool,
manage_policies -> Bool,
create_new_collections -> Bool,
edit_any_collection -> Bool,
delete_any_collection -> Bool,
access_event_logs -> Bool,
access_import_export -> Bool,
access_reports -> Bool,
v2_upgrade_token -> Nullable<Text>,
} }
} }
@@ -384,6 +410,7 @@ joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid)); joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid)); joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid)); joinable!(sso_users -> users (user_uuid));
joinable!(user_signature_key_pairs -> users (user_uuid));
allow_tables_to_appear_in_same_query!( allow_tables_to_appear_in_same_query!(
archives, archives,
@@ -410,4 +437,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups, collections_groups,
event, event,
auth_requests, auth_requests,
user_signature_key_pairs,
); );
+11 -1
View File
@@ -577,9 +577,19 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
.limit("data-form", 525.megabytes()) // This needs to match the maximum allowed file size for Send .limit("data-form", 525.megabytes()) // This needs to match the maximum allowed file size for Send
.limit("file", 525.megabytes()); // This needs to match the maximum allowed file size for attachments .limit("file", 525.megabytes()); // This needs to match the maximum allowed file size for attachments
let rocket = rocket::custom(config);
#[cfg(feature = "unsafe_test_mode")]
let rocket = {
warn!(
"This build has `unsafe_test_mode`: its test seeder at /test/seeder creates accounts without any checks. \
Never use it outside of tests!"
);
rocket.mount([basepath, "/test/seeder"].concat(), api::test_seeder_routes())
};
// If adding more paths here, consider also adding them to // If adding more paths here, consider also adding them to
// crate::utils::LOGGED_ROUTES to make sure they appear in the log // crate::utils::LOGGED_ROUTES to make sure they appear in the log
let instance = rocket::custom(config) let instance = rocket
.mount([basepath, "/"].concat(), api::web_routes()) .mount([basepath, "/"].concat(), api::web_routes())
.mount([basepath, "/api"].concat(), api::core_routes()) .mount([basepath, "/api"].concat(), api::core_routes())
.mount([basepath, "/admin"].concat(), api::admin_routes()) .mount([basepath, "/admin"].concat(), api::admin_routes())
+12 -4
View File
@@ -173,10 +173,14 @@ const ORG_TYPES = {
"name": "User", "name": "User",
"bg": "blue" "bg": "blue"
}, },
"4": { "3": {
"name": "Manager", "name": "Manager",
"bg": "green" "bg": "green"
}, },
"4": {
"name": "Custom",
"bg": "teal"
},
}; };
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
@@ -187,15 +191,19 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
// Get org specific values // Get org specific values
const userOrgType = event.relatedTarget.dataset.vwOrgType; const userOrgType = event.relatedTarget.dataset.vwOrgType;
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
const orgName = event.relatedTarget.dataset.vwOrgName; const orgName = event.relatedTarget.dataset.vwOrgName;
const orgUuid = event.relatedTarget.dataset.vwOrgUuid; const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
document.getElementById("userOrgTypeForm").reset();
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeUserUuid").value = userUuid;
document.getElementById("userOrgTypeOrgUuid").value = orgUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; // The Custom role can only be selected while it is enabled
const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`);
if (userOrgTypeRadio) {
userOrgTypeRadio.checked = true;
}
}, false); }, false);
// Prevent accidental submission of the form with valid elements after the modal has been hidden. // Prevent accidental submission of the form with valid elements after the modal has been hidden.
@@ -222,7 +230,7 @@ function updateUserOrgType(event) {
function initUserTable() { function initUserTable() {
// Color all the org buttons per type // Color all the org buttons per type
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
const orgType = ORG_TYPES[e.dataset.vwOrgType]; const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" };
e.style.backgroundColor = orgType.bg; e.style.backgroundColor = orgType.bg;
if (orgType.font !== undefined) { if (orgType.font !== undefined) {
e.style.color = orgType.font; e.style.color = orgType.font;
+5
View File
@@ -135,6 +135,11 @@
<div class="radio"> <div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label> <label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label>
</div> </div>
{{#if custom_roles_enabled}}
<div class="radio">
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom">&nbsp;Custom</label>
</div>
{{/if}}
<div class="radio"> <div class="radio">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label> <label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>
</div> </div>
@@ -98,10 +98,21 @@ app-security > app-two-factor-setup > form {
} }
/* Hide unsupported Custom Role options */ /* Hide unsupported Custom Role options */
{{#if custom_roles_enabled}}
/* Only the collection permissions and Manage users can be granted yet */
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"]),
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageGroups"]),
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="managePolicies"]),
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]),
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageResetPassword"]) {
@extend %vw-hide;
}
{{else}}
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), :is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide; @extend %vw-hide;
} }
{{/if}}
/* Change collapsed menu icon to Vaultwarden */ /* Change collapsed menu icon to Vaultwarden */
bit-nav-logo bit-nav-item a:before { bit-nav-logo bit-nav-item a:before {
+84 -2
View File
@@ -302,6 +302,20 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
// Effectively ignores, any static file route, and the alive endpoint // Effectively ignores, any static file route, and the alive endpoint
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
const MAX_LOGGED_QUERY_BYTES: usize = 100;
// Query values can hold secrets, like the access code of a login request, so we don't log them
fn redact_query_values(query: &str) -> String {
query
.split('&')
.map(|param| match param.split_once('=') {
Some((name, _)) => format!("{name}=…"),
None => param.to_owned(),
})
.collect::<Vec<_>>()
.join("&")
}
// When extra is true, we ignore the whitelist above and also print the mounts // When extra is true, we ignore the whitelist above and also print the mounts
pub struct BetterLogging { pub struct BetterLogging {
extra: bool, extra: bool,
@@ -381,8 +395,9 @@ impl Fairing for BetterLogging {
}; };
match uri.query() { match uri.query() {
Some(q) => { Some(q) => {
let q = q.as_str(); let query = redact_query_values(q.as_str());
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug()); let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)];
info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug());
} }
None => info!(target: "request", "{method} {}", path.escape_debug()), None => info!(target: "request", "{method} {}", path.escape_debug()),
} }
@@ -884,6 +899,12 @@ pub fn parse_experimental_client_feature_flags(
.collect() .collect()
} }
/// Whether the admin enabled this supported client feature flag
pub fn is_client_feature_flag_enabled(flag: &str) -> bool {
parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly)
.contains_key(flag)
}
/// TODO: This is extracted from IpAddr::is_global, which is unstable: /// TODO: This is extracted from IpAddr::is_global, which is unstable:
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
@@ -974,6 +995,22 @@ pub async fn save_temp_file(
Ok(()) Ok(())
} }
/// Whether `value` has the form of an RSA-encrypted string (types 3 to 6), the form of the account recovery key: the
/// type and its number of `|` pieces like the clients' `UnsignedSharedKey` parser, and each piece canonical, padded
/// base64, which is what the clients send.
///
/// Ref: <https://github.com/bitwarden/sdk-internal/blob/6238472451e4fa7b5c592b4b24a84e950a258957/crates/bitwarden-crypto/src/enc_string/asymmetric.rs#L85-L119>
pub fn is_valid_rsa_encrypted_string(value: &str) -> bool {
let (pieces, rest) = match value.split_once('.') {
Some(("3" | "4", rest)) => (1, rest),
Some(("5" | "6", rest)) => (2, rest),
_ => return false,
};
let parts: Vec<&str> = rest.split('|').collect();
parts.len() == pieces
&& parts.iter().all(|part| !part.is_empty() && data_encoding::BASE64.decode(part.as_bytes()).is_ok())
}
/// These are some tests to check that the implementations match /// These are some tests to check that the implementations match
/// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17 /// The IPv4 can be all checked in 30 seconds or so and they are correct as of nightly 2023-07-17
/// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct /// The IPV6 can't be checked in a reasonable time, so we check over a hundred billion random ones, so far correct
@@ -1027,3 +1064,48 @@ mod tests {
}); });
} }
} }
#[cfg(test)]
mod query_log_tests {
use super::{MAX_LOGGED_QUERY_BYTES, redact_query_values};
#[test]
fn test_redact_query_values() {
assert_eq!(redact_query_values("code=SECRET"), "code=…");
assert_eq!(redact_query_values("code=SECRET&other=SECRET"), "code=…&other=…");
assert_eq!(redact_query_values("a=1&b=2&flag"), "a=…&b=…&flag");
assert_eq!(redact_query_values("a=b=SECRET"), "a=…");
assert_eq!(redact_query_values("a=1&&=SECRET"), "a=…&&=…");
assert_eq!(redact_query_values("a=x%26b%3DSECRET"), "a=…");
assert_eq!(redact_query_values("a=1;b=SECRET"), "a=…");
assert_eq!(redact_query_values("a=1&a=SECRET"), "a=…&a=…");
assert_eq!(redact_query_values(""), "");
}
#[test]
fn test_redacted_query_truncates_on_a_char_boundary() {
let name = "a".repeat(MAX_LOGGED_QUERY_BYTES - 1);
let query = redact_query_values(&format!("{name}=SECRET"));
assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}="));
}
}
#[cfg(test)]
mod encrypted_string_tests {
use super::is_valid_rsa_encrypted_string as valid;
#[test]
fn rsa_encrypted_strings_are_validated() {
assert!(valid("3.YWJjZA=="));
assert!(valid("4.YWJjZA=="));
assert!(valid("6.YWJjZA==|YWJjZA=="));
assert!(!valid("2.YWJjZA==|YWJjZA==|YWJjZA=="), "symmetric type");
assert!(!valid("YWJjZA=="), "no type");
assert!(!valid("4.not base64!"));
assert!(!valid("4.YWJjZA==|YWJjZA=="), "one piece too many");
assert!(!valid("5.YWJjZA=="), "one piece too few");
assert!(!valid("4.YWJjZB=="), "padding bits set");
assert!(!valid("4."), "empty piece");
assert!(!valid(""));
}
}
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# Runs the live-server end-to-end tests of bitwarden/sdk-internal (e2e/wasm) against vaultwarden.
#
# Usage: tools/sdk-live-tests/run.sh --sdk-dir <path> [--skip-sdk-build] [--mode vectors|seeder] [-- <vitest args>]
#
# --skip-sdk-build uses the package already in the SDK's crates/bitwarden-wasm-internal/npm: an earlier build, or a
# published @bitwarden/sdk-internal unpacked there.
#
# The server is built with `unsafe_test_mode`, whose test seeder (src/api/test_seeder.rs) creates the test accounts.
# - vectors: for SDK versions whose live tests log in to one given account. Every user test vector with a master
# password gets a fresh server, is seeded there with its vault, and the tests run on it (they rotate its keys).
# - seeder: for SDK versions whose tests seed their own accounts through BW_LIVE_SEEDER_URL. One server, one run.
# The mode follows what the SDK's e2e/wasm/live/config.ts reads, unless --mode says otherwise. Arguments after `--` go
# to vitest, e.g. `-- tests/user-crypto-management/rotate-user-keys.live.test.ts` to run one file.
#
# Every vector runs even after a failure; a summary follows, and the exit code is 1 if any failed. The server logs are
# in <target dir>/sdk-live-tests/. BW_LIVE_CLIENT_VERSION defaults to 2026.10.1, the minimum for V2 accounts.
#
# Needs: Node.js >= 22.12, curl, python3, and to build the SDK its Rust toolchain with the wasm32-unknown-unknown target
# and rust-src.
set -euo pipefail
VW_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
TARGET_DIR="${CARGO_TARGET_DIR:-${VW_DIR}/target}"
OUT_DIR="${TARGET_DIR}/sdk-live-tests"
PORT=8099
URL="http://127.0.0.1:${PORT}"
SEEDER_URL="${URL}/test/seeder"
SEEDER_USERNAME="sdk-live-tests"
SEEDER_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')"
SKIP=3 # seed_vector.py's exit code for a vector the tests can't use
export BW_LIVE_CLIENT_VERSION="${BW_LIVE_CLIENT_VERSION:-2026.10.1}"
SDK_DIR=""
BUILD_SDK=1
MODE=""
VITEST_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--sdk-dir) SDK_DIR="$(cd "$2" && pwd)"; shift 2 ;;
--skip-sdk-build) BUILD_SDK=0; shift ;;
--mode) MODE="$2"; shift 2 ;;
--) shift; VITEST_ARGS=("$@"); break ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
[[ -n "${SDK_DIR}" ]] || { echo "--sdk-dir is required" >&2; exit 2; }
TESTS_DIR="${SDK_DIR}/e2e/wasm"
[[ -f "${TESTS_DIR}/live/config.ts" ]] || { echo "${TESTS_DIR}/live/config.ts not found: no live tests in this SDK" >&2; exit 2; }
if [[ -z "${MODE}" ]]; then
if grep -q BW_LIVE_SEEDER_URL "${TESTS_DIR}/live/config.ts"; then MODE=seeder; else MODE=vectors; fi
fi
[[ "${MODE}" == vectors || "${MODE}" == seeder ]] || { echo "--mode must be vectors or seeder" >&2; exit 2; }
echo "Mode: ${MODE}, client version ${BW_LIVE_CLIENT_VERSION}"
(cd "${VW_DIR}" && cargo build --features sqlite,unsafe_test_mode)
if [[ ${BUILD_SDK} -eq 1 ]]; then
bash "${SDK_DIR}/crates/bitwarden-wasm-internal/build.sh"
fi
(cd "${TESTS_DIR}" && npm ci)
server=""
trap 'if [[ -n "${server}" ]]; then kill "${server}" 2>/dev/null || true; fi' EXIT
# Starts a server on an empty database in $OUT_DIR/$1
start_server() {
local data_dir="${OUT_DIR}/$1"
rm -rf "${data_dir}" && mkdir -p "${data_dir}"
if curl -s -o /dev/null "${URL}/alive"; then
echo "Something already answers on port ${PORT}" >&2
return 1
fi
ENV_FILE=/dev/null DATA_FOLDER="${data_dir}" DATABASE_URL="sqlite://${data_dir}/db.sqlite3" \
ROCKET_ADDRESS=127.0.0.1 ROCKET_PORT="${PORT}" DOMAIN="${URL}" WEB_VAULT_ENABLED=false \
SIGNUPS_ALLOWED=true SIGNUPS_VERIFY=false LOGIN_RATELIMIT_MAX_BURST=1000 \
TEST_SEEDER_USERNAME="${SEEDER_USERNAME}" TEST_SEEDER_PASSWORD="${SEEDER_PASSWORD}" \
"${TARGET_DIR}/debug/vaultwarden" > "${data_dir}/vaultwarden.log" 2>&1 &
server=$!
local i
for i in $(seq 1 60); do
if ! kill -0 "${server}" 2>/dev/null; then break; fi
if curl -sf -o /dev/null "${URL}/alive"; then return 0; fi
sleep 1
done
echo "The server didn't start; the end of ${data_dir}/vaultwarden.log:" >&2
tail -50 "${data_dir}/vaultwarden.log" >&2
stop_server
return 1
}
stop_server() {
if [[ -n "${server}" ]]; then
kill "${server}" 2>/dev/null || true
wait "${server}" 2>/dev/null || true
server=""
fi
}
run_tests() {
(cd "${TESTS_DIR}" && BW_LIVE_SERVER_URL="${URL}" "$@" npm run test:live -- "${VITEST_ARGS[@]}")
}
results=()
failed=0
if [[ "${MODE}" == seeder ]]; then
if start_server seeder && run_tests env BW_LIVE_SEEDER_URL="${SEEDER_URL}" \
BW_LIVE_SEEDER_USERNAME="${SEEDER_USERNAME}" BW_LIVE_SEEDER_PASSWORD="${SEEDER_PASSWORD}"; then
results+=("seeder: pass")
else
results+=("seeder: FAIL")
failed=1
fi
stop_server
else
for vector_file in "${SDK_DIR}"/test-vectors/users/*.json; do
vector="$(basename "${vector_file}" .json)"
echo "=== ${vector}"
if ! start_server "${vector}"; then
results+=("${vector}: FAIL (server didn't start)")
failed=1
continue
fi
status=0
credentials="$(python3 -I "${VW_DIR}/tools/sdk-live-tests/seed_vector.py" "${vector_file}" "${SEEDER_URL}" \
"${SEEDER_USERNAME}" "${SEEDER_PASSWORD}")" || status=$?
if [[ ${status} -eq ${SKIP} ]]; then
results+=("${vector}: skip (${credentials})")
elif [[ ${status} -ne 0 ]]; then
results+=("${vector}: FAIL (seeding)")
failed=1
elif run_tests env BW_LIVE_EMAIL="$(sed -n 1p <<< "${credentials}")" \
BW_LIVE_PASSWORD="$(sed -n 2p <<< "${credentials}")"; then
results+=("${vector}: pass")
else
results+=("${vector}: FAIL")
failed=1
fi
stop_server
done
fi
echo
echo "=== Summary (logs in ${OUT_DIR})"
printf '%s\n' "${results[@]}"
exit "${failed}"
+59
View File
@@ -0,0 +1,59 @@
"""Seeds one of the SDK's user test vectors, vault included, through vaultwarden's test seeder.
Usage: seed_vector.py <vector.json> <seeder url> <seeder username> <seeder password>
Prints the account's email and password, one per line. Exits 3, printing why, for a vector the tests can't log in
with (no master password), and 1 if the seeder refuses it.
"""
import base64
import json
import sys
import urllib.error
import urllib.request
import uuid
SKIP = 3
def main() -> int:
vector_file, seeder_url, username, password = sys.argv[1:5]
with open(vector_file, encoding="utf-8") as f:
vector = json.load(f)
has_master_password = any("masterPasswordUnlock" in method for method in vector.get("unlockMethods", []))
if not has_master_password or "password" not in vector.get("account", {}):
print("no master password")
return SKIP
# Without attachments: the rotation test compares the vault before and after, and our sync gives each attachment
# a link with a fresh download token every time (upstream's is the same on every sync). A vector's legacy
# attachments, without a key of their own, would also make the SDK refuse to rotate at all.
for cipher in vector.get("vault", {}).get("ciphers", []):
cipher.get("encrypted", {}).pop("attachments", None)
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
request = urllib.request.Request(
f"{seeder_url}/seed",
data=json.dumps({"template": "TestVectorUserScene", "arguments": vector}).encode(),
method="POST",
headers={
"Authorization": f"Basic {credentials}",
"Content-Type": "application/json",
"X-Play-Id": f"sdk-live-tests-{uuid.uuid4()}",
},
)
try:
with urllib.request.urlopen(request) as response:
result = json.load(response)["result"]
except urllib.error.HTTPError as e:
print(f"The seeder refused {vector_file}: {e.code} {e.read().decode(errors='replace')}", file=sys.stderr)
return 1
print(result["email"])
print(vector["account"]["password"])
return 0
if __name__ == "__main__":
sys.exit(main())