Browse Source

Merge branch 'main' into main

pull/7566/head
kittygaming99 5 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 # HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
## Client Settings ## 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. ## Enable experimental feature flags for clients.
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3". ## 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! ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
## ##
## The following flags are available: ## The following flags are available:
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0) ## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0) ## - "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) ## - "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. ## 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-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) ## - "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) ## - "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) ## - "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) ## - "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= # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
@ -489,6 +497,15 @@
## KNOW WHAT YOU ARE DOING! ## KNOW WHAT YOU ARE DOING!
# ORG_GROUPS_ENABLED=false # ORG_GROUPS_ENABLED=false
## EXPERIMENTAL FEATURE: Custom roles
## Enables the Custom member role with granular permissions for organizations.
## This is still being developed: only the permissions this version enforces can be granted.
## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS.
## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled.
## Disabled by default because this is an experimental feature!
## KNOW WHAT YOU ARE DOING!
# EXPERIMENTAL_CUSTOM_ROLES=false
## Increase secure note size limit (Know the risks!) ## Increase secure note size limit (Know the risks!)
## Sets the secure note size limit to 100_000 instead of the default 10_000. ## Sets the secure note size limit to 100_000 instead of the default 10_000.
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
@ -606,6 +623,9 @@
## Note that the checkbox would still be present, but ignored. ## Note that the checkbox would still be present, but ignored.
# DISABLE_2FA_REMEMBER=false # DISABLE_2FA_REMEMBER=false
## ##
## Number of days before a remembered 2FA login expires (min: 1, max: 90).
# TWO_FACTOR_REMEMBER_DAYS=30
##
## Authenticator Settings ## Authenticator Settings
## Disable authenticator time drifted codes to be valid. ## Disable authenticator time drifted codes to be valid.
## TOTP codes of the previous and next 30 seconds will be invalid ## 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" } macros = { path = "./macros" }
# Logging # Logging
log = "0.4.34" log = "0.4.35"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } 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 # 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"] } 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" hickory-resolver = "0.26.3"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.4" html5gum = "0.9.0"
regex = { version = "1.13.1", default-features = false, features = [ regex = { version = "1.13.1", default-features = false, features = [
"perf", "perf",
"std", "std",
@ -215,7 +215,7 @@ bytes = "1.12.1"
svg-hush = "0.9.7" svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching) # 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 # Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.2" cookie = "0.18.2"
@ -235,7 +235,7 @@ governor = "0.10.4"
ipnet = "2.12.2" ipnet = "2.12.2"
# OIDC for SSO # 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"] } moka = { version = "0.12.16", features = ["future"] }
# Check client versions for specific features. # Check client versions for specific features.
@ -268,7 +268,7 @@ aws-config = { version = "1.13.0", optional = true, default-features = false, fe
"sso", "sso",
] } ] }
aws-credential-types = { version = "1.3.1", optional = true } 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 } http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true } reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true } reqsign-core = { version = "3.3.2", optional = true }

4
docker/DockerSettings.yaml

@ -1,6 +1,6 @@
--- ---
vault_version: "v2026.8.0" vault_version: "v2026.10.1"
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a" vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52"
# Cross Compile Docker Helper Scripts v1.10.0 # 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 # 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 # 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, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] # [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
# [docker.io/vaultwarden/web-vault:v2026.8.0] # [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 ########################## ########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## 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, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 # $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] # [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
# [docker.io/vaultwarden/web-vault:v2026.8.0] # [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 ########################## ########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash 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 reqwest::Method;
use rocket::{ use rocket::{
@ -17,7 +17,7 @@ use crate::{
api::{ api::{
ApiResult, EmptyResult, JsonResult, Notify, ApiResult, EmptyResult, JsonResult, Notify,
core::{log_event, two_factor}, 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}, auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
config::ConfigBuilder, config::ConfigBuilder,
@ -26,6 +26,7 @@ use crate::{
models::{ models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
custom_roles_enabled,
}, },
}, },
error::{Error, MapResult}, error::{Error, MapResult},
@ -256,6 +257,7 @@ struct AdminTemplateData {
logged_in: bool, logged_in: bool,
urlpath: String, urlpath: String,
sso_enabled: bool, sso_enabled: bool,
custom_roles_enabled: bool,
} }
impl AdminTemplateData { impl AdminTemplateData {
@ -266,6 +268,7 @@ impl AdminTemplateData {
logged_in: true, logged_in: true,
urlpath: CONFIG.domain_path(), urlpath: CONFIG.domain_path(),
sso_enabled: CONFIG.sso_enabled(), sso_enabled: CONFIG.sso_enabled(),
custom_roles_enabled: custom_roles_enabled(),
} }
} }
@ -390,6 +393,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string())); usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
.await
.into_iter()
.map(|member| (member.org_uuid.to_string(), member.atype))
.collect();
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
org["type"] = json!(atype);
}
}
users_json.push(usr); users_json.push(usr);
} }
@ -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")] #[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?; 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 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; 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 { for membership in memberships {
log_event( log_event(
EventType::OrganizationUserDeleted, 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") err!("The specified user isn't member of the organization")
}; };
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32 // Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
} else { // are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
err!("Invalid type") Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
MembershipType::Manager
}
Some(new_type) => new_type,
None => err!("Invalid type"),
}; };
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
@ -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 // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;

44
src/api/core/accounts.rs

@ -13,7 +13,7 @@ use crate::{
api::{ api::{
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{accept_org_invite, log_user_event, two_factor::email}, 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}, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
crypto, crypto,
@ -1148,7 +1148,11 @@ async fn post_email_token(data: Json<EmailTokenData>, headers: Headers, conn: Db
error!("Error sending change-email email: {e:#?}"); error!("Error sending change-email email: {e:#?}");
} }
} else { } 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); user.email_new = Some(data.new_email);
@ -1301,7 +1305,7 @@ struct DeleteRecoverTokenData {
} }
#[post("/accounts/delete-recover-token", data = "<data>")] #[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 data: DeleteRecoverTokenData = data.into_inner();
let Ok(claims) = decode_delete(&data.token) else { 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 { if claims.sub != *user.uuid {
err!("Invalid claim"); 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>")] #[post("/accounts/delete", data = "<data>")]
async fn post_delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult { async fn post_delete_account(
delete_account(data, headers, conn).await data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
delete_account(data, headers, conn, nt).await
} }
#[delete("/accounts", data = "<data>")] #[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 data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
data.validate(&user, true, &conn).await?; 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")] #[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?; mail::send_password_hint(email, hint).await?;
Ok(()) Ok(())
} else if let Some(hint) = hint { } else if let Some(hint) = hint {
err!(format!("Your password hint is: {hint}")); err_silent!(format!("Your password hint is: {hint}"));
} else { } else {
err!(NO_HINT); err!(NO_HINT);
} }

165
src/api/core/ciphers.rs

@ -13,7 +13,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, 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::ClientVersion,
auth::{Headers, OrgIdGuard, OwnerHeaders}, auth::{Headers, OrgIdGuard, OwnerHeaders},
config::PathType, config::PathType,
@ -23,7 +23,7 @@ use crate::{
models::{ models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, 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}, 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, "%+") { match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format // 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 => { 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.") 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. // A missing attachment means it was removed via an other client.
// Also the Desktop Client supports removing attachments and save an update afterwards. // Also the Desktop Client supports removing attachments and save an update afterwards.
// Bitwarden it self ignores these mismatches server side. // Bitwarden it self ignores these mismatches server side.
warn!("Attachment {id} doesn't exist"); warn!("Attachment {} doesn't exist", id.escape_debug());
continue; continue;
}; };
@ -580,7 +580,7 @@ pub async fn update_cipher_from_data(
match data.archived_date { match data.archived_date {
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") { Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?, 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?, 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() { 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()); let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id; 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()); 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?; 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, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> 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 put_cipher(cipher_id, data, headers, conn, nt).await
} }
@ -698,7 +706,7 @@ async fn post_cipher_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> 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>")] #[post("/ciphers/<cipher_id>", data = "<data>")]
@ -735,6 +743,11 @@ async fn put_cipher(
err!("Cipher is not write accessible") 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?; 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?)) 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, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
let data: ShareCipherData = data.into_inner(); put_cipher_share(cipher_id, data, headers, conn, nt).await
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
} }
#[put("/ciphers/<cipher_id>/share", data = "<data>")] #[put("/ciphers/<cipher_id>/share", data = "<data>")]
@ -1008,6 +1019,14 @@ async fn put_cipher_share(
) -> JsonResult { ) -> JsonResult {
let data: ShareCipherData = data.into_inner(); 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 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() { if cipher.id.is_none() {
err!("Request missing ids field") 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()); let mut responses = Vec::with_capacity(data.ciphers.len());
@ -1214,6 +1236,40 @@ enum FileUploadType {
// Azure = 1, // only used upstream // 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. /// v2 API for creating an attachment associated with a cipher.
/// This redirects the client to the API it should use to upload the attachment. /// 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. /// 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>, data: Json<AttachmentRequestData>,
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
@ -1243,10 +1300,18 @@ async fn post_attachment_v2(
if file_size > SIZE_525_MB { if file_size > SIZE_525_MB {
err!("Max file size is 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_id = crypto::generate_attachment_id();
let attachment = let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key)); Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
attachment.save(&conn).await.expect("Error saving attachment"); 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 url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
let response_key = match data.admin_request { let response_key = match data.admin_request {
@ -1309,55 +1374,7 @@ async fn save_attachment(
Some(a) => a.file_size, // v2 API Some(a) => a.file_size, // v2 API
}; };
let size_limit = if let Some(ref user_id) = cipher.user_uuid { if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await?
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
&& size > size_limit && size > size_limit
{ {
err!("Attachment storage limit exceeded with this file"); 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?; 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( nt.send_cipher_update(
UpdateType::SyncCipherUpdate, UpdateType::SyncCipherUpdate,
&cipher, cipher,
&cipher.update_users_revision(&conn).await, &cipher.update_users_revision(conn).await,
&headers.device, &headers.device,
None, None,
&conn, conn,
) )
.await; .await;
@ -1432,12 +1459,10 @@ async fn save_attachment(
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
&conn, conn,
) )
.await; .await;
} }
Ok((cipher, conn))
} }
/// v2 API for uploading the actual data content of an attachment. /// 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 // 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 = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
let accessible_ciphers_count = accessible_ciphers.len(); let accessible_ciphers_count = accessible_ciphers.len();
let mut move_result = Ok(());
for cipher in accessible_ciphers { 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 { if cipher_count == 1 {
single_cipher = Some(cipher); 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 { if let Some(cipher) = single_cipher {
nt.send_cipher_update( 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; .await;
} }
} }
// Client-side cipher events (viewed, copied, toggled, autofilled). // Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth).
1107..=1114 | 1117 | 1119..=1132 => { 1107..=1114 | 1117 | 1119..=1133 => {
// The cipher determines the organization the event is logged to, so make sure the // 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. // user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id if let Some(cipher_uuid) = &event.cipher_id

1
src/api/core/mod.rs

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

173
src/api/core/organizations.rs

@ -15,10 +15,10 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
TwoFactorType, User, UserId, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
}, },
}, },
mail, mail,
@ -131,13 +131,16 @@ impl FullCollectionData {
let org_groups = Group::find_by_organization(org_id, conn).await; 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(); 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)) { 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_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); 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)) { 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(()) Ok(())
@ -552,7 +555,7 @@ async fn post_bulk_access_collections(
let org_groups = Group::find_by_organization(&org_id, &conn).await; 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(); 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)) { 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 { 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_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); 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)) { 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_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); 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)) { 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(()) Ok(())
@ -1002,14 +1008,12 @@ async fn send_invite(
data.validate(&org_id, &conn).await?; 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 // 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(); let raw_type = &data.r#type.into_string();
// Membership::from_str will convert custom (4) to manager (3) let Some(requested_type) = MembershipType::from_str(raw_type) else {
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
new_type as i32
} else {
err!("Invalid type") err!("Invalid type")
}; };
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled());
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can invite Managers, Admins or Owners") 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 // 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 // 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 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("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == 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; let mut user_created: bool;
for email in &data.emails { for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32; 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())); 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.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.status = member_status;
new_member.save(&conn).await?; new_member.save(&conn).await?;
@ -1156,7 +1171,10 @@ async fn bulk_reinvite_members(
for member_id in data.ids { for member_id in data.ids {
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
Ok(()) => String::new(), 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!( bulk_response.push(json!(
@ -1329,7 +1347,10 @@ async fn bulk_confirm_invite(
let user_key = invite.key.unwrap_or_default(); 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 { let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(), 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!( bulk_response.push(json!(
@ -1496,24 +1517,32 @@ async fn edit_member(
let data: EditUserData = data.into_inner(); 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 // 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(); let raw_type = &data.r#type.into_string();
// MembershipType::from_str will convert custom (4) to manager (3) let Some(requested_type) = MembershipType::from_str(raw_type) else {
let Some(new_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type") 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 // 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 // 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 // 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 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("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == 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 { // While Custom roles are disabled, a Custom member keeps their stored permissions untouched
err!("The specified user isn't member of the organization") 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 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.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 // 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. // 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 { for member_id in data.ids {
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await { let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
Ok(()) => String::new(), 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!( bulk_response.push(json!(
@ -2039,6 +2074,29 @@ async fn put_policy(
err!("Invalid or unsupported policy type") 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 // 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 // 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. // 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 // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
two_factor::enforce_2fa_policy_for_org( two_factor::enforce_2fa_policy_for_org(
@ -2102,7 +2191,7 @@ async fn put_policy(
} }
log_event( log_event(
EventType::OrganizationUserRemoved, EventType::OrganizationUserRevokedSingleOrganizationNonCompliance,
&member.uuid, &member.uuid,
&org_id, &org_id,
&headers.user.uuid, &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, Some(p) => p,
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), 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 { for member_id in members {
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await { let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
Ok(()) => String::new(), 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!( bulk_response.push(json!(
@ -2325,7 +2417,10 @@ async fn bulk_restore_members(
for member_id in data.ids { for member_id in data.ids {
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
Ok(()) => String::new(), 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!( bulk_response.push(json!(
@ -2478,13 +2573,16 @@ impl GroupRequest {
let org_collections = Collection::find_by_organization(org_id, conn).await; 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(); 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)) { 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_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); 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)) { 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(()) Ok(())
@ -2838,12 +2936,11 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality"); err!("Organization user must be confirmed for password reset functionality");
} }
let fallback_2fa_email = let fallback_2fa_email = req.reset_two_factor
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() { && CONFIG.mail_enabled()
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none() && CONFIG.email_2fa_auto_fallback()
} else { && user.verified()
false && 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 // Check the new password before the email below, so that a rejected request doesn't tell the user
// their password was reset // 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 file_id = crate::crypto::generate_send_file_id();
let mut data_value: Value = serde_json::from_str(&send.data)?; let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() { let Some(o) = data_value.as_object_mut() else {
o.insert(String::from("id"), Value::String(file_id.clone())); err!("Invalid Send file data")
o.insert(String::from("size"), Value::Number(file_length.into())); };
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); 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.data = serde_json::to_string(&data_value)?;
send.save(&conn).await?; 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 { } 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 { let Some(code) = &data.auth_request_access_code else {
err!("no auth request access code") 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.request_ip != client_headers.ip.ip.to_string()
|| !auth_request.check_access_code(code) || !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 { } else if let Some(device_identifier) = &data.device_identifier {
// iOS/Android SSO logins send the email and device id but no password hash, // 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?; member.save(conn).await?;
log_event( log_event(
EventType::OrganizationUserRevoked, EventType::OrganizationUserRevokedTwoFactorNonCompliance,
&member.uuid, &member.uuid,
&member.org_uuid, &member.org_uuid,
act_user_id, act_user_id,
@ -185,8 +185,16 @@ pub async fn enforce_2fa_policy_for_org(
member.revoke(); member.revoke();
member.save(conn).await?; member.save(conn).await?;
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn) log_event(
.await; 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 => { TAG_BASE => {
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) { base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
let href = std::str::from_utf8(href).unwrap_or_default(); 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) { match base_url.join(href) {
Ok(inner_url) => inner_url, Ok(inner_url) => inner_url,
_ => continue, _ => continue,
@ -663,6 +663,17 @@ impl reqwest::cookie::CookieStore for Jar {
use time::Duration; use time::Duration;
let mut cookie_store = self.0.write().unwrap(); 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| { let cookies = cookie_headers.filter_map(|val| {
std::str::from_utf8(val.as_bytes()) std::str::from_utf8(val.as_bytes())
.map_err(RawCookieParseError::from) .map_err(RawCookieParseError::from)

29
src/api/identity.rs

@ -46,6 +46,7 @@ pub fn routes() -> Vec<Route> {
post_prelogin, post_prelogin,
prelogin_password, prelogin_password,
register_verification_email, register_verification_email,
register_verification_email_clicked,
register_finish, register_finish,
prevalidate, prevalidate,
authorize, authorize,
@ -111,7 +112,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
.await?; .await?;
Ok(Json(tokens.to_json())) 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 { if let Some(user_id) = user_id {
@ -1034,6 +1035,13 @@ struct RegisterVerificationData {
// receiveMarketingEmails: bool, // receiveMarketingEmails: bool,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterVerificationEmailClickedData {
email: String,
email_verification_token: String,
}
#[derive(rocket::Responder)] #[derive(rocket::Responder)]
enum RegisterVerificationResponse { enum RegisterVerificationResponse {
#[response(status = 204)] #[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>")] #[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult { async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; 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::routes as notifications_routes,
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
push::{ push::{
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
unregister_push_device, register_push_device, unregister_push_device,
}, },
web::catchers as web_catchers, web::catchers as web_catchers,
web::routes as web_routes, 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() { if !CONFIG.push_enabled() || push_id.is_none() {
return Ok(()); 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}"); let auth_header = format!("Bearer {auth_api_token}");
match make_http_request( if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))?
Method::POST, .header(CONTENT_TYPE, "application/json")
&format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()), .header(ACCEPT, "application/json")
)? .header(AUTHORIZATION, auth_header)
.header(AUTHORIZATION, auth_header) .json(&data)
.send() .send()
.await .await?
.error_for_status()
{ {
Ok(r) => r, err!(format!("An error occurred during device unregistration: {e}"));
Err(e) => err!(format!("An error occurred during device unregistration: {e}")), }
};
Ok(()) 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) { pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await { if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ push_logout_to_relay(user, acting_device);
"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
})));
} }
} }
// 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) { 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 { if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({

5
src/api/web.rs

@ -20,7 +20,7 @@ use crate::{
crypto::sha256_hex, crypto::sha256_hex,
db::{ db::{
DbConn, DbConn,
models::{AttachmentId, CipherId}, models::{AttachmentId, CipherId, custom_roles_enabled},
}, },
error::Error, error::Error,
util::{Cached, EtagCached}, util::{Cached, EtagCached},
@ -90,6 +90,7 @@ fn vaultwarden_css() -> EtagCached<String> {
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({ let css_options = json!({
"custom_roles_enabled": custom_roles_enabled(),
"email_change_allowed": CONFIG.email_change_allowed(), "email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true, "load_user_scss": true,
@ -332,6 +333,6 @@ pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error>
if let Some(file) = STATIC_FILES.get(filename) { if let Some(file) = STATIC_FILES.get(filename) {
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag)) Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
} else { } 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(); let time_now = Utc::now();
TwoFactorRememberClaims { TwoFactorRememberClaims {
nbf: time_now.timestamp(), 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(), iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
sub: device_uuid, sub: device_uuid,
user_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)] #[derive(Debug, Serialize, Deserialize)]
pub struct BasicJwtClaims { pub struct BasicJwtClaims {
// Not before // Not before

6
src/auth/send.rs

@ -72,7 +72,11 @@ impl SendTokens {
conn: &DbConn, conn: &DbConn,
) -> ApiResult<SendTokens> { ) -> ApiResult<SendTokens> {
let Some(send_id) = Self::as_send_id(access_id) else { 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 { 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> { async fn from_file() -> Result<Option<Self>, Error> {
// The Fs operator would create the folder if it doesn't exist, which we never want
if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() {
return Ok(None);
}
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let config_bytes = match operator.read(&CONFIG_FILENAME).await { let config_bytes = match operator.read(&CONFIG_FILENAME).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
@ -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. /// 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; 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 |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
hibp_api_key: Pass, true, option; 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. /// 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. /// Note that the checkbox would still be present, but ignored.
disable_2fa_remember: bool, true, def, false; 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 /// 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. /// 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!). /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
org_groups_enabled: bool, false, def, false; org_groups_enabled: bool, false, def, false;
/// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations.
/// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled.
experimental_custom_roles: bool, false, def, false;
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
increase_note_size_limit: bool, true, def, false; increase_note_size_limit: bool, true, def, false;
@ -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(); let connect_src = cfg.allowed_connect_src.to_lowercase();
for url in connect_src.split_whitespace() { for url in connect_src.split_whitespace() {
if !url.starts_with("https://") || Url::parse(url).is_err() { 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") 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 // Validate schedule crontab format
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() { 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") err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
@ -1479,6 +1511,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Autofill Team // Autofill Team
"undetermined-cipher-scenario-logic", "undetermined-cipher-scenario-logic",
"enable-basic-auth-response", "enable-basic-auth-response",
"fill-assist-targeting-rules",
"ssh-agent-v2", "ssh-agent-v2",
"windows-desktop-autotype", "windows-desktop-autotype",
"windows-desktop-autotype-ga", "windows-desktop-autotype-ga",
@ -1593,7 +1626,7 @@ impl Config {
pub fn is_email_domain_allowed(&self, email: &str) -> bool { pub fn is_email_domain_allowed(&self, email: &str) -> bool {
let e: Vec<&str> = email.rsplitn(2, '@').collect(); let e: Vec<&str> = email.rsplitn(2, '@').collect();
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() { 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; return false;
} }
let email_domain = e[0].to_lowercase(); let email_domain = e[0].to_lowercase();

71
src/db/mod.rs

@ -534,3 +534,74 @@ mod postgresql_migrations {
Ok(()) Ok(())
} }
} }
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
#[cfg(all(test, sqlite))]
pub mod test_db {
use super::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::{ use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, MembershipType, OrganizationId, User, UserId, MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -520,7 +520,16 @@ impl Cipher {
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await; 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) { match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
// No changes // No changes
(None, None) => Ok(()), (None, None) => Ok(()),
@ -863,7 +872,7 @@ impl Cipher {
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
); );
} }
@ -898,7 +907,7 @@ impl Cipher {
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
); );
} }
@ -1102,7 +1111,7 @@ impl Cipher {
.or(collections_groups::collections_uuid .or(collections_groups::collections_uuid
.is_not_null() // Access via groups .is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false))) .and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@ -1131,7 +1140,7 @@ impl Cipher {
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .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_collections::user_uuid.eq(user_uuid)) // User has access to collection
.or_filter(users_organizations::access_all.eq(true)) // User has access all .or_filter(users_organizations::access_all.eq(true)) // User has access all
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group .or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))

14
src/db/models/collection.rs

@ -18,7 +18,7 @@ use macros::UuidFromParam;
use super::{ use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId, User, UserId, organization::ORG_ADMIN_ATYPES,
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@ -375,7 +375,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@ -412,7 +412,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
)) ))
.select(collections::all_columns) .select(collections::all_columns)
@ -455,7 +455,7 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::access_all.eq(true)) // access_all via membership
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
@ -489,7 +489,7 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::access_all.eq(true)) // access_all via membership
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
@ -540,7 +540,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@ -600,7 +600,7 @@ impl Collection {
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::access_all.eq(true).or(
// access_all in Organization // access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(

5
src/db/models/event.rs

@ -79,6 +79,7 @@ pub enum EventType {
CipherSoftDeleted = 1115, CipherSoftDeleted = 1115,
CipherRestored = 1116, CipherRestored = 1116,
CipherClientToggledCardNumberVisible = 1117, CipherClientToggledCardNumberVisible = 1117,
CipherClientHttpAuthReleased = 1133,
// Collection // Collection
CollectionCreated = 1300, CollectionCreated = 1300,
@ -111,8 +112,8 @@ pub enum EventType {
// OrganizationUserAutomaticallyConfirmed = 1517, // OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519, OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520, OrganizationUserRevokedTwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, OrganizationUserRevokedSingleOrganizationNonCompliance = 1521,
OrganizationUserNotificationBannerActionClicked = 1522, OrganizationUserNotificationBannerActionClicked = 1522,
// Organization // 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::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
pub use self::organization::{ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
OrganizationId, OrganizationApiKey, OrganizationId, custom_roles_enabled,
}; };
pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::send::{Send, SendFileId, SendId, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};

10
src/db/models/org_policy.rs

@ -53,6 +53,8 @@ pub enum OrgPolicyType {
// AutoConfirm = 18, // Not supported (not implemented yet) // AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20, 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 // 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, 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 /// Local methods
impl OrgPolicy { impl OrgPolicy {
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { 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 chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
@ -21,8 +24,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
TwoFactor, User, UserId, OrgPolicyType, TwoFactor, User, UserId,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -41,6 +44,7 @@ pub struct Organization {
#[diesel(table_name = users_organizations)] #[diesel(table_name = users_organizations)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))] #[diesel(primary_key(uuid))]
#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")]
pub struct Membership { pub struct Membership {
pub uuid: MembershipId, pub uuid: MembershipId,
pub user_uuid: UserId, pub user_uuid: UserId,
@ -54,6 +58,17 @@ pub struct Membership {
pub atype: i32, pub atype: i32,
pub reset_password_key: Option<String>, pub reset_password_key: Option<String>,
pub external_id: Option<String>, pub external_id: Option<String>,
// The permissions of a Custom role, only accessed through `CustomPermission`.
manage_users: bool,
manage_groups: bool,
manage_policies: bool,
create_new_collections: bool,
edit_any_collection: bool,
delete_any_collection: bool,
access_event_logs: bool,
access_import_export: bool,
access_reports: bool,
} }
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[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 { pub enum MembershipType {
Owner = 0, Owner = 0,
Admin = 1, Admin = 1,
User = 2, User = 2,
// The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead.
Manager = 3, Manager = 3,
// Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled.
Custom = 4,
} }
impl MembershipType { impl MembershipType {
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
#[expect(
clippy::match_same_arms,
reason = "Specifically define `4|Custom` since this is a hack, not a default"
)]
match s { match s {
"0" | "Owner" => Some(MembershipType::Owner), "0" | "Owner" => Some(MembershipType::Owner),
"1" | "Admin" => Some(MembershipType::Admin), "1" | "Admin" => Some(MembershipType::Admin),
"2" | "User" => Some(MembershipType::User), "2" | "User" => Some(MembershipType::User),
"3" | "Manager" => Some(MembershipType::Manager), "3" | "Manager" => Some(MembershipType::Manager),
// HACK: We convert the custom role to a manager role "4" | "Custom" => Some(MembershipType::Custom),
"4" | "Custom" => Some(MembershipType::Manager),
_ => None, _ => None,
} }
} }
/// The role a member invite or edit from the Bitwarden clients asks for, given the member's current
/// role (`None` for an invite).
///
/// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. 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 { impl Ord for MembershipType {
fn cmp(&self, other: &MembershipType) -> Ordering { fn cmp(&self, other: &MembershipType) -> Ordering {
// For easy comparison, map each variant to an access level (where 0 is lowest). self.rank().cmp(&other.rank())
const ACCESS_LEVEL: [i32; 4] = [
3, // Owner
2, // Admin
0, // User
1, // Manager && Custom
];
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
} }
} }
@ -180,6 +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 /// Local methods
impl Organization { impl Organization {
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self { pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
@ -271,9 +397,92 @@ impl Membership {
atype: MembershipType::User as i32, atype: MembershipType::User as i32,
reset_password_key: None, reset_password_key: None,
external_id: None, external_id: None,
manage_users: false,
manage_groups: false,
manage_policies: false,
create_new_collections: false,
edit_any_collection: false,
delete_any_collection: false,
access_event_logs: false,
access_import_export: false,
access_reports: false,
}
}
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 { pub fn restore(&mut self) -> bool {
if self.status < MembershipStatus::Invited as i32 { if self.status < MembershipStatus::Invited as i32 {
self.status += ACTIVATE_REVOKE_DIFF; self.status += ACTIVATE_REVOKE_DIFF;
@ -712,6 +921,12 @@ impl Membership {
} }
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
// The legacy `access_all` flag reaches every collection, which a Custom role may only do through its
// permissions, so it must never be stored on one. `set_type` takes care of this.
if self.has_type(MembershipType::Custom) && self.access_all {
err!("A Custom role membership can't have access to all collections")
}
User::update_uuid_revision(&self.user_uuid, conn).await; User::update_uuid_revision(&self.user_uuid, conn).await;
db_run! { conn: db_run! { conn:
@ -1228,13 +1443,93 @@ pub struct OrgApiKeyId(String);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
#[allow(non_snake_case)] fn membership_type_authority() {
fn partial_cmp_MembershipType() { assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
assert!(MembershipType::Owner > MembershipType::Admin); assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
assert!(MembershipType::Admin > MembershipType::Manager); for raw in [99, -1] {
assert!(MembershipType::Manager > MembershipType::User); assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); }
for (value, expected) in [
("3", Some(Manager)),
("Manager", Some(Manager)),
("4", Some(Custom)),
("Custom", Some(Custom)),
("5", None),
] {
assert_eq!(MembershipType::from_str(value), expected);
}
}
#[test]
fn requested_membership_type() {
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> { pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct FileData { struct FileData {
#[serde(rename = "size", alias = "Size")]
size: NumberOrString, 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 { for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
if member.atype == MembershipType::Owner if member.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1 && 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 Invitation::take(&self.email, conn).await; // Delete invitation if any
conn.run(move |conn| { 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 .await
} }

9
src/db/schema.rs

@ -244,6 +244,15 @@ table! {
atype -> Integer, atype -> Integer,
reset_password_key -> Nullable<Text>, reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
manage_users -> Bool,
manage_groups -> Bool,
manage_policies -> Bool,
create_new_collections -> Bool,
edit_any_collection -> Bool,
delete_any_collection -> Bool,
access_event_logs -> Bool,
access_import_export -> Bool,
access_reports -> Bool,
} }
} }

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 { pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
let mut query = url::Url::parse("https://query.builder").unwrap(); 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 { let Some(query_string) = query.query() else {
err!("Failed to build verify URL query parameters") err!("Failed to build verify URL query parameters")
}; };

10
src/sso.rs

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

2
src/sso_client.rs

@ -260,7 +260,7 @@ impl Client {
}; };
if CONFIG.sso_debug_tokens() { 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!("Access token: {}", token_response.access_token().secret());
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret)); debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
debug!("Expiration time: {:?}", token_response.expires_in()); debug!("Expiration time: {:?}", token_response.expires_in());

16
src/static/scripts/admin_users.js

@ -173,10 +173,14 @@ const ORG_TYPES = {
"name": "User", "name": "User",
"bg": "blue" "bg": "blue"
}, },
"4": { "3": {
"name": "Manager", "name": "Manager",
"bg": "green" "bg": "green"
}, },
"4": {
"name": "Custom",
"bg": "teal"
},
}; };
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
@ -187,15 +191,19 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
// Get org specific values // Get org specific values
const userOrgType = event.relatedTarget.dataset.vwOrgType; const userOrgType = event.relatedTarget.dataset.vwOrgType;
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
const orgName = event.relatedTarget.dataset.vwOrgName; const orgName = event.relatedTarget.dataset.vwOrgName;
const orgUuid = event.relatedTarget.dataset.vwOrgUuid; const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
document.getElementById("userOrgTypeForm").reset();
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeUserUuid").value = userUuid;
document.getElementById("userOrgTypeOrgUuid").value = orgUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; // The Custom role can only be selected while it is enabled
const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`);
if (userOrgTypeRadio) {
userOrgTypeRadio.checked = true;
}
}, false); }, false);
// Prevent accidental submission of the form with valid elements after the modal has been hidden. // Prevent accidental submission of the form with valid elements after the modal has been hidden.
@ -222,7 +230,7 @@ function updateUserOrgType(event) {
function initUserTable() { function initUserTable() {
// Color all the org buttons per type // Color all the org buttons per type
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
const orgType = ORG_TYPES[e.dataset.vwOrgType]; const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" };
e.style.backgroundColor = orgType.bg; e.style.backgroundColor = orgType.bg;
if (orgType.font !== undefined) { if (orgType.font !== undefined) {
e.style.color = orgType.font; e.style.color = orgType.font;

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

@ -135,6 +135,11 @@
<div class="radio"> <div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label> <label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label>
</div> </div>
{{#if custom_roles_enabled}}
<div class="radio">
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom">&nbsp;Custom</label>
</div>
{{/if}}
<div class="radio"> <div class="radio">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label> <label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>
</div> </div>

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

@ -98,10 +98,17 @@ app-security > app-two-factor-setup > form {
} }
/* Hide unsupported Custom Role options */ /* 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-ml-4:has(bit-form-control input),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide; @extend %vw-hide;
} }
{{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 */ /* Change collapsed menu icon to Vaultwarden */
bit-nav-logo bit-nav-item a:before { 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 // Effectively ignores, any static file route, and the alive endpoint
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
const MAX_LOGGED_QUERY_BYTES: usize = 100;
// Query values can hold secrets, like the access code of a login request, so we don't log them
fn redact_query_values(query: &str) -> String {
query
.split('&')
.map(|param| match param.split_once('=') {
Some((name, _)) => format!("{name}=…"),
None => param.to_owned(),
})
.collect::<Vec<_>>()
.join("&")
}
// When extra is true, we ignore the whitelist above and also print the mounts // When extra is true, we ignore the whitelist above and also print the mounts
pub struct BetterLogging { pub struct BetterLogging {
extra: bool, extra: bool,
@ -381,8 +395,9 @@ impl Fairing for BetterLogging {
}; };
match uri.query() { match uri.query() {
Some(q) => { Some(q) => {
let q = q.as_str(); let query = redact_query_values(q.as_str());
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug()); let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)];
info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug());
} }
None => info!(target: "request", "{method} {}", path.escape_debug()), None => info!(target: "request", "{method} {}", path.escape_debug()),
} }
@ -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