Browse Source

Merge branch 'main' into main

pull/7566/head
kittygaming99 4 days ago
committed by GitHub
parent
commit
14ccd87419
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 22
      .env.template
  2. 634
      Cargo.lock
  3. 10
      Cargo.toml
  4. 4
      docker/DockerSettings.yaml
  5. 12
      docker/Dockerfile.alpine
  6. 12
      docker/Dockerfile.debian
  7. 22
      migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql
  8. 13
      migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql
  9. 20
      migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql
  10. 13
      migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql
  11. 19
      migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql
  12. 11
      migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql
  13. 52
      src/api/admin.rs
  14. 44
      src/api/core/accounts.rs
  15. 165
      src/api/core/ciphers.rs
  16. 4
      src/api/core/events.rs
  17. 1
      src/api/core/mod.rs
  18. 173
      src/api/core/organizations.rs
  19. 11
      src/api/core/sends.rs
  20. 18
      src/api/core/two_factor/email.rs
  21. 14
      src/api/core/two_factor/mod.rs
  22. 13
      src/api/icons.rs
  23. 29
      src/api/identity.rs
  24. 4
      src/api/mod.rs
  25. 58
      src/api/push.rs
  26. 5
      src/api/web.rs
  27. 6
      src/auth.rs
  28. 6
      src/auth/send.rs
  29. 35
      src/config.rs
  30. 71
      src/db/mod.rs
  31. 21
      src/db/models/cipher.rs
  32. 14
      src/db/models/collection.rs
  33. 5
      src/db/models/event.rs
  34. 6
      src/db/models/mod.rs
  35. 10
      src/db/models/org_policy.rs
  36. 343
      src/db/models/organization.rs
  37. 1
      src/db/models/send.rs
  38. 4
      src/db/models/user.rs
  39. 9
      src/db/schema.rs
  40. 2
      src/mail.rs
  41. 10
      src/sso.rs
  42. 2
      src/sso_client.rs
  43. 16
      src/static/scripts/admin_users.js
  44. 5
      src/static/templates/admin/users.hbs
  45. 7
      src/static/templates/scss/vaultwarden.scss.hbs
  46. 44
      src/util.rs

22
.env.template

@ -386,23 +386,31 @@
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
## Client Settings
## Server-wide Fill Assist rules feed base directory URL, not a JSON file.
## Defaults to https://fillassist.bitwarden.com.
## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace.
# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com
## Enable experimental feature flags for clients.
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
## 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:
## - "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-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)
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
## - "pm-30529-webauthn-related-origins":
## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.0)
## - "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)
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.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.
## 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.
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent.
@ -489,6 +497,15 @@
## KNOW WHAT YOU ARE DOING!
# 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!)
## 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!
@ -606,6 +623,9 @@
## Note that the checkbox would still be present, but ignored.
# DISABLE_2FA_REMEMBER=false
##
## Number of days before a remembered 2FA login expires (min: 1, max: 90).
# TWO_FACTOR_REMEMBER_DAYS=30
##
## Authenticator Settings
## Disable authenticator time drifted codes to be valid.
## TOTP codes of the previous and next 30 seconds will be invalid

634
Cargo.lock

File diff suppressed because it is too large

10
Cargo.toml

@ -66,7 +66,7 @@ syslog = "7.0.0"
macros = { path = "./macros" }
# Logging
log = "0.4.34"
log = "0.4.35"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
tracing = { version = "0.1.44", features = ["log"] }
@ -204,7 +204,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [
hickory-resolver = "0.26.3"
# Favicon extraction libraries
html5gum = "0.8.4"
html5gum = "0.9.0"
regex = { version = "1.13.1", default-features = false, features = [
"perf",
"std",
@ -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 = "4.0.3", features = ["async"] }
cached = { version = "4.1.0", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2"
@ -235,7 +235,7 @@ governor = "0.10.4"
ipnet = "2.12.2"
# OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false }
openidconnect = { version = "4.1.0", default-features = false }
moka = { version = "0.12.16", features = ["future"] }
# Check client versions for specific features.
@ -268,7 +268,7 @@ aws-config = { version = "1.13.0", optional = true, default-features = false, fe
"sso",
] }
aws-credential-types = { version = "1.3.1", optional = true }
aws-smithy-runtime-api = { version = "1.19.1", optional = true }
aws-smithy-runtime-api = { version = "1.19.2", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true }

4
docker/DockerSettings.yaml

@ -1,6 +1,6 @@
---
vault_version: "v2026.8.0"
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
vault_version: "v2026.10.1"
vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52"
# Cross Compile Docker Helper Scripts v1.10.0
# 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

12
docker/Dockerfile.alpine

@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.8.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
# [docker.io/vaultwarden/web-vault:v2026.10.1]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64

12
docker/Dockerfile.debian

@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
# [docker.io/vaultwarden/web-vault:v2026.8.0]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
# [docker.io/vaultwarden/web-vault:v2026.10.1]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts

22
migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql

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

13
migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql

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

20
migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql

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

13
migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql

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

19
migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql

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

11
migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql

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

52
src/api/admin.rs

@ -1,4 +1,4 @@
use std::{env, sync::LazyLock};
use std::{collections::HashMap, env, sync::LazyLock};
use reqwest::Method;
use rocket::{
@ -17,7 +17,7 @@ use crate::{
api::{
ApiResult, EmptyResult, JsonResult, Notify,
core::{log_event, two_factor},
unregister_push_device,
push_logout_to_relay, unregister_push_device,
},
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
config::ConfigBuilder,
@ -26,6 +26,7 @@ use crate::{
models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
custom_roles_enabled,
},
},
error::{Error, MapResult},
@ -256,6 +257,7 @@ struct AdminTemplateData {
logged_in: bool,
urlpath: String,
sso_enabled: bool,
custom_roles_enabled: bool,
}
impl AdminTemplateData {
@ -266,6 +268,7 @@ impl AdminTemplateData {
logged_in: true,
urlpath: CONFIG.domain_path(),
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()));
// 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);
}
@ -419,13 +434,21 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso
}
#[post("/users/<user_id>/delete", format = "application/json")]
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let user = get_user_or_404(&user_id, &conn).await?;
// Get the membership records before deleting the actual user
// Get the membership records and push devices before deleting the actual user
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await;
let res = user.delete(&conn).await;
if res.is_ok() {
nt.send_logout(&user, None, &conn).await;
if has_push_device {
push_logout_to_relay(&user, None);
}
}
for membership in memberships {
log_event(
EventType::OrganizationUserDeleted,
@ -557,10 +580,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
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()) {
new_type as i32
} else {
err!("Invalid type")
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
// Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
// are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
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 {
@ -570,7 +597,14 @@ 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
if new_type == MembershipType::Custom
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(&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
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;

44
src/api/core/accounts.rs

@ -13,7 +13,7 @@ use crate::{
api::{
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{accept_org_invite, log_user_event, two_factor::email},
master_password_policy, register_push_device, unregister_push_device,
master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device,
},
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
crypto,
@ -1148,7 +1148,11 @@ async fn post_email_token(data: Json<EmailTokenData>, headers: Headers, conn: Db
error!("Error sending change-email email: {e:#?}");
}
} else {
debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email);
debug!(
"Email change request for user ({}) to email ({}) with token ({token})",
user.uuid,
data.new_email.escape_debug()
);
}
user.email_new = Some(data.new_email);
@ -1301,7 +1305,7 @@ struct DeleteRecoverTokenData {
}
#[post("/accounts/delete-recover-token", data = "<data>")]
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn) -> EmptyResult {
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: DeleteRecoverTokenData = data.into_inner();
let Ok(claims) = decode_delete(&data.token) else {
@ -1315,22 +1319,44 @@ async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbC
if claims.sub != *user.uuid {
err!("Invalid claim");
}
user.delete(&conn).await
// The devices are deleted together with the user, so check for push devices beforehand
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
user.delete(&conn).await?;
nt.send_logout(&user, None, &conn).await;
if has_push_device {
push_logout_to_relay(&user, None);
}
Ok(())
}
#[post("/accounts/delete", data = "<data>")]
async fn post_delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
delete_account(data, headers, conn).await
async fn post_delete_account(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
delete_account(data, headers, conn, nt).await
}
#[delete("/accounts", data = "<data>")]
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, true, &conn).await?;
user.delete(&conn).await
// The devices are deleted together with the user, so check for push devices beforehand
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
user.delete(&conn).await?;
nt.send_logout(&user, None, &conn).await;
if has_push_device {
push_logout_to_relay(&user, None);
}
Ok(())
}
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
@ -1381,7 +1407,7 @@ async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn)
mail::send_password_hint(email, hint).await?;
Ok(())
} else if let Some(hint) = hint {
err!(format!("Your password hint is: {hint}"));
err_silent!(format!("Your password hint is: {hint}"));
} else {
err!(NO_HINT);
}

165
src/api/core/ciphers.rs

@ -13,7 +13,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
auth::ClientVersion,
auth::{Headers, OrgIdGuard, OwnerHeaders},
config::PathType,
@ -23,7 +23,7 @@ use crate::{
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@ -458,7 +458,7 @@ pub async fn update_cipher_from_data(
{
match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
@ -521,7 +521,7 @@ pub async fn update_cipher_from_data(
// A missing attachment means it was removed via an other client.
// Also the Desktop Client supports removing attachments and save an update afterwards.
// Bitwarden it self ignores these mismatches server side.
warn!("Attachment {id} doesn't exist");
warn!("Attachment {} doesn't exist", id.escape_debug());
continue;
};
@ -580,7 +580,7 @@ pub async fn update_cipher_from_data(
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}"),
Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()),
},
None => cipher.unarchive(&headers.user.uuid, conn).await?,
}
@ -666,6 +666,9 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id;
// A personal import always creates personal ciphers, the organization ID is ignored instead of rejected
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
cipher_data.organization_id = None;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
@ -687,6 +690,11 @@ async fn put_cipher_admin(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
// Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's
if data.organization_id.is_none() {
err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code);
}
put_cipher(cipher_id, data, headers, conn, nt).await
}
@ -698,7 +706,7 @@ async fn post_cipher_admin(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
post_cipher(cipher_id, data, headers, conn, nt).await
put_cipher_admin(cipher_id, data, headers, conn, nt).await
}
#[post("/ciphers/<cipher_id>", data = "<data>")]
@ -735,6 +743,11 @@ async fn put_cipher(
err!("Cipher is not write accessible")
}
// Moving a cipher into an organization is only possible through the share endpoints
if cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@ -993,9 +1006,7 @@ async fn post_cipher_share(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
put_cipher_share(cipher_id, data, headers, conn, nt).await
}
#[put("/ciphers/<cipher_id>/share", data = "<data>")]
@ -1008,6 +1019,14 @@ async fn put_cipher_share(
) -> JsonResult {
let data: ShareCipherData = data.into_inner();
if data.cipher.organization_id.is_none() {
err!("Cipher OrganizationId is required.")
}
if data.collection_ids.is_empty() {
err!("You must select at least one collection.")
}
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
}
@ -1078,6 +1097,9 @@ async fn put_cipher_share_selected(
if cipher.id.is_none() {
err!("Request missing ids field")
}
if cipher.organization_id.is_none() {
err!("Cipher OrganizationId is required.")
}
}
let mut responses = Vec::with_capacity(data.ciphers.len());
@ -1214,6 +1236,40 @@ enum FileUploadType {
// Azure = 1, // only used upstream
}
/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited.
/// `size_adjust` credits a v2 attachment whose record already counts as used.
async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult<Option<i64>> {
let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid {
let Some(limit_kb) = CONFIG.user_attachment_limit() else {
return Ok(None);
};
(limit_kb, Attachment::size_by_user(user_id, conn).await)
} else if let Some(ref org_id) = cipher.organization_uuid {
let Some(limit_kb) = CONFIG.org_attachment_limit() else {
return Ok(None);
};
(limit_kb, Attachment::size_by_org(org_id, conn).await)
} else {
err!("Cipher is neither owned by a user nor an organization");
};
if limit_kb == 0 {
err!("Attachments are disabled")
}
let Some(left) =
limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust))
else {
err!("Attachment size overflow");
};
if left <= 0 {
err!("Attachment storage limit reached! Delete some attachments to free up space")
}
Ok(Some(left))
}
/// v2 API for creating an attachment associated with a cipher.
/// This redirects the client to the API it should use to upload the attachment.
/// For upstream's cloud-hosted service, it's an Azure object storage API.
@ -1224,6 +1280,7 @@ async fn post_attachment_v2(
data: Json<AttachmentRequestData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
err!("Cipher doesn't exist")
@ -1243,10 +1300,18 @@ async fn post_attachment_v2(
if file_size > SIZE_525_MB {
err!("Max file size is 525 MB.")
}
if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await?
&& file_size > size_limit
{
err!("Attachment storage limit exceeded with this file");
}
let attachment_id = crypto::generate_attachment_id();
let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
attachment.save(&conn).await.expect("Error saving attachment");
notify_attachment_created(&cipher, &headers, &conn, &nt).await;
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
let response_key = match data.admin_request {
@ -1309,55 +1374,7 @@ async fn save_attachment(
Some(a) => a.file_size, // v2 API
};
let size_limit = if let Some(ref user_id) = cipher.user_uuid {
match CONFIG.user_attachment_limit() {
Some(0) => err!("Attachments are disabled"),
Some(limit_kb) => {
let already_used = Attachment::size_by_user(user_id, &conn).await;
let left = limit_kb
.checked_mul(1024)
.and_then(|l| l.checked_sub(already_used))
.and_then(|l| l.checked_add(size_adjust));
let Some(left) = left else {
err!("Attachment size overflow");
};
if left <= 0 {
err!("Attachment storage limit reached! Delete some attachments to free up space")
}
Some(left)
}
None => None,
}
} else if let Some(ref org_id) = cipher.organization_uuid {
match CONFIG.org_attachment_limit() {
Some(0) => err!("Attachments are disabled"),
Some(limit_kb) => {
let already_used = Attachment::size_by_org(org_id, &conn).await;
let left = limit_kb
.checked_mul(1024)
.and_then(|l| l.checked_sub(already_used))
.and_then(|l| l.checked_add(size_adjust));
let Some(left) = left else {
err!("Attachment size overflow");
};
if left <= 0 {
err!("Attachment storage limit reached! Delete some attachments to free up space")
}
Some(left)
}
None => None,
}
} else {
err!("Cipher is neither owned by a user nor an organization");
};
if let Some(size_limit) = size_limit
if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await?
&& size > size_limit
{
err!("Attachment storage limit exceeded with this file");
@ -1414,13 +1431,23 @@ async fn save_attachment(
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
// With the v2 API, post_attachment_v2() already did this when it created the attachment record
if attachment.is_none() {
notify_attachment_created(&cipher, headers, &conn, &nt).await;
}
Ok((cipher, conn))
}
/// Sends the cipher update to the clients and logs the attachment event
async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) {
nt.send_cipher_update(
UpdateType::SyncCipherUpdate,
&cipher,
&cipher.update_users_revision(&conn).await,
cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
&conn,
conn,
)
.await;
@ -1432,12 +1459,10 @@ async fn save_attachment(
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
conn,
)
.await;
}
Ok((cipher, conn))
}
/// v2 API for uploading the actual data content of an attachment.
@ -1713,12 +1738,22 @@ async fn move_cipher_selected(
// Find all ciphers a user has access to, all others will be ignored
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
let accessible_ciphers_count = accessible_ciphers.len();
let mut move_result = Ok(());
for cipher in accessible_ciphers {
cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?;
move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await;
if move_result.is_err() {
break;
}
if cipher_count == 1 {
single_cipher = Some(cipher);
}
}
// Update the revision once for the whole batch. Also when a move failed halfway,
// since the ciphers moved before it are already changed.
if accessible_ciphers_count > 0 {
User::update_uuid_revision(user_id, &conn).await;
}
move_result?;
if let Some(cipher) = single_cipher {
nt.send_cipher_update(

4
src/api/core/events.rs

@ -214,8 +214,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
// Client-side cipher events (viewed, copied, toggled, autofilled).
1107..=1114 | 1117 | 1119..=1132 => {
// Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth).
1107..=1114 | 1117 | 1119..=1133 => {
// 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.
if let Some(cipher_uuid) = &event.cipher_id

1
src/api/core/mod.rs

@ -247,6 +247,7 @@ fn config() -> Json<Value> {
"notifications": format!("{domain}/notifications"),
"sso": "",
"cloudRegion": null,
"fillAssistRules": CONFIG.fill_assist_rules_url(),
},
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
"push": {

173
src/api/core/organizations.rs

@ -15,10 +15,10 @@ use crate::{
db::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
TwoFactorType, User, UserId,
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
},
},
mail,
@ -131,13 +131,16 @@ impl FullCollectionData {
let org_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id))
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id.escape_debug(), org_id))
}
let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
err!(
"Invalid member",
format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id)
)
}
Ok(())
@ -552,7 +555,7 @@ async fn post_bulk_access_collections(
let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
}
for col_id in data.collection_ids {
@ -975,13 +978,16 @@ impl InviteData {
let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
err!(
"Invalid collection",
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
)
}
let org_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id))
}
Ok(())
@ -1002,14 +1008,12 @@ async fn send_invite(
data.validate(&org_id, &conn).await?;
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
let raw_type = &data.r#type.into_string();
// Membership::from_str will convert custom (4) to manager (3)
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
new_type as i32
} else {
let Some(requested_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type")
};
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled());
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can invite Managers, Admins or Owners")
@ -1019,11 +1023,19 @@ async fn send_invite(
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// If the box is not checked, the user will still be a manager, but not with the access_all permission
let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4")
|| (new_type == MembershipType::Manager
&& raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
// Checked before anything is created, so a rejected permission leaves nothing behind
let custom_permissions = if new_type == MembershipType::Custom {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
let mut user_created: bool;
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
@ -1065,8 +1077,11 @@ async fn send_invite(
};
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
new_member.set_type(new_type);
new_member.access_all = access_all;
new_member.atype = new_type;
if let Some(permissions) = &custom_permissions {
new_member.set_custom_permissions(permissions);
}
new_member.status = member_status;
new_member.save(&conn).await?;
@ -1156,7 +1171,10 @@ async fn bulk_reinvite_members(
for member_id in data.ids {
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
Err(e) => {
warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug());
e.message().to_owned()
}
};
bulk_response.push(json!(
@ -1329,7 +1347,10 @@ async fn bulk_confirm_invite(
let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
Err(e) => {
warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug());
e.message().to_owned()
}
};
bulk_response.push(json!(
@ -1496,24 +1517,32 @@ async fn edit_member(
let data: EditUserData = data.into_inner();
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
let raw_type = &data.r#type.into_string();
// MembershipType::from_str will convert custom (4) to manager (3)
let Some(new_type) = MembershipType::from_str(raw_type) else {
let Some(requested_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type")
};
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err!("The specified user isn't member of the organization")
};
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled());
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// If the box is not checked, the user will still be a manager, but not with the access_all permission
let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4")
|| (new_type == MembershipType::Manager
&& raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err!("The specified user isn't member of the organization")
// While Custom roles are disabled, a Custom member keeps their stored permissions untouched
let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
if new_type != member_to_edit.atype
@ -1537,8 +1566,11 @@ async fn edit_member(
}
}
member_to_edit.set_type(new_type);
member_to_edit.access_all = access_all;
member_to_edit.atype = new_type as i32;
if let Some(permissions) = &custom_permissions {
member_to_edit.set_custom_permissions(permissions);
}
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// We need to perform the check after changing the type since `admin` is exempt.
@ -1610,7 +1642,10 @@ async fn bulk_delete_member(
for member_id in data.ids {
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
Err(e) => {
warn!("Unable to delete member {}: {e:?}", member_id.escape_debug());
e.message().to_owned()
}
};
bulk_response.push(json!(
@ -2039,6 +2074,29 @@ async fn put_policy(
err!("Invalid or unsupported policy type")
};
let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await;
let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled);
// Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the
// policy changes state.
if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled {
let single_org_policy_enabled =
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled);
if !single_org_policy_enabled {
err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.")
}
}
if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled {
let fill_assist_policy_enabled =
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled);
if fill_assist_policy_enabled {
err!("Turn off the Activate fill assist policy because it requires the Single organization policy.")
}
}
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
// Now that groups are available we can enforce this option when wanted.
@ -2071,6 +2129,37 @@ async fn put_policy(
}
}
if pol_type_enum == OrgPolicyType::FillAssist {
let fill_assist_data = match data.data.as_ref() {
None | Some(Value::Null) => FillAssistPolicyData {
rules_url: None,
},
Some(data) => {
let Ok(data) = serde_json::from_value::<FillAssistPolicyData>(data.clone()) else {
err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.")
};
data
}
};
if data.enabled {
let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else {
err!("The RulesUrl field is required.")
};
let Ok(rules_url) = url::Url::parse(&rules_url) else {
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
};
if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") {
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
}
if rules_url.scheme() != "https" {
err!("RulesUrl must use HTTPS.")
}
}
}
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
two_factor::enforce_2fa_policy_for_org(
@ -2102,7 +2191,7 @@ async fn put_policy(
}
log_event(
EventType::OrganizationUserRemoved,
EventType::OrganizationUserRevokedSingleOrganizationNonCompliance,
&member.uuid,
&org_id,
&headers.user.uuid,
@ -2118,7 +2207,7 @@ async fn put_policy(
}
}
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
let mut policy = match current_policy {
Some(p) => p,
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
};
@ -2222,7 +2311,10 @@ async fn bulk_revoke_members(
for member_id in members {
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
Err(e) => {
warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug());
e.message().to_owned()
}
};
bulk_response.push(json!(
@ -2325,7 +2417,10 @@ async fn bulk_restore_members(
for member_id in data.ids {
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
Err(e) => {
warn!("Unable to restore member {}: {e:?}", member_id.escape_debug());
e.message().to_owned()
}
};
bulk_response.push(json!(
@ -2478,13 +2573,16 @@ impl GroupRequest {
let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
err!(
"Invalid collection",
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
)
}
let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id))
}
Ok(())
@ -2838,12 +2936,11 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
let fallback_2fa_email =
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none()
} else {
false
};
let fallback_2fa_email = req.reset_two_factor
&& CONFIG.mail_enabled()
&& CONFIG.email_2fa_auto_fallback()
&& user.verified()
&& TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none();
// Check the new password before the email below, so that a rejected request doesn't tell the user
// their password was reset

11
src/api/core/sends.rs

@ -268,11 +268,12 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
let file_id = crate::crypto::generate_send_file_id();
let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() {
o.insert(String::from("id"), Value::String(file_id.clone()));
o.insert(String::from("size"), Value::Number(file_length.into()));
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
}
let Some(o) = data_value.as_object_mut() else {
err!("Invalid Send file data")
};
o.insert(String::from("id"), Value::String(file_id.clone()));
o.insert(String::from("size"), Value::Number(file_length.into()));
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
send.data = serde_json::to_string(&data_value)?;
send.save(&conn).await?;

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

@ -84,18 +84,26 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
)
}
} else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
err!("AuthRequest doesn't exist", "User not found")
};
let Some(code) = &data.auth_request_access_code else {
err!("no auth request access code")
};
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await
else {
err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user")
};
if auth_request.device_type != client_headers.device_type
let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5);
if !auth_request.approved.unwrap_or(false)
|| auth_request.authentication_date.is_some()
|| request_expired
|| auth_request.device_type != client_headers.device_type
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|| !auth_request.check_access_code(code)
{
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
err!(
"AuthRequest doesn't exist",
"Auth request not approved, already used or expired, or invalid device, IP or code"
)
}
} else if let Some(device_identifier) = &data.device_identifier {
// iOS/Android SSO logins send the email and device id but no password hash,

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

@ -151,7 +151,7 @@ pub async fn enforce_2fa_policy(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked,
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
&member.uuid,
&member.org_uuid,
act_user_id,
@ -185,8 +185,16 @@ pub async fn enforce_2fa_policy_for_org(
member.revoke();
member.save(conn).await?;
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
.await;
log_event(
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
&member.uuid,
org_id,
act_user_id,
device_type,
ip,
conn,
)
.await;
}
}

13
src/api/icons.rs

@ -264,7 +264,7 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
TAG_BASE => {
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
let href = std::str::from_utf8(href).unwrap_or_default();
debug!("Found base href: {href}");
debug!("Found base href: {}", href.escape_debug());
match base_url.join(href) {
Ok(inner_url) => inner_url,
_ => continue,
@ -663,6 +663,17 @@ impl reqwest::cookie::CookieStore for Jar {
use time::Duration;
let mut cookie_store = self.0.write().unwrap();
// The store only skips expired cookies when reading them, so remove them here or they are kept forever
let expired: Vec<_> = cookie_store
.iter_any()
.filter(|c| c.is_expired())
.map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned()))
.collect();
for (domain, path, name) in expired {
cookie_store.remove(&domain, &path, &name);
}
let cookies = cookie_headers.filter_map(|val| {
std::str::from_utf8(val.as_bytes())
.map_err(RawCookieParseError::from)

29
src/api/identity.rs

@ -46,6 +46,7 @@ pub fn routes() -> Vec<Route> {
post_prelogin,
prelogin_password,
register_verification_email,
register_verification_email_clicked,
register_finish,
prevalidate,
authorize,
@ -111,7 +112,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
.await?;
Ok(Json(tokens.to_json()))
}
t => err!("Invalid type", t),
t => err!("Invalid type", t.escape_debug().to_string()),
};
if let Some(user_id) = user_id {
@ -1034,6 +1035,13 @@ struct RegisterVerificationData {
// receiveMarketingEmails: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterVerificationEmailClickedData {
email: String,
email_verification_token: String,
}
#[derive(rocket::Responder)]
enum RegisterVerificationResponse {
#[response(status = 204)]
@ -1096,6 +1104,25 @@ async fn register_verification_email(
}
}
#[post("/accounts/register/verification-email-clicked", data = "<data>")]
async fn register_verification_email_clicked(
data: Json<RegisterVerificationEmailClickedData>,
conn: DbConn,
) -> EmptyResult {
let data = data.into_inner();
let token_valid = auth::decode_register_verify(&data.email_verification_token)
.is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email));
let user_exists = User::find_by_mail(&data.email, &conn)
.await
.is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some());
if !token_valid || user_exists {
err!("Expired link. Please restart registration or try logging in. You may already have an account")
}
Ok(())
}
#[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;

4
src/api/mod.rs

@ -25,8 +25,8 @@ pub use crate::api::{
notifications::routes as notifications_routes,
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
push::{
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
unregister_push_device,
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
register_push_device, unregister_push_device,
},
web::catchers as web_catchers,
web::routes as web_routes,

58
src/api/push.rs

@ -139,21 +139,25 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
if !CONFIG.push_enabled() || push_id.is_none() {
return Ok(());
}
let auth_api_token = get_auth_api_token().await?;
let data = json!({
"id": push_id.as_ref().unwrap(),
});
let auth_api_token = get_auth_api_token().await?;
let auth_header = format!("Bearer {auth_api_token}");
match make_http_request(
Method::POST,
&format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()),
)?
.header(AUTHORIZATION, auth_header)
.send()
.await
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))?
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.header(AUTHORIZATION, auth_header)
.json(&data)
.send()
.await?
.error_for_status()
{
Ok(r) => r,
Err(e) => err!(format!("An error occurred during device unregistration: {e}")),
};
err!(format!("An error occurred during device unregistration: {e}"));
}
Ok(())
}
@ -190,22 +194,28 @@ 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) {
if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid,
"organizationId": (),
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
"identifier": acting_device.map(|d| &d.uuid),
"type": UpdateType::LogOut as i32,
"payload": {
"userId": user.uuid,
"date": format_date(&user.updated_at)
},
"clientType": null,
"installationId": null
})));
push_logout_to_relay(user, acting_device);
}
}
// Sends the logout without checking for push devices first.
// 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>) {
tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid,
"organizationId": (),
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
"identifier": acting_device.map(|d| &d.uuid),
"type": UpdateType::LogOut as i32,
"payload": {
"userId": user.uuid,
"date": format_date(&user.updated_at)
},
"clientType": null,
"installationId": null
})));
}
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({

5
src/api/web.rs

@ -20,7 +20,7 @@ use crate::{
crypto::sha256_hex,
db::{
DbConn,
models::{AttachmentId, CipherId},
models::{AttachmentId, CipherId, custom_roles_enabled},
},
error::Error,
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
let css_options = json!({
"custom_roles_enabled": custom_roles_enabled(),
"email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true,
@ -332,6 +333,6 @@ pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error>
if let Some(file) = STATIC_FILES.get(filename) {
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
} else {
err!(format!("Static file not found: {filename}"))
err!(format!("Static file not found: {}", filename.escape_debug()))
}
}

6
src/auth.rs

@ -503,13 +503,17 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) ->
let time_now = Utc::now();
TwoFactorRememberClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()),
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
sub: device_uuid,
user_uuid,
}
}
fn two_factor_remember_expiration(time_now: DateTime<Utc>, remember_days: i64) -> i64 {
(time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BasicJwtClaims {
// Not before

6
src/auth/send.rs

@ -72,7 +72,11 @@ impl SendTokens {
conn: &DbConn,
) -> ApiResult<SendTokens> {
let Some(send_id) = Self::as_send_id(access_id) else {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
return Self::invalid_error(
&format!("Can't convert {}", access_id.escape_debug()),
"send_id_invalid",
false,
);
};
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {

35
src/config.rs

@ -261,6 +261,10 @@ macro_rules! make_config {
}
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 config_bytes = match operator.read(&CONFIG_FILENAME).await {
Ok(bytes) => bytes,
@ -603,6 +607,9 @@ make_config! {
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
sends_allowed: bool, true, def, true;
/// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory.
fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned();
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
hibp_api_key: Pass, true, option;
@ -733,6 +740,8 @@ make_config! {
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
/// Note that the checkbox would still be present, but ignored.
disable_2fa_remember: bool, true, def, false;
/// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90).
two_factor_remember_days: i64, true, def, 30;
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
/// TOTP codes of the previous and next 30 seconds will be invalid.
@ -812,6 +821,10 @@ make_config! {
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
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.
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
increase_note_size_limit: bool, true, def, false;
@ -1025,6 +1038,21 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
);
}
let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| {
url.scheme() == "https"
&& url.has_host()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none()
&& cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url
});
if !valid_fill_assist_rules_url {
err!(
"`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace."
);
}
let connect_src = cfg.allowed_connect_src.to_lowercase();
for url in connect_src.split_whitespace() {
if !url.starts_with("https://") || Url::parse(url).is_err() {
@ -1259,6 +1287,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
}
if !(1..=90).contains(&cfg.two_factor_remember_days) {
err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days")
}
// Validate schedule crontab format
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
@ -1479,6 +1511,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Autofill Team
"undetermined-cipher-scenario-logic",
"enable-basic-auth-response",
"fill-assist-targeting-rules",
"ssh-agent-v2",
"windows-desktop-autotype",
"windows-desktop-autotype-ga",
@ -1593,7 +1626,7 @@ impl Config {
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
let e: Vec<&str> = email.rsplitn(2, '@').collect();
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
warn!("Failed to parse email address '{email}'");
warn!("Failed to parse email address '{}'", email.escape_debug());
return false;
}
let email_domain = e[0].to_lowercase();

71
src/db/mod.rs

@ -534,3 +534,74 @@ mod postgresql_migrations {
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::sqlite_migrations::MIGRATIONS;
use diesel::connection::SimpleConnection;
use diesel_migrations::MigrationHarness;
#[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);
}
}

21
src/db/models/cipher.rs

@ -25,7 +25,7 @@ use macros::UuidFromParam;
use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, MembershipType, OrganizationId, User, UserId,
MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -520,7 +520,16 @@ impl Cipher {
conn: &DbConn,
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await
}
/// Same as `move_to_folder`, but the caller is responsible for updating the user's revision.
pub async fn move_to_folder_without_revision_update(
&self,
folder_uuid: Option<FolderId>,
user_uuid: &UserId,
conn: &DbConn,
) -> EmptyResult {
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
// No changes
(None, None) => Ok(()),
@ -863,7 +872,7 @@ impl Cipher {
if !visible_only {
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
);
}
@ -898,7 +907,7 @@ impl Cipher {
if !visible_only {
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
);
}
@ -1102,7 +1111,7 @@ impl Cipher {
.or(collections_groups::collections_uuid
.is_not_null() // Access via groups
.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
)
.select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn)
@ -1131,7 +1140,7 @@ impl Cipher {
.or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection
.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
)
.select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn)
@ -1173,7 +1182,7 @@ impl Cipher {
)
.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::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(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))

14
src/db/models/collection.rs

@ -18,7 +18,7 @@ use macros::UuidFromParam;
use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId,
User, UserId, organization::ORG_ADMIN_ATYPES,
};
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@ -375,7 +375,7 @@ impl Collection {
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// 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(
@ -412,7 +412,7 @@ impl Collection {
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// 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)
@ -455,7 +455,7 @@ impl Collection {
)
.filter(
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_collections::collection_uuid
.eq(&self.uuid) // write access given to collection
@ -489,7 +489,7 @@ impl Collection {
)
.filter(
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_collections::collection_uuid
.eq(&self.uuid) // write access given to collection
@ -540,7 +540,7 @@ impl Collection {
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// 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(
@ -600,7 +600,7 @@ impl Collection {
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// 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(

5
src/db/models/event.rs

@ -79,6 +79,7 @@ pub enum EventType {
CipherSoftDeleted = 1115,
CipherRestored = 1116,
CipherClientToggledCardNumberVisible = 1117,
CipherClientHttpAuthReleased = 1133,
// Collection
CollectionCreated = 1300,
@ -111,8 +112,8 @@ pub enum EventType {
// OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
OrganizationUserRevokedTwoFactorNonCompliance = 1520,
OrganizationUserRevokedSingleOrganizationNonCompliance = 1521,
OrganizationUserNotificationBannerActionClicked = 1522,
// Organization

6
src/db/models/mod.rs

@ -31,10 +31,10 @@ pub use self::event::{Event, EventType};
pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId,
CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
OrganizationApiKey, OrganizationId, custom_roles_enabled,
};
pub use self::send::{Send, SendFileId, SendId, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};

10
src/db/models/org_policy.rs

@ -53,6 +53,8 @@ pub enum OrgPolicyType {
// AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20,
// SendControls = 21, // Not supported yet
FillAssist = 22,
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
@ -71,6 +73,14 @@ pub struct ResetPasswordDataModel {
pub auto_enroll_enabled: bool,
}
// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FillAssistPolicyData {
#[serde(alias = "RulesUrl")]
pub rules_url: Option<String>,
}
/// Local methods
impl OrgPolicy {
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {

343
src/db/models/organization.rs

@ -1,4 +1,7 @@
use std::cmp::Ordering;
use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
};
use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
@ -21,8 +24,8 @@ use crate::{
use macros::UuidFromParam;
use super::{
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
TwoFactor, User, UserId,
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
OrgPolicyType, TwoFactor, User, UserId,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -41,6 +44,7 @@ pub struct Organization {
#[diesel(table_name = users_organizations)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")]
pub struct Membership {
pub uuid: MembershipId,
pub user_uuid: UserId,
@ -54,6 +58,17 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: 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,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -89,42 +104,68 @@ impl MembershipStatus {
}
}
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)]
pub enum MembershipType {
Owner = 0,
Admin = 1,
User = 2,
// The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead.
Manager = 3,
// Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled.
Custom = 4,
}
impl MembershipType {
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 {
"0" | "Owner" => Some(MembershipType::Owner),
"1" | "Admin" => Some(MembershipType::Admin),
"2" | "User" => Some(MembershipType::User),
"3" | "Manager" => Some(MembershipType::Manager),
// HACK: We convert the custom role to a manager role
"4" | "Custom" => Some(MembershipType::Manager),
"4" | "Custom" => Some(MembershipType::Custom),
_ => 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. An existing
/// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never
/// turns one into the other.
pub fn requested(
requested: MembershipType,
current: Option<&Membership>,
custom_roles_enabled: bool,
) -> MembershipType {
if requested != MembershipType::Custom {
return requested;
}
match current {
Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
_ if custom_roles_enabled => MembershipType::Custom,
_ => 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 {
fn cmp(&self, other: &MembershipType) -> Ordering {
// For easy comparison, map each variant to an access level (where 0 is lowest).
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])
self.rank().cmp(&other.rank())
}
}
@ -180,6 +221,91 @@ 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] = &[];
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>, crate::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 {
CONFIG.experimental_custom_roles()
}
/// Local methods
impl Organization {
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
@ -271,9 +397,92 @@ impl Membership {
atype: MembershipType::User as i32,
reset_password_key: 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,
}
}
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 without losing what they may do now, if they can't. A
/// conversion must not silently change their access, and a Custom role can't keep access to all collections
/// (which also puts every collection in the Manager's own vault), nor manage any collection yet.
pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> {
if !self.has_type(MembershipType::Manager) {
return None;
}
if self.access_all {
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 first, \
or keep the Manager role.",
);
}
// Whatever their status, the collections they manage directly, through a group or through a group with
// access to all collections
let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await
|| CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.iter()
.any(|assignment| assignment.manage);
for group_user in GroupUser::find_by_member(&self.uuid, conn).await {
if manages {
break;
}
manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn)
.await
.iter()
.any(|assignment| assignment.manage);
}
manages.then_some(
"This Manager manages collections, with the manage permission or through a group with access to all \
collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.",
)
}
pub fn restore(&mut self) -> bool {
if self.status < MembershipStatus::Invited as i32 {
self.status += ACTIVATE_REVOKE_DIFF;
@ -712,6 +921,12 @@ impl Membership {
}
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;
db_run! { conn:
@ -1228,13 +1443,93 @@ pub struct OrgApiKeyId(String);
#[cfg(test)]
mod tests {
use super::*;
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]
#[allow(non_snake_case)]
fn partial_cmp_MembershipType() {
assert!(MembershipType::Owner > MembershipType::Admin);
assert!(MembershipType::Admin > MembershipType::Manager);
assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
fn membership_type_authority() {
assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
for raw in [99, -1] {
assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
}
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() {
for (current, disabled, enabled) in [
(None, Manager, Custom),
(Some(User), Manager, Custom),
(Some(Manager), Manager, Manager),
(Some(Custom), Custom, Custom),
] {
let current = current.map(member);
for (flag, expected) in [(false, disabled), (true, enabled)] {
assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected);
}
}
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User);
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User);
for requested in [Owner, Admin, Manager] {
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), 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, []);
}
#[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);
}
}
}

1
src/db/models/send.rs

@ -334,7 +334,6 @@ impl Send {
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
#[derive(serde::Deserialize)]
struct FileData {
#[serde(rename = "size", alias = "Size")]
size: NumberOrString,
}

4
src/db/models/user.rs

@ -362,7 +362,7 @@ impl User {
}
}
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
if member.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
@ -385,7 +385,7 @@ impl User {
Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| {
diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user")
diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user")
})
.await
}

9
src/db/schema.rs

@ -244,6 +244,15 @@ table! {
atype -> Integer,
reset_password_key -> 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,
}
}

2
src/mail.rs

@ -206,7 +206,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
let mut query = url::Url::parse("https://query.builder").unwrap();
query.query_pairs_mut().append_pair("email", email).append_pair("token", token);
query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true");
let Some(query_string) = query.query() else {
err!("Failed to build verify URL query parameters")
};

10
src/sso.rs

@ -266,8 +266,8 @@ pub async fn exchange_code(
(_, Some(re)) => {
let error_msg = format!(
"SSO authorization failed: {}, {}",
re.error,
re.error_description.as_ref().unwrap_or(&String::new())
re.error.escape_debug(),
re.error_description.as_deref().unwrap_or_default().escape_debug()
);
sso_auth.delete(conn).await?;
err!(error_msg);
@ -348,7 +348,11 @@ pub async fn exchange_code(
user_name: user_name.clone(),
};
debug!("Authenticated user {authenticated_user:?}");
if CONFIG.sso_debug_tokens() {
debug!("Authenticated user {authenticated_user:?}");
} else {
debug!("Authenticated user {identifier}");
}
sso_auth.auth_response = Some(authenticated_user.clone());
sso_auth.updated_at = Utc::now().naive_utc();
sso_auth.save(conn).await?;

2
src/sso_client.rs

@ -260,7 +260,7 @@ impl Client {
};
if CONFIG.sso_debug_tokens() {
debug!("Id token: {}", id_token.to_string());
debug!("Id token: {id_token}");
debug!("Access token: {}", token_response.access_token().secret());
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
debug!("Expiration time: {:?}", token_response.expires_in());

16
src/static/scripts/admin_users.js

@ -173,10 +173,14 @@ const ORG_TYPES = {
"name": "User",
"bg": "blue"
},
"4": {
"3": {
"name": "Manager",
"bg": "green"
},
"4": {
"name": "Custom",
"bg": "teal"
},
};
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
@ -187,15 +191,19 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
// Get org specific values
const userOrgType = event.relatedTarget.dataset.vwOrgType;
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
const orgName = event.relatedTarget.dataset.vwOrgName;
const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
document.getElementById("userOrgTypeForm").reset();
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
document.getElementById("userOrgTypeUserUuid").value = userUuid;
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);
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
@ -222,7 +230,7 @@ function updateUserOrgType(event) {
function initUserTable() {
// Color all the org buttons per type
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;
if (orgType.font !== undefined) {
e.style.color = orgType.font;

5
src/static/templates/admin/users.hbs

@ -135,6 +135,11 @@
<div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label>
</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">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>
</div>

7
src/static/templates/scss/vaultwarden.scss.hbs

@ -98,10 +98,17 @@ app-security > app-two-factor-setup > form {
}
/* Hide unsupported Custom Role options */
{{#if custom_roles_enabled}}
: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"]) {
@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-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide;
}
{{/if}}
/* Change collapsed menu icon to Vaultwarden */
bit-nav-logo bit-nav-item a:before {

44
src/util.rs

@ -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
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
pub struct BetterLogging {
extra: bool,
@ -381,8 +395,9 @@ impl Fairing for BetterLogging {
};
match uri.query() {
Some(q) => {
let q = q.as_str();
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug());
let query = redact_query_values(q.as_str());
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()),
}
@ -1027,3 +1042,28 @@ 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}="));
}
}

Loading…
Cancel
Save