Browse Source

Merge main into agent/sso-default-organization

pull/7422/head
tom27052006 1 week ago
parent
commit
3b57c4e057
  1. 4
      .dockerignore
  2. 4
      .env.template
  3. 2
      .github/workflows/hadolint.yml
  4. 6
      .github/workflows/release.yml
  5. 2
      .github/workflows/trivy.yml
  6. 2
      .github/workflows/typos.yml
  7. 2
      .github/workflows/zizmor.yml
  8. 2
      .pre-commit-config.yaml
  9. 513
      Cargo.lock
  10. 25
      Cargo.toml
  11. 3
      docker/DockerSettings.yaml
  12. 8
      docker/Dockerfile.alpine
  13. 2
      docker/Dockerfile.debian
  14. 10
      docker/render_template
  15. 2
      macros/Cargo.toml
  16. 0
      migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql
  17. 1
      migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql
  18. 0
      migrations/mysql/2026-09-02-120000_add_key_id/down.sql
  19. 1
      migrations/mysql/2026-09-02-120000_add_key_id/up.sql
  20. 0
      migrations/postgresql/2026-09-02-120000_add_key_id/down.sql
  21. 1
      migrations/postgresql/2026-09-02-120000_add_key_id/up.sql
  22. 0
      migrations/sqlite/2026-09-02-120000_add_key_id/down.sql
  23. 1
      migrations/sqlite/2026-09-02-120000_add_key_id/up.sql
  24. 7
      playwright/tests/organization.smtp.spec.ts
  25. 2
      rust-toolchain.toml
  26. 42
      src/api/core/accounts.rs
  27. 48
      src/api/core/ciphers.rs
  28. 20
      src/api/core/events.rs
  29. 104
      src/api/core/organizations.rs
  30. 15
      src/api/core/two_factor/email.rs
  31. 5
      src/api/core/two_factor/mod.rs
  32. 29
      src/api/identity.rs
  33. 12
      src/auth.rs
  34. 7
      src/config.rs
  35. 9
      src/db/models/archive.rs
  36. 18
      src/db/models/attachment.rs
  37. 30
      src/db/models/auth_request.rs
  38. 18
      src/db/models/cipher.rs
  39. 78
      src/db/models/collection.rs
  40. 21
      src/db/models/device.rs
  41. 20
      src/db/models/emergency_access.rs
  42. 17
      src/db/models/event.rs
  43. 29
      src/db/models/folder.rs
  44. 109
      src/db/models/group.rs
  45. 2
      src/db/models/mod.rs
  46. 21
      src/db/models/org_policy.rs
  47. 71
      src/db/models/organization.rs
  48. 18
      src/db/models/send.rs
  49. 44
      src/db/models/user.rs
  50. 1
      src/db/schema.rs
  51. 85
      src/http_client.rs
  52. 14
      src/mail.rs
  53. 16
      src/static/scripts/datatables.css
  54. 9418
      src/static/scripts/datatables.js
  55. 12
      src/static/templates/email/admin_account_recovery.hbs
  56. 11
      src/static/templates/email/admin_account_recovery.html.hbs
  57. 4
      src/static/templates/email/admin_reset_password.hbs
  58. 19
      src/storage.rs

4
.dockerignore

@ -1,7 +1,7 @@
// Ignore everything
# Ignore everything
*
// Allow what is needed
# Allow what is needed
!.git
!docker/healthcheck.sh
!docker/start.sh

4
.env.template

@ -390,6 +390,7 @@
##
## The following flags are available:
## - "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)
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
@ -401,10 +402,13 @@
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
## - "pm-30529-webauthn-related-origins":
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent.

2
.github/workflows/hadolint.yml

@ -20,7 +20,7 @@ jobs:
steps:
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:

6
.github/workflows/release.yml

@ -58,13 +58,13 @@ jobs:
steps:
- name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: "arm64,arm"
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@ -185,7 +185,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"

2
.github/workflows/trivy.yml

@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
sarif_file: 'trivy-results.sarif'

2
.github/workflows/typos.yml

@ -23,4 +23,4 @@ jobs:
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@4d9c206a77c041268485162b8e2579ad7a5cb9a3 # v1.50.0
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2

2
.github/workflows/zizmor.yml

@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.

2
.pre-commit-config.yaml

@ -18,7 +18,7 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos
rev: 4d9c206a77c041268485162b8e2579ad7a5cb9a3 # v1.50.0
rev: 512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
hooks:
- id: typos
always_run: true

513
Cargo.lock

File diff suppressed because it is too large

25
Cargo.toml

@ -12,7 +12,6 @@ members = ["macros"]
name = "vaultwarden"
version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
readme = "README.md"
build = "build.rs"
repository.workspace = true
edition.workspace = true
@ -41,6 +40,7 @@ vendored_openssl = ["openssl/vendored"]
enable_mimalloc = ["dep:mimalloc"]
s3 = [
"opendal/services-s3",
"dep:opendal-http-transport-reqwest",
"dep:aws-config",
"dep:aws-credential-types",
"dep:aws-smithy-runtime-api",
@ -107,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.12", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@ -125,11 +125,11 @@ libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries
rand = "0.10.2"
ring = "0.17.14"
rustls = { version = "0.23.43", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.26.0", features = ["v4"] }
uuid = { version = "1.26.1", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
@ -143,7 +143,7 @@ job_scheduler_ng = "2.5.0"
data-encoding = "2.11.1"
# JWT library
jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library
totp-lite = "2.0.1"
@ -183,7 +183,7 @@ email_address = "0.2.9"
handlebars = { version = "6.4.4", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.13.4", default-features = false, features = [
reqwest = { version = "0.13.5", default-features = false, features = [
# Misc
"charset",
"cookies",
@ -201,7 +201,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [
"socks",
"system-proxy",
] }
hickory-resolver = "0.26.1"
hickory-resolver = "0.26.3"
# Favicon extraction libraries
html5gum = "0.8.4"
@ -215,7 +215,7 @@ bytes = "1.12.1"
svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "3.1.1", features = ["async"] }
cached = { version = "4.0.0", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2"
@ -232,7 +232,7 @@ pastey = "0.2.3"
governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.1"
ipnet = "2.12.2"
# OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false }
@ -257,17 +257,18 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.58.2", default-features = false, features = ["services-fs"] }
opendal = { version = "0.59.2", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.2", default-features = false, features = ["rustls-no-provider"], optional = true }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.11.0", optional = true, default-features = false, features = [
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.15.0", optional = true }
aws-smithy-runtime-api = { version = "1.16.2", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.0", optional = true }
reqsign-core = { version = "3.3.1", optional = true }

3
docker/DockerSettings.yaml

@ -5,7 +5,8 @@ vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.98.0 # Rust version to be used
# The `rust_version` variable is extracted from `rust-toolchain.toml`
# rust_version: x.yy.z # Rust version to be used
debian_version: trixie # Debian release name to be used
alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images

8
docker/Dockerfile.alpine

@ -32,10 +32,10 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330
########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.98.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.98.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.98.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.98.0 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.98.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.98.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.98.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.98.1 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006

2
docker/Dockerfile.debian

@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.0-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.98.1-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / /
ARG TARGETARCH

10
docker/render_template

@ -3,17 +3,23 @@
import os
import argparse
import json
import tomllib
import yaml
import jinja2
# Load settings file
with open("DockerSettings.yaml", 'r') as yaml_file:
with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file:
yaml_data = yaml.safe_load(yaml_file)
# Extract the rust_version from the rust-toolchain.toml file
script_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file:
yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"]
settings_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.getcwd()),
)
settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data))
settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data))
args_parser = argparse.ArgumentParser()
args_parser.add_argument('template_file', help='Jinja2 template file to render.')

2
macros/Cargo.toml

@ -14,7 +14,7 @@ proc-macro = true
[dependencies]
quote = "1.0.47"
syn = "3.0.4"
syn = "3.0.6"
[lints]
workspace = true

0
migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql

1
migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

0
migrations/mysql/2026-09-02-120000_add_key_id/down.sql

1
migrations/mysql/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

0
migrations/postgresql/2026-09-02-120000_add_key_id/down.sql

1
migrations/postgresql/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

0
migrations/sqlite/2026-09-02-120000_add_key_id/down.sql

1
migrations/sqlite/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

7
playwright/tests/organization.smtp.spec.ts

@ -127,6 +127,9 @@ test('Organization is visible', async ({ page }) => {
});
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
let newPassword = "TotoNewPassword";
@ -138,9 +141,10 @@ test('Recover user password', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
await page.getByRole('checkbox', { name: 'Reset two-step login' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed'));
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
});
let user2 = {
@ -150,6 +154,7 @@ test('Recover user password', async ({ page }) => {
};
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true,
});
});

2
rust-toolchain.toml

@ -1,4 +1,4 @@
[toolchain]
channel = "1.98.0"
channel = "1.98.1"
components = [ "rustfmt", "clippy" ]
profile = "minimal"

42
src/api/core/accounts.rs

@ -21,9 +21,9 @@ use crate::{
DbConn, DbPool,
models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId,
MembershipStatus, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId,
UserKdfType,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
MembershipId, MembershipStatus, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User,
UserId, UserKdfType,
},
},
mail,
@ -47,6 +47,7 @@ pub fn routes() -> Vec<rocket::Route> {
post_set_password,
post_kdf,
post_rotatekey,
post_user_key,
post_sstamp,
post_email_token,
post_email,
@ -1067,6 +1068,23 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
save_result
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeyIdData {
user_key_id: KeyId,
}
#[post("/accounts/key-management/user-key-id", data = "<data>")]
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
let mut user = headers.user;
if user.key_id.is_some() {
err_code!("Unexpected data", Status::UnprocessableEntity.code);
}
user.key_id = Some(data.into_inner().user_key_id);
user.save(&conn).await
}
#[post("/accounts/security-stamp", data = "<data>")]
async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: PasswordOrOtpData = data.into_inner();
@ -1382,11 +1400,13 @@ pub struct PreloginData {
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: PreloginData = data.into_inner();
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
@ -1394,7 +1414,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
};
Json(json!({
Ok(Json(json!({
"kdf": kdf_type,
"kdfIterations": kdf_iter,
"kdfMemory": kdf_mem,
@ -1406,7 +1426,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"parallelism": kdf_para
},
"salt": null,
}))
})))
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
@ -1637,6 +1657,8 @@ async fn post_auth_request(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let data = data.into_inner();
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
@ -1649,7 +1671,7 @@ async fn post_auth_request(
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
};
let mut auth_request = AuthRequest::new(
let auth_request = AuthRequest::new(
user.uuid.clone(),
data.device_identifier.clone(),
client_headers.device_type,
@ -1798,6 +1820,8 @@ async fn get_auth_request_response(
client_headers: ClientHeaders,
conn: DbConn,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
err!("AuthRequest doesn't exist", "User not found")
};

48
src/api/core/ciphers.rs

@ -6,6 +6,7 @@ use rocket::{
Route,
form::{Form, FromForm},
fs::TempFile,
http::Status,
serde::json::Json,
};
use serde_json::Value;
@ -21,8 +22,8 @@ use crate::{
DbConn, DbPool,
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@ -161,6 +162,12 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let policies_json: Vec<Value> =
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
let policies_new_json: Vec<Value> = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn)
.await
.iter()
.map(OrgPolicy::to_json)
.collect();
let domains_json = if data.exclude_domains {
Value::Null
} else {
@ -193,11 +200,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"folders": folders_json,
"collections": collections_json,
"policies": policies_json,
"policiesNew": policies_new_json,
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"object": "sync"
})))
@ -260,12 +269,19 @@ pub struct CipherData {
key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
/*
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5
BankAccount = 6
DriversLicense = 7
Passport = 8
*/
pub r#type: i32,
pub name: String,
@ -278,6 +294,9 @@ pub struct CipherData {
card: Option<Value>,
identity: Option<Value>,
ssh_key: Option<Value>,
bank_account: Option<Value>,
drivers_license: Option<Value>,
passport: Option<Value>,
favorite: Option<bool>,
reprompt: Option<i32>,
@ -333,6 +352,10 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
@ -362,6 +385,17 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
// which results in a warning message being logged. This field isn't
@ -510,6 +544,9 @@ pub async fn update_cipher_from_data(
3 => data.card,
4 => data.identity,
5 => data.ssh_key,
6 => data.bank_account,
7 => data.drivers_license,
8 => data.passport,
_ => err!("Invalid type"),
};
@ -537,11 +574,12 @@ pub async fn update_cipher_from_data(
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
if let Some(dt_str) = data.archived_date {
match NaiveDateTime::parse_from_str(&dt_str, "%+") {
match data.archived_date {
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
}
},
None => cipher.unarchive(&headers.user.uuid, conn).await?,
}
if ut != UpdateType::None {

20
src/api/core/events.rs

@ -199,6 +199,26 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
// Only the vault notification banner click is accepted from clients. The rest of
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
if let Some(org_id) = &event.organization_id
&& let Some(membership) =
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
{
log_event_impl(
event.r#type,
&membership.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
Some(event_date),
&headers.ip.ip,
&conn,
)
.await;
}
}
_ => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.

104
src/api/core/organizations.rs

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use num_traits::FromPrimitive;
use rocket::{Route, serde::json::Json};
use rocket::{Route, http::Status, serde::json::Json};
use serde_json::Value;
use crate::{
@ -17,7 +17,8 @@ use crate::{
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
UserId,
},
},
mail,
@ -132,7 +133,6 @@ struct FullCollectionData {
name: String,
groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>,
id: Option<CollectionId>,
external_id: Option<String>,
}
@ -391,7 +391,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
Ok(Json(json!({
@ -887,11 +887,11 @@ struct OrgIdData {
#[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
Ok(Json(json!({
@ -959,7 +959,7 @@ async fn get_members(
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
let mut users_json = Vec::new();
@ -1079,7 +1079,7 @@ async fn send_invite(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let mut user_created: bool = false;
let mut user_created: bool;
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
let user = match User::find_by_mail(email, &conn).await {
@ -1114,6 +1114,7 @@ async fn send_invite(
member_status = MembershipStatus::Accepted as i32;
}
}
user_created = false;
user
}
};
@ -1797,11 +1798,22 @@ async fn bulk_public_keys(
use super::ciphers::CipherData;
use super::ciphers::update_cipher_from_data;
// The import endpoint only ever uses the name/id/external_id of a collection.
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
// mandatory: clients are free to leave them out.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportCollectionData {
name: String,
id: Option<CollectionId>,
external_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportData {
ciphers: Vec<CipherData>,
collections: Vec<FullCollectionData>,
collections: Vec<ImportCollectionData>,
collection_relationships: Vec<RelationsData>,
}
@ -2480,7 +2492,7 @@ async fn get_groups_data(
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@ -2931,8 +2943,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: String,
key: String,
new_master_password_hash: Option<String>,
key: Option<String>,
#[serde(default)]
reset_master_password: bool,
@ -2976,12 +2988,7 @@ async fn put_recover_account(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let req = data.into_inner();
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
// Deprecated since `v2026.4.2`
@ -3001,7 +3008,7 @@ async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
reset_request: OrganizationUserRecoverAccountRequest,
req: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@ -3016,7 +3023,7 @@ async fn recover_account(
err!("User to reset isn't member of required organization")
};
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found")
};
@ -3029,29 +3036,56 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
// Sending email before resetting password to ensure working email configuration and the resulting
// user notification. Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await {
let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
} else {
false
};
// Sending email first ensure working email configuration and the resulting user notification.
// Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_account_recovery(
&user.email,
user.display_name(),
&org.name,
req.reset_master_password,
req.reset_two_factor,
fallback_2fa_email,
)
.await
{
err!(format!("Error sending user reset password email: {e:#?}"));
}
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
if req.reset_master_password {
if let Some(key) = req.key
&& let Some(hash) = req.new_master_password_hash
{
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
}
}
if req.reset_two_factor {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?;
}
}
user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await;
log_event(
EventType::OrganizationUserAdminResetPassword,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
if req.reset_master_password {
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
}
if req.reset_two_factor {
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
}
Ok(())
}

15
src/api/core/two_factor/email.rs

@ -63,13 +63,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let user = if let Some(email) = email {
let Some(user) = User::find_by_mail(email, &conn).await else {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
};
if let Some(master_password_hash) = master_password_hash {
// Check password
if !user.check_valid_password(master_password_hash) {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
}
} else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@ -96,7 +102,10 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
};
// SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
)
};
user

5
src/api/core/two_factor/mod.rs

@ -16,8 +16,8 @@ use crate::{
db::{
DbConn, DbPool,
models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId,
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
},
},
mail,
@ -151,6 +151,7 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
}

29
src/api/identity.rs

@ -3,7 +3,7 @@ use num_traits::FromPrimitive;
use rocket::{
Route,
form::{Form, FromForm},
http::{Cookie, CookieJar, SameSite},
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
response::Redirect,
serde::json::Json,
};
@ -905,6 +905,12 @@ async fn twofactor_auth(
// Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
// No device may keep skipping 2FA once every second factor is gone.
// `device` is cleared in memory too, since saving it later would restore its token.
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
device.delete_twofactor_remember();
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
@ -1050,13 +1056,13 @@ async fn json_err_twofactor(
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[post("/accounts/register", data = "<data>")]
@ -1077,11 +1083,18 @@ enum RegisterVerificationResponse {
#[response(status = 204)]
NoContent(()),
Token(Json<String>),
PlainToken(String),
}
// Return JSON only when the client explicitly requests it, otherwise return plain text.
fn accepts_json(accept: Option<&Accept>) -> bool {
accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON)
}
#[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email(
data: Json<RegisterVerificationData>,
accept: Option<&Accept>,
ip: ClientIp,
conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> {
@ -1119,7 +1132,11 @@ async fn register_verification_email(
} else {
// If email verification is not required, return the token directly
// the clients will use this token to finish the registration
Ok(RegisterVerificationResponse::Token(Json(token)))
Ok(if accepts_json(accept) {
RegisterVerificationResponse::Token(Json(token))
} else {
RegisterVerificationResponse::PlainToken(token)
})
}
}

12
src/auth.rs

@ -23,14 +23,14 @@ use rocket::{
use crate::{
CONFIG,
api::ApiResult,
api::{ApiResult, core::log_event},
config::PathType,
db::{
DbConn,
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
SendId, User, UserId, UserStampException,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
},
},
error::Error,
@ -822,6 +822,12 @@ pub struct AdminHeaders {
pub org_id: OrganizationId,
}
impl AdminHeaders {
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders {
type Error = &'static str;

7
src/config.rs

@ -1431,7 +1431,9 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"desktop-ui-migration-milestone-4",
// Auth Team
"pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management",
// Autofill Team
"enable-basic-auth-response",
"ssh-agent",
"ssh-agent-v2",
// Key Management Team
@ -1444,8 +1446,11 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"mutual-tls",
"cxp-import-mobile",
"cxp-export-mobile",
"pm-34171-card-scanner",
// Platform Team
"pm-30529-webauthn-related-origins",
// Vault Team
"pm-32009-new-item-types",
];
impl Config {
@ -1750,7 +1755,7 @@ where
reg!("email/email_footer");
reg!("email/email_footer_text");
reg!("email/admin_reset_password", ".html");
reg!("email/admin_account_recovery", ".html");
reg!("email/change_email_existing", ".html");
reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html");

9
src/db/models/archive.rs

@ -41,17 +41,20 @@ impl Archive {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
diesel::replace_into(archives::table)
mysql {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
archives::cipher_uuid.eq(cipher_uuid),
archives::archived_at.eq(archived_at),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(archives::archived_at.eq(archived_at))
.execute(conn)
.map_res("Error saving archive")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),

18
src/db/models/attachment.rs

@ -82,24 +82,16 @@ impl Attachment {
impl Attachment {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(attachments::table)
mysql {
diesel::insert_into(attachments::table)
.values(self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(attachments::table)
.filter(attachments::id.eq(&self.id))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving attachment")
}
Err(e) => Err(e.into()),
}.map_res("Error saving attachment")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(attachments::table)
.values(self)
.on_conflict(attachments::id)

30
src/db/models/auth_request.rs

@ -82,31 +82,23 @@ impl AuthRequest {
}
impl AuthRequest {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(auth_requests::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(auth_requests::table)
.filter(auth_requests::uuid.eq(&self.uuid))
.set(&*self)
mysql {
diesel::insert_into(auth_requests::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error auth_request")
}
Err(e) => Err(e.into()),
}.map_res("Error auth_request")
.map_res("Error saving auth_request")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(auth_requests::table)
.values(&*self)
.values(self)
.on_conflict(auth_requests::uuid)
.do_update()
.set(&*self)
.set(self)
.execute(conn)
.map_res("Error saving auth_request")
}

18
src/db/models/cipher.rs

@ -440,24 +440,16 @@ impl Cipher {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(ciphers::table)
mysql {
diesel::insert_into(ciphers::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(ciphers::table)
.filter(ciphers::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving cipher")
}
Err(e) => Err(e.into()),
}.map_res("Error saving cipher")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(ciphers::table)
.values(&*self)
.on_conflict(ciphers::uuid)

78
src/db/models/collection.rs

@ -168,24 +168,16 @@ impl Collection {
self.update_users_revision(conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(collections::table)
mysql {
diesel::insert_into(collections::table)
.values(self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections::table)
.filter(collections::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving collection")
}
Err(e) => Err(e.into()),
}.map_res("Error saving collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(collections::table)
.values(self)
.on_conflict(collections::uuid)
@ -728,53 +720,30 @@ impl CollectionUser {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(users_collections::table)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(users_collections::table)
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_collections::collection_uuid.eq(collection_uuid))
.set((
let values = (
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
);
db_run! { conn:
mysql {
diesel::insert_into(users_collections::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
.execute(conn)
.map_res("Error adding user to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(users_collections::table)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.values(values)
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
.do_update()
.set((
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.set(values)
.execute(conn)
.map_res("Error adding user to collection")
}
@ -909,19 +878,18 @@ impl CollectionCipher {
Self::update_users_revision(collection_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
// Not checking for ForeignKey Constraints here.
// Table ciphers_collections does not have ForeignKey Constraints which would cause conflicts.
// This table has no constraints pointing to itself, but only to others.
diesel::replace_into(ciphers_collections::table)
mysql {
diesel::insert_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
ciphers_collections::collection_uuid.eq(collection_uuid),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),

21
src/db/models/device.rs

@ -146,15 +146,18 @@ impl Device {
}
db_run! { conn:
sqlite, mysql {
mysql {
crate::util::retry(||
diesel::replace_into(devices::table)
diesel::insert_into(devices::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn),
10,
).map_res("Error saving device")
}
postgresql {
postgresql, sqlite {
crate::util::retry(||
diesel::insert_into(devices::table)
.values(&*self)
@ -266,10 +269,22 @@ impl Device {
let devices = Self::find_by_user(user_uuid, conn).await;
for mut device in devices {
device.refresh_token = Device::generate_refresh_token();
device.twofactor_remember = None;
device.save(false, conn).await?;
}
Ok(())
}
pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(devices::table)
.filter(devices::user_uuid.eq(user_uuid))
.set(devices::twofactor_remember.eq::<Option<String>>(None))
.execute(conn)
.map_res("Error removing two factor remember tokens")
})
.await
}
}
#[derive(Display)]

20
src/db/models/emergency_access.rs

@ -146,24 +146,16 @@ impl EmergencyAccess {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(emergency_access::table)
mysql {
diesel::insert_into(emergency_access::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(emergency_access::table)
.filter(emergency_access::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error updating emergency access")
}
Err(e) => Err(e.into()),
}.map_res("Error saving emergency access")
.map_res("Error saving emergency access")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(emergency_access::table)
.values(&*self)
.on_conflict(emergency_access::uuid)

17
src/db/models/event.rs

@ -43,7 +43,7 @@ pub struct Event {
pub provider_org_uuid: Option<String>,
}
// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs
// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs
#[derive(Debug, Copy, Clone)]
pub enum EventType {
// User
@ -108,6 +108,12 @@ pub enum EventType {
OrganizationUserRejectedAuthRequest = 1514,
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, // User voluntarily left the organization
// OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
OrganizationUserNotificationBannerActionClicked = 1522,
// Organization
OrganizationUpdated = 1600,
@ -202,13 +208,16 @@ impl Event {
/// Basic Queries
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(event::table)
mysql {
diesel::insert_into(event::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(event::table)
.values(self)
.on_conflict(event::uuid)

29
src/db/models/folder.rs

@ -77,24 +77,16 @@ impl Folder {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(folders::table)
mysql {
diesel::insert_into(folders::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(folders::table)
.filter(folders::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving folder")
}
Err(e) => Err(e.into()),
}.map_res("Error saving folder")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(folders::table)
.values(&*self)
.on_conflict(folders::uuid)
@ -147,16 +139,15 @@ impl Folder {
impl FolderCipher {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
// Not checking for ForeignKey Constraints here.
// Table folders_ciphers does not have ForeignKey Constraints which would cause conflicts.
// This table has no constraints pointing to itself, but only to others.
diesel::replace_into(folders_ciphers::table)
mysql {
diesel::insert_into(folders_ciphers::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to folder")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(folders_ciphers::table)
.values(self)
.on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid))

109
src/db/models/group.rs

@ -166,24 +166,16 @@ impl Group {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(groups::table)
mysql {
diesel::insert_into(groups::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups::table)
.filter(groups::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving group")
}
Err(e) => Err(e.into()),
}.map_res("Error saving group")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(groups::table)
.values(&*self)
.on_conflict(groups::uuid)
@ -326,53 +318,30 @@ impl CollectionGroup {
group_user.update_user_revision(conn).await;
}
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(collections_groups::table)
.values((
let values = (
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections_groups::table)
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
.set((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
))
);
db_run! { conn:
mysql {
diesel::insert_into(collections_groups::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
.execute(conn)
.map_res("Error adding group to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding group to collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(collections_groups::table)
.values((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
collections_groups::manage.eq(self.manage),
))
.values(values)
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
.do_update()
.set((
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
collections_groups::manage.eq(self.manage),
))
.set(values)
.execute(conn)
.map_res("Error adding group to collection")
}
@ -497,43 +466,25 @@ impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.update_user_revision(conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(groups_users::table)
.values((
let values = (
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups_users::table)
.filter(groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid))
.filter(groups_users::groups_uuid.eq(&self.groups_uuid))
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
);
db_run! { conn:
mysql {
diesel::insert_into(groups_users::table)
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding user to group")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to group")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(groups_users::table)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.values(values)
.on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid))
.do_update()
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.do_nothing()
.execute(conn)
.map_res("Error adding user to group")
}

2
src/db/models/mod.rs

@ -39,4 +39,4 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};

21
src/db/models/org_policy.rs

@ -49,6 +49,7 @@ pub enum OrgPolicyType {
// AutotypeDefaultSetting = 17, // Not supported yet
// AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20,
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
@ -186,6 +187,26 @@ impl OrgPolicy {
.await
}
pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
org_policies::table
.inner_join(
users_organizations::table.on(users_organizations::org_uuid
.eq(org_policies::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.filter(
users_organizations::status
.eq(MembershipStatus::Accepted as i32)
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
)
.select(org_policies::all_columns)
.load::<Self>(conn)
.expect("Error loading org_policy")
})
.await
}
pub async fn find_by_org_and_type(
org_uuid: &OrganizationId,
policy_type: OrgPolicyType,

71
src/db/models/organization.rs

@ -353,25 +353,16 @@ impl Organization {
}
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(organizations::table)
mysql {
diesel::insert_into(organizations::table)
.values(self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(organizations::table)
.filter(organizations::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving organization")
}
Err(e) => Err(e.into()),
}.map_res("Error saving organization")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(organizations::table)
.values(self)
.on_conflict(organizations::uuid)
@ -753,24 +744,16 @@ impl Membership {
User::update_uuid_revision(&self.user_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(users_organizations::table)
mysql {
diesel::insert_into(users_organizations::table)
.values(self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(users_organizations::table)
.filter(users_organizations::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error adding user to organization")
},
Err(e) => Err(e.into()),
}.map_res("Error adding user to organization")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(users_organizations::table)
.values(self)
.on_conflict(users_organizations::uuid)
@ -861,6 +844,21 @@ impl Membership {
.await
}
pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(
users_organizations::status
.eq(MembershipStatus::Accepted as i32)
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
)
.load::<Self>(conn)
.unwrap_or_default()
})
.await
}
pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
@ -1186,25 +1184,16 @@ impl Membership {
impl OrganizationApiKey {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(organization_api_key::table)
mysql {
diesel::insert_into(organization_api_key::table)
.values(self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(organization_api_key::table)
.filter(organization_api_key::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving organization")
}
Err(e) => Err(e.into()),
}.map_res("Error saving organization")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(organization_api_key::table)
.values(self)
.on_conflict((organization_api_key::uuid, organization_api_key::org_uuid))

18
src/db/models/send.rs

@ -202,24 +202,16 @@ impl Send {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(sends::table)
mysql {
diesel::insert_into(sends::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(sends::table)
.filter(sends::uuid.eq(&self.uuid))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving send")
}
Err(e) => Err(e.into()),
}.map_res("Error saving send")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(sends::table)
.values(&*self)
.on_conflict(sends::uuid)

44
src/db/models/user.rs

@ -69,6 +69,8 @@ pub struct User {
pub avatar_color: Option<String>,
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>,
}
#[derive(Identifiable, Queryable, Insertable)]
@ -154,6 +156,8 @@ impl User {
avatar_color: None,
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None,
}
}
@ -259,6 +263,11 @@ impl User {
orgs_json.push(c.to_json(conn).await);
}
let mut orgs_new_json = Vec::new();
for c in Membership::find_accepted_and_confirmed_by_user(&self.uuid, conn).await {
orgs_new_json.push(c.to_json(conn).await);
}
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty();
// TODO: Might want to save the status field in the DB
@ -299,6 +308,7 @@ impl User {
"privateKey": self.private_key,
"securityStamp": self.security_stamp,
"organizations": orgs_json,
"organizationsNew": orgs_new_json,
"providers": [],
"providerOrganizations": [],
"forcePasswordReset": false,
@ -463,15 +473,17 @@ impl Invitation {
}
db_run! { conn:
sqlite, mysql {
// Not checking for ForeignKey Constraints here
// Table invitations does not have any ForeignKey Constraints.
diesel::replace_into(invitations::table)
mysql {
diesel::insert_into(invitations::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error saving invitation")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(invitations::table)
.values(self)
.on_conflict(invitations::email)
@ -525,16 +537,36 @@ impl Invitation {
#[from(forward)]
pub struct UserId(String);
#[derive(
Clone,
Debug,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
UuidFromParam,
)]
#[deref(forward)]
#[from(forward)]
pub struct KeyId(String);
impl SsoUser {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(sso_users::table)
mysql {
diesel::insert_into(sso_users::table)
.values(self)
.execute(conn)
.map_res("Error saving SSO user")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(sso_users::table)
.values(self)
.execute(conn)

1
src/db/schema.rs

@ -217,6 +217,7 @@ table! {
api_key -> Nullable<Text>,
avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>,
key_id -> Nullable<Text>,
}
}

85
src/http_client.rs

@ -14,7 +14,10 @@ use reqwest::{
};
use url::Host;
use crate::{CONFIG, util::is_global};
use crate::{
CONFIG,
util::{get_env_bool, is_global},
};
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
static INSTANCE: LazyLock<Client> =
@ -36,7 +39,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
let mut headers = header::HeaderMap::new();
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden"));
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= 5 {
return attempt.error("Too many redirects");
}
@ -45,7 +48,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
return attempt.error("Invalid host");
};
if let Err(e) = should_block_host(&host) {
if enforce_block && let Err(e) = should_block_host(&host) {
return attempt.error(e);
}
@ -59,6 +62,14 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
.timeout(Duration::from_secs(10))
}
fn dns_prefer_ipv6() -> bool {
// CONFIG may require DNS to initialize, so avoid forcing it during bootstrap.
match LazyLock::get(&CONFIG) {
Some(config) => config.dns_prefer_ipv6(),
None => get_env_bool("DNS_PREFER_IPV6").unwrap_or(false),
}
}
fn should_block_ip(ip: IpAddr) -> bool {
if !CONFIG.http_request_block_non_global_ips() {
return false;
@ -258,12 +269,8 @@ impl CustomDnsResolver {
fn new() -> Arc<Self> {
TokioResolver::builder(TokioRuntimeProvider::default())
.and_then(|mut builder| {
// Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first
// This might cause issues on IPv4 only systems or containers
// Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default
if !CONFIG.dns_prefer_ipv6() {
// Query both families; the preferred order is applied per lookup below.
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
}
builder.build()
})
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
@ -289,6 +296,17 @@ impl CustomDnsResolver {
}
}
fn sort_addresses(addresses: &mut [SocketAddr], prefer_ipv6: bool) {
// `sort_by_key` orders `false` before `true`.
// When IPv6 is preferred, IPv6 addresses return `false` for `is_ipv4()` and sort first.
// When IPv4 is preferred, IPv4 addresses return `false` for `is_ipv6()` and sort first.
if prefer_ipv6 {
addresses.sort_by_key(SocketAddr::is_ipv4);
} else {
addresses.sort_by_key(SocketAddr::is_ipv6);
}
}
fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> {
let Ok(host) = get_valid_host(name) else {
return Err(CustomHttpClientError::Invalid {
@ -320,7 +338,9 @@ impl Resolve for CustomDns {
let this = Arc::clone(&self.resolver);
Box::pin(async move {
let name = name.as_str();
let results = this.resolve_domain(name, enforce_block).await?;
let mut results = this.resolve_domain(name, enforce_block).await?;
// Recheck after bootstrap so long-lived clients adopt the loaded config.
sort_addresses(&mut results, dns_prefer_ipv6());
if results.is_empty() {
warn!("Unable to resolve {name} to any valid IP address");
}
@ -339,10 +359,29 @@ pub(crate) mod aws {
};
use reqwest::Client;
use super::get_reqwest_client_builder;
// Adapter that wraps reqwest to be compatible with the AWS SDK
#[derive(Debug)]
pub(crate) struct AwsReqwestConnector {
pub(crate) client: Client,
client: Client,
}
impl AwsReqwestConnector {
pub(crate) fn new() -> Self {
let client = get_reqwest_client_builder(false).build().expect("Failed to build AWS HTTP client");
Self {
client,
}
}
}
fn connector_error(error: reqwest::Error) -> ConnectorError {
if error.is_timeout() {
ConnectorError::timeout(Box::new(error))
} else {
ConnectorError::io(Box::new(error))
}
}
impl HttpConnector for AwsReqwestConnector {
@ -362,10 +401,10 @@ pub(crate) mod aws {
req_builder = req_builder.body(body_bytes.to_vec());
}
let response = req_builder.send().await.map_err(|e| ConnectorError::io(Box::new(e)))?;
let response = req_builder.send().await.map_err(connector_error)?;
let status = response.status().into();
let bytes = response.bytes().await.map_err(|e| ConnectorError::io(Box::new(e)))?;
let bytes = response.bytes().await.map_err(connector_error)?;
Ok(HttpResponse::new(status, bytes.into()))
};
@ -391,7 +430,7 @@ pub(crate) mod aws {
mod tests {
use super::*;
use crate::util::is_global_hardcoded;
use std::net::Ipv4Addr;
use std::net::{Ipv4Addr, Ipv6Addr};
use url::Host;
// ===
@ -404,6 +443,26 @@ mod tests {
}
}
#[test]
fn dns_setup_does_not_initialize_config() {
assert!(LazyLock::get(&CONFIG).is_none());
drop(CustomDns::instance(false));
assert!(LazyLock::get(&CONFIG).is_none());
}
#[test]
fn dns_preference_orders_addresses() {
let ipv4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0);
let mut addresses = [ipv6, ipv4];
sort_addresses(&mut addresses, false);
assert_eq!(addresses, [ipv4, ipv6]);
sort_addresses(&mut addresses, true);
assert_eq!(addresses, [ipv6, ipv4]);
}
#[test]
fn dotted_decimal_loopback_normalizes() {
let ip = parse_to_ip("127.0.0.1").unwrap();

14
src/mail.rs

@ -633,14 +633,24 @@ pub async fn send_test(address: &str) -> EmptyResult {
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult {
pub async fn send_admin_account_recovery(
address: &str,
user_name: &str,
org_name: &str,
reset_password: bool,
reset_2fa: bool,
fallback_2fa_email: bool,
) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/admin_reset_password",
"email/admin_account_recovery",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"user_name": user_name,
"org_name": org_name,
"reset_password": reset_password,
"reset_2fa": reset_2fa,
"fallback_2fa_email": fallback_2fa_email,
}),
)?;
send_email(address, &subject, body_html, body_text).await

16
src/static/scripts/datatables.css

@ -4,10 +4,10 @@
*
* To rebuild or modify this file with the latest versions of the included
* software please visit:
* https://datatables.net/download/#bs5/dt-3.0.3
* https://datatables.net/download/#bs5/dt-3.0.4
*
* Included libraries:
* DataTables 3.0.3
* DataTables 3.0.4
*/
/*! DataTables Bootstrap 5 integration
@ -636,6 +636,18 @@ table.dataTable.table-sm > thead > tr td.dt-type-date .dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-type-numeric .dt-column-order {
left: 0.25rem;
}
table.dataTable.table-sm > thead > tr th.dt-left .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-left .dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-left .dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-head-left .dt-column-order {
left: auto;
right: 0.25rem;
}
table.dataTable.table-sm > thead > tr th.dt-right .dt-column-order, table.dataTable.table-sm > thead > tr th.dt-head-right .dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-right .dt-column-order,
table.dataTable.table-sm > thead > tr td.dt-head-right .dt-column-order {
left: 0.25rem;
right: auto;
}
div.dt-scroll-head table.table-bordered {
border-bottom-width: 0;

9418
src/static/scripts/datatables.js

File diff suppressed because it is too large

12
src/static/templates/email/admin_account_recovery.hbs

@ -0,0 +1,12 @@
Admin account recovery from {{org_name}} organization
<!---------------->
{{#if reset_password}}
The master password for {{user_name}} has been changed.
{{/if}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
If you did not initiate this request, please reach out to your administrator immediately.
{{> email/email_footer_text }}

11
src/static/templates/email/admin_reset_password.html.hbs → src/static/templates/email/admin_account_recovery.html.hbs

@ -1,10 +1,17 @@
Master Password Has Been Changed
Admin account recovery from {{org_name}} organization
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
The master password for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> has been changed by an administrator in your <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization. If you did not initiate this request, please reach out to your administrator immediately.
{{#if reset_password}}
The master password for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> has been changed.
{{/if}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
<br>
If you did not initiate this request, please reach out to your administrator immediately.
</td>
</tr>
</table>

4
src/static/templates/email/admin_reset_password.hbs

@ -1,4 +0,0 @@
Master Password Has Been Changed
<!---------------->
The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately.
{{> email/email_footer_text }}

19
src/storage.rs

@ -77,10 +77,18 @@ pub(crate) fn operator_for_path(path: &str) -> Result<opendal::Operator, crate::
#[cfg(s3)]
mod s3 {
use std::sync::LazyLock;
use opendal_http_transport_reqwest::ReqwestTransport;
use reqwest::Url;
use crate::error::Error;
static HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
// Storage endpoints are administrator-configured and may be private.
crate::http_client::get_reqwest_client_builder(false).build().expect("Failed to build OpenDAL HTTP client")
});
pub(super) fn is_uri(path: &str) -> bool {
path.starts_with("s3://")
}
@ -177,12 +185,7 @@ mod s3 {
let chain = DEFAULT_CREDENTIAL_CHAIN
.get_or_init(|| {
let reqwest_client = reqwest::Client::builder().build().unwrap();
let connector = AwsReqwestConnector {
client: reqwest_client,
};
let conf = ProviderConfig::default().with_http_client(connector);
let conf = ProviderConfig::default().with_http_client(AwsReqwestConnector::new());
DefaultCredentialsChain::builder().configure(conf).build()
})
@ -236,7 +239,9 @@ mod s3 {
builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider));
}
Ok(opendal::Operator::new(builder)?)
let http_transport = opendal::HttpTransporter::new(ReqwestTransport::new(HTTP_CLIENT.clone()));
let context = opendal::OperationContext::new().with_http_transport(http_transport);
Ok(opendal::Operator::new(builder)?.with_context(context))
}
fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool {

Loading…
Cancel
Save