Compare commits

..
29 changed files with 1028 additions and 216 deletions

No files matched your search

+10
View File
@@ -396,6 +396,7 @@
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
## ##
## The following flags are available: ## The following flags are available:
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0) ## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
@@ -496,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!
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
@@ -0,0 +1,22 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything
-- is dropped: change their role first, or restore the backup taken before the upgrade.
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TEMPORARY TABLE custom_role_downgrade;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;
@@ -0,0 +1,13 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
Whitespace-only changes.
@@ -0,0 +1,3 @@
-- TEXT has no size limit here, only MySQL needed the change.
-- PostgreSQL rejects a migration that is only comments ("Received an empty query"), hence the no-op.
SELECT 1;
@@ -0,0 +1,20 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TABLE custom_role_downgrade;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;
@@ -0,0 +1,13 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
Whitespace-only changes.
@@ -0,0 +1 @@
-- TEXT has no size limit here, only MySQL needed the change
@@ -0,0 +1,19 @@
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
INSERT INTO custom_role_downgrade
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
FROM users_organizations WHERE atype = 4 LIMIT 1;
DROP TABLE custom_role_downgrade;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;
@@ -0,0 +1,11 @@
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE
+32 -6
View File
@@ -1,4 +1,4 @@
use std::{env, sync::LazyLock}; use std::{collections::HashMap, env, sync::LazyLock};
use reqwest::Method; use reqwest::Method;
use rocket::{ use rocket::{
@@ -26,6 +26,7 @@ use crate::{
models::{ models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
custom_roles_enabled,
}, },
}, },
error::{Error, MapResult}, error::{Error, MapResult},
@@ -256,6 +257,7 @@ struct AdminTemplateData {
logged_in: bool, logged_in: bool,
urlpath: String, urlpath: String,
sso_enabled: bool, sso_enabled: bool,
custom_roles_enabled: bool,
} }
impl AdminTemplateData { impl AdminTemplateData {
@@ -266,6 +268,7 @@ impl AdminTemplateData {
logged_in: true, logged_in: true,
urlpath: CONFIG.domain_path(), urlpath: CONFIG.domain_path(),
sso_enabled: CONFIG.sso_enabled(), sso_enabled: CONFIG.sso_enabled(),
custom_roles_enabled: custom_roles_enabled(),
} }
} }
@@ -390,6 +393,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string())); usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
.await
.into_iter()
.map(|member| (member.org_uuid.to_string(), member.atype))
.collect();
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
org["type"] = json!(atype);
}
}
users_json.push(usr); users_json.push(usr);
} }
@@ -565,10 +580,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32 // Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
} else { // are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
err!("Invalid type") Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
MembershipType::Manager
}
Some(new_type) => new_type,
None => err!("Invalid type"),
}; };
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
@@ -578,7 +597,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?;
+19 -1
View File
@@ -651,6 +651,8 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid master password salt") err!("Invalid master password salt")
} }
validate_key_id_unchanged(&user, &unlock_data)?;
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
(new_master_password_hash, new_key) (new_master_password_hash, new_key)
@@ -753,6 +755,19 @@ pub(super) struct UnlockData {
salt: String, salt: String,
kdf: KDFData, kdf: KDFData,
pub(super) master_key_wrapped_user_key: String, pub(super) master_key_wrapped_user_key: String,
contained_key_id: Option<KeyId>,
}
/// A password or KDF change keeps the user key, so its key id must be the current one, when both are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs#L27-L47>
fn validate_key_id_unchanged(user: &User, unlock_data: &UnlockData) -> EmptyResult {
if let (Some(current), Some(contained)) = (&user.key_id, &unlock_data.contained_key_id)
&& current != contained
{
err!("Invalid user key sent in master-password unlock data.")
}
Ok(())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -773,6 +788,8 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
data.authentication_data.check(&headers.user, &data.unlock_data)?; data.authentication_data.check(&headers.user, &data.unlock_data)?;
validate_key_id_unchanged(&headers.user, &data.unlock_data)?;
let mut user = headers.user; let mut user = headers.user;
set_kdf_data(&mut user, &data.unlock_data.kdf)?; set_kdf_data(&mut user, &data.unlock_data.kdf)?;
@@ -1073,8 +1090,9 @@ struct KeyIdData {
#[post("/accounts/key-management/user-key-id", data = "<data>")] #[post("/accounts/key-management/user-key-id", data = "<data>")]
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult { async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
let mut user = headers.user; let mut user = headers.user;
// Only a backfill for accounts that have none. Afterwards the id changes with the key, in a rotation.
if user.key_id.is_some() { if user.key_id.is_some() {
err_code!("Unexpected data", Status::UnprocessableEntity.code); err!("User key id is already set.")
} }
user.key_id = Some(data.into_inner().user_key_id); user.key_id = Some(data.into_inner().user_key_id);
+228 -82
View File
@@ -24,6 +24,7 @@ use crate::{
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, User, UserId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
is_data_blob_encrypted,
}, },
}, },
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@@ -189,12 +190,19 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey, "masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey, "masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email "salt": headers.user.email,
"containedKeyId": headers.user.key_id,
}) })
} else { } else {
Value::Null Value::Null
}; };
// Upstream omits these when unset rather than sending null
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock });
if let Some(key_id) = &headers.user.key_id {
user_decryption["userKeyId"] = json!(key_id);
}
Ok(Json(json!({ Ok(Json(json!({
"profile": user_json, "profile": user_json,
"folders": folders_json, "folders": folders_json,
@@ -204,10 +212,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json, "ciphers": ciphers_json,
"domains": domains_json, "domains": domains_json,
"sends": sends_json, "sends": sends_json,
"userDecryption": { "userDecryption": user_decryption,
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"object": "sync" "object": "sync"
}))) })))
} }
@@ -269,7 +274,8 @@ pub struct CipherData {
key: Option<String>, key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0 // Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent
pub encrypted_for: Option<UserId>,
// Added in web-v2025.8.1, Optional for compat // Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>, pub encrypted_by_key_id: Option<KeyId>,
@@ -284,7 +290,8 @@ pub struct CipherData {
Passport = 8 Passport = 8
*/ */
pub r#type: i32, pub r#type: i32,
pub name: String, // Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub notes: Option<String>, pub notes: Option<String>,
fields: Option<Value>, fields: Option<Value>,
@@ -298,6 +305,9 @@ pub struct CipherData {
drivers_license: Option<Value>, drivers_license: Option<Value>,
passport: Option<Value>, passport: Option<Value>,
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
data: Option<String>,
favorite: Option<bool>, favorite: Option<bool>,
reprompt: Option<i32>, reprompt: Option<i32>,
@@ -319,6 +329,98 @@ pub struct CipherData {
archived_date: Option<String>, archived_date: Option<String>,
} }
/// A field of a [`CipherData`] that fails upstream's model validation.
#[derive(Debug)]
pub struct CipherValidationError {
pub field: &'static str,
pub message: String,
}
impl From<CipherValidationError> for crate::Error {
fn from(e: CipherValidationError) -> Self {
Self::new_msg(e.message)
}
}
/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1857-L1870>
fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult {
if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) {
err!("Cipher was not encrypted for the current user. Please try again.")
}
Ok(())
}
/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1886-L1911>
fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
validate_encrypted_for_user(data, &user.uuid)?;
if !is_org_cipher
&& let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id)
&& cipher_key_id != user_key_id
{
err!("Cipher was not encrypted with the current user key. Please try again.")
}
Ok(())
}
/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data`
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// Whether `data` is a blob rather than the per-type fields. Parses `data`, so keep the result.
pub fn is_blob(&self) -> bool {
self.data.as_deref().is_some_and(is_data_blob_encrypted)
}
/// Saving's last checks: a known type, and its data unless it's a blob.
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
let type_data = match self.r#type {
1 => &self.login,
2 => &self.secure_note,
3 => &self.card,
4 => &self.identity,
5 => &self.ssh_key,
6 => &self.bank_account,
7 => &self.drivers_license,
8 => &self.passport,
_ => err!("Invalid type"),
};
if !is_blob && type_data.is_none() {
err!("Data missing")
}
Ok(())
}
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
/// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L91-L99>
pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> {
if let Some(data) = &self.data
&& data.len() > MAX_CIPHER_DATA_LENGTH
{
return Err(CipherValidationError {
field: "Data",
message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."),
});
}
// A blob carries the name inside it, so only the other formats need one
if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) {
return Err(CipherValidationError {
field: "Name",
message: String::from("The Name field is required."),
});
}
Ok(())
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PartialCipherData { pub struct PartialCipherData {
@@ -352,16 +454,14 @@ async fn post_ciphers_create(
) -> JsonResult { ) -> JsonResult {
let mut data: ShareCipherData = data.into_inner(); let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid { validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
// This check is usually only needed in update_cipher_from_data(), but we // This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to // need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below. // cipher.save() below.
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?; enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); let mut cipher = Cipher::new(data.cipher.r#type);
cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?; cipher.save(&conn).await?;
@@ -385,16 +485,7 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner(); let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid { validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
// The web/browser clients set this field to null as expected, but the // The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`, // mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
@@ -402,7 +493,7 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
// needed when creating a new cipher, so just ignore it unconditionally. // needed when creating a new cipher, so just ignore it unconditionally.
data.last_known_revision_date = None; data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type, data.name.clone()); let mut cipher = Cipher::new(data.r#type);
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).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?))
@@ -426,6 +517,44 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
Ok(()) Ok(())
} }
/// The checks of saving a cipher, for callers that run them before writing anything else
async fn validate_cipher_update(
cipher: &Cipher,
data: &CipherData,
headers: &Headers,
conn: &DbConn,
ut: UpdateType,
) -> EmptyResult {
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
&& let Some(dt) = &data.last_known_revision_date
{
match NaiveDateTime::parse_from_str(dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
if let Some(folder_id) = &data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
Ok(())
}
pub async fn update_cipher_from_data( pub async fn update_cipher_from_data(
cipher: &mut Cipher, cipher: &mut Cipher,
data: CipherData, data: CipherData,
@@ -451,32 +580,15 @@ pub async fn update_cipher_from_data(
enforce_personal_ownership_policy(Some(&data), headers, conn).await?; enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
// Check that the client isn't updating an existing cipher with stale data. let is_blob = data.is_blob();
// And only perform this check when not importing ciphers, else the date/time check will fail. data.validate_content(is_blob)?;
if ut != UpdateType::None data.validate_type_data(is_blob)?;
&& let Some(dt) = data.last_known_revision_date validate_cipher_update(cipher, &data, headers, conn, ut).await?;
{
match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id { if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher") err!("Organization mismatch. Please resync the client before updating the cipher")
} }
if let Some(note) = &data.notes {
let max_note_size = CONFIG._max_note_size();
if note.len() > max_note_size {
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
}
}
// Check if this cipher is being transferred from a personal to an organization vault // Check if this cipher is being transferred from a personal to an organization vault
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
@@ -507,12 +619,6 @@ pub async fn update_cipher_from_data(
cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.user_uuid = Some(headers.user.uuid.clone());
} }
if let Some(ref folder_id) = data.folder_id
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
{
err!("Invalid folder", "Folder does not exist or belongs to another user");
}
// Modify attachments name and keys when rotating // Modify attachments name and keys when rotating
if let Some(attachments) = data.attachments2 { if let Some(attachments) = data.attachments2 {
for (id, attachment) in attachments { for (id, attachment) in attachments {
@@ -551,26 +657,36 @@ pub async fn update_cipher_from_data(
_ => err!("Invalid type"), _ => err!("Invalid type"),
}; };
let type_data = if let Some(mut data) = type_data_opt { if let Some(blob) = data.data.filter(|_| is_blob) {
// A blob holds everything, the name included; the column can't be null, so it's left empty
// TODO: Make `ciphers.name` nullable and store `None` here instead.
cipher.name = String::new();
cipher.notes = None;
cipher.fields = None;
cipher.password_history = None;
cipher.data = blob;
} else {
let Some(mut type_data) = type_data_opt else {
err!("Data missing")
};
// Remove the 'Response' key from the base object. // Remove the 'Response' key from the base object.
if let Some(data_obj) = data.as_object_mut() { if let Some(data_obj) = type_data.as_object_mut() {
data_obj.remove("response"); data_obj.remove("response");
} }
// Remove the 'Response' key from every Uri. // Remove the 'Response' key from every Uri.
if data["uris"].is_array() { if type_data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone()); type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
} }
data
} else { // `validate_content` made sure there is a name
err!("Data missing") cipher.name = data.name.unwrap_or_default();
}; cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.data = type_data.to_string();
}
cipher.key = data.key; cipher.key = data.key;
cipher.name = data.name;
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.data = type_data.to_string();
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32); cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
cipher.save(conn).await?; cipher.save(conn).await?;
@@ -670,7 +786,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58 // Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
cipher_data.organization_id = None; 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);
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?;
} }
@@ -743,6 +859,9 @@ async fn put_cipher(
err!("Cipher is not write accessible") err!("Cipher is not write accessible")
} }
// Against the cipher we hold rather than the organization the client claims, like upstream
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
// Moving a cipher into an organization is only possible through the share endpoints // Moving a cipher into an organization is only possible through the share endpoints
if cipher.organization_uuid != data.organization_id { if cipher.organization_uuid != data.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher") err!("Organization mismatch. Please resync the client before updating the cipher")
@@ -1019,13 +1138,20 @@ async fn put_cipher_share(
) -> JsonResult { ) -> JsonResult {
let data: ShareCipherData = data.into_inner(); let data: ShareCipherData = data.into_inner();
// Upstream's `PutShare` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L899-L912>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L498-L519>
if data.cipher.organization_id.is_none() { if data.cipher.organization_id.is_none() {
err!("Cipher OrganizationId is required.") err!("Cipher OrganizationId is required.")
} }
if data.collection_ids.is_empty() { if data.collection_ids.is_empty() {
err!("You must select at least one collection.") err!("You must select at least one collection.")
} }
if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid))
{
err_code!("Cipher doesn't exist", Status::NotFound.code)
}
validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?;
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
} }
@@ -1085,21 +1211,35 @@ async fn put_cipher_share_selected(
) -> JsonResult { ) -> JsonResult {
let data: ShareSelectedCipherData = data.into_inner(); let data: ShareSelectedCipherData = data.into_inner();
// Upstream's `PutShareMany` checks, before anything is written.
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L560-L604>
if data.ciphers.is_empty() { if data.ciphers.is_empty() {
err!("You must select at least one cipher.") err!("You must select at least one cipher.")
} }
if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) {
err!("All Ciphers must have an Id and OrganizationId.")
}
if data.ciphers.iter().map(|c| &c.organization_id).collect::<HashSet<_>>().len() != 1 {
err!("All ciphers must be for the same organization.")
}
if data.collection_ids.is_empty() { if data.collection_ids.is_empty() {
err!("You must select at least one collection.") err!("You must select at least one collection.")
} }
for cipher in &data.ciphers { for cipher in &data.ciphers {
if cipher.id.is_none() { let is_blob = cipher.is_blob();
err!("Request missing ids field") cipher.validate_content(is_blob)?;
} cipher.validate_type_data(is_blob)?;
if cipher.organization_id.is_none() { }
err!("Cipher OrganizationId is required.") for cipher in &data.ciphers {
} validate_encrypted_for_user(cipher, &headers.user.uuid)?;
}
let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await;
for cipher_data in &data.ciphers {
let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else {
err!("Trying to share ciphers that you do not own.")
};
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
} }
let mut responses = Vec::with_capacity(data.ciphers.len()); let mut responses = Vec::with_capacity(data.ciphers.len());
@@ -1161,6 +1301,22 @@ async fn share_cipher_by_uuid(
err!("Organization mismatch. Please resync the client before updating the cipher") err!("Organization mismatch. Please resync the client before updating the cipher")
} }
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
// For the same reason, the other checks of saving
let is_blob = data.cipher.is_blob();
data.cipher.validate_content(is_blob)?;
data.cipher.validate_type_data(is_blob)?;
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
let mut shared_to_collections = vec![]; let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id { if let Some(organization_id) = &data.cipher.organization_id {
@@ -1179,16 +1335,6 @@ async fn share_cipher_by_uuid(
} }
} }
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
let ut = if let Some(ut) = override_ut {
ut
} else if data.cipher.last_known_revision_date.is_some() {
UpdateType::SyncCipherUpdate
} else {
UpdateType::SyncCipherCreate
};
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).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?))
+39 -19
View File
@@ -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, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
OrganizationId, TwoFactor, TwoFactorType, User, UserId, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
}, },
}, },
mail, mail,
@@ -1008,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")
@@ -1025,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;
@@ -1071,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?;
@@ -1508,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
@@ -1549,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.
@@ -1846,7 +1866,7 @@ async fn post_org_import(
cipher_data.folder_id = None; cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org // Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone()); cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data( update_cipher_from_data(
&mut cipher, &mut cipher,
cipher_data, cipher_data,
+2 -1
View File
@@ -20,7 +20,7 @@ use crate::{
crypto::sha256_hex, crypto::sha256_hex,
db::{ db::{
DbConn, DbConn,
models::{AttachmentId, CipherId}, models::{AttachmentId, CipherId, custom_roles_enabled},
}, },
error::Error, error::Error,
util::{Cached, EtagCached}, util::{Cached, EtagCached},
@@ -90,6 +90,7 @@ fn vaultwarden_css() -> EtagCached<String> {
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({ let css_options = json!({
"custom_roles_enabled": custom_roles_enabled(),
"email_change_allowed": CONFIG.email_change_allowed(), "email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true, "load_user_scss": true,
+4
View File
@@ -817,6 +817,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;
+71
View File
@@ -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);
}
}
+114 -67
View File
@@ -19,13 +19,13 @@ use crate::{
}, },
}, },
error::MapResult, error::MapResult,
util::LowerCase, util::{LowerCase, convert_json_key_lcase_first},
}; };
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, MembershipType, OrganizationId, User, UserId, MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -63,6 +63,18 @@ pub struct Cipher {
pub reprompt: Option<i32>, pub reprompt: Option<i32>,
} }
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
///
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType { pub enum RepromptType {
None = 0, None = 0,
Password = 1, Password = 1,
@@ -70,7 +82,8 @@ pub enum RepromptType {
/// Local methods /// Local methods
impl Cipher { impl Cipher {
pub fn new(atype: i32, name: String) -> Self { /// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
pub fn new(atype: i32) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
Self { Self {
@@ -84,7 +97,7 @@ impl Cipher {
key: None, key: None,
atype, atype,
name, name: String::new(),
notes: None, notes: None,
fields: None, fields: None,
@@ -110,6 +123,16 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
} }
let is_blob = cipher.is_blob();
if let Err(e) = cipher.validate_content(is_blob) {
validation_errors
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
}
if let Err(e) = cipher.validate_type_data(is_blob) {
validation_errors
.insert(format!("Ciphers[{index}].Type"), serde_json::to_value([e.message()]).unwrap());
}
// Validate the password history if it contains `null` values and if so, return a warning // Validate the password history if it contains `null` values and if so, return a warning
if let Some(Value::Array(password_history)) = &cipher.password_history { if let Some(Value::Array(password_history)) = &cipher.password_history {
for pwh in password_history { for pwh in password_history {
@@ -154,6 +177,10 @@ impl Cipher {
) -> Result<Value, crate::Error> { ) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date}; use crate::util::{format_date, validate_and_format_date};
// Parsed once, since `data` can be large and this runs for every cipher in a sync
let type_data = serde_json::from_str::<Value>(&self.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null; let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -250,63 +277,9 @@ impl Cipher {
}) })
.unwrap_or_default(); .unwrap_or_default();
// Get the type_data or a default to an empty json object '{}'. // Like upstream, fields and password history stored as NULL are sent as null, not `[]`
// If not passing an empty object, mobile clients will crash. let fields_json = self.fields.is_some().then_some(fields_json);
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data) let password_history_json = self.password_history.is_some().then_some(password_history_json);
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
@@ -403,10 +376,84 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
}; };
json_object[key] = type_data_json; if is_blob_encrypted {
// Like upstream, sent as-is with the structured fields null
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
Ok(json_object) Ok(json_object)
} }
/// The per-type data of a legacy cipher, with fixups for values known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = if let Ok(data @ Value::Object(_)) = type_data {
convert_json_key_lcase_first(data)
} else {
warn!("Error parsing data field for {}", self.uuid);
Value::Object(serde_json::Map::new())
};
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
type_data_json
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> { pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
let mut user_uuids = Vec::new(); let mut user_uuids = Vec::new();
match self.user_uuid { match self.user_uuid {
@@ -872,7 +919,7 @@ impl Cipher {
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
); );
} }
@@ -907,7 +954,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
); );
} }
@@ -1111,7 +1158,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)
@@ -1140,7 +1187,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)
@@ -1182,7 +1229,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))
+7 -7
View File
@@ -18,7 +18,7 @@ use macros::UuidFromParam;
use super::{ use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId, User, UserId, 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(
+3 -3
View File
@@ -21,7 +21,7 @@ mod user;
pub use self::archive::Archive; pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId}; pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{ pub use self::emergency_access::{
@@ -33,8 +33,8 @@ pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
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};
+319 -24
View File
@@ -1,4 +1,7 @@
use std::cmp::Ordering; use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
};
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
@@ -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);
}
} }
} }
+9
View File
@@ -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,
} }
} }
+12 -4
View File
@@ -173,10 +173,14 @@ const ORG_TYPES = {
"name": "User", "name": "User",
"bg": "blue" "bg": "blue"
}, },
"4": { "3": {
"name": "Manager", "name": "Manager",
"bg": "green" "bg": "green"
}, },
"4": {
"name": "Custom",
"bg": "teal"
},
}; };
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
@@ -187,15 +191,19 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
// Get org specific values // Get org specific values
const userOrgType = event.relatedTarget.dataset.vwOrgType; const userOrgType = event.relatedTarget.dataset.vwOrgType;
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
const orgName = event.relatedTarget.dataset.vwOrgName; const orgName = event.relatedTarget.dataset.vwOrgName;
const orgUuid = event.relatedTarget.dataset.vwOrgUuid; const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
document.getElementById("userOrgTypeForm").reset();
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeUserUuid").value = userUuid;
document.getElementById("userOrgTypeOrgUuid").value = orgUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; // The Custom role can only be selected while it is enabled
const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`);
if (userOrgTypeRadio) {
userOrgTypeRadio.checked = true;
}
}, false); }, false);
// Prevent accidental submission of the form with valid elements after the modal has been hidden. // Prevent accidental submission of the form with valid elements after the modal has been hidden.
@@ -222,7 +230,7 @@ function updateUserOrgType(event) {
function initUserTable() { function initUserTable() {
// Color all the org buttons per type // Color all the org buttons per type
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
const orgType = ORG_TYPES[e.dataset.vwOrgType]; const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" };
e.style.backgroundColor = orgType.bg; e.style.backgroundColor = orgType.bg;
if (orgType.font !== undefined) { if (orgType.font !== undefined) {
e.style.color = orgType.font; e.style.color = orgType.font;
+5
View File
@@ -135,6 +135,11 @@
<div class="radio"> <div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label> <label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label>
</div> </div>
{{#if custom_roles_enabled}}
<div class="radio">
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom">&nbsp;Custom</label>
</div>
{{/if}}
<div class="radio"> <div class="radio">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label> <label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>
</div> </div>
@@ -98,10 +98,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 {
+42 -2
View File
@@ -302,6 +302,20 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
// Effectively ignores, any static file route, and the alive endpoint // Effectively ignores, any static file route, and the alive endpoint
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
const MAX_LOGGED_QUERY_BYTES: usize = 100;
// Query values can hold secrets, like the access code of a login request, so we don't log them
fn redact_query_values(query: &str) -> String {
query
.split('&')
.map(|param| match param.split_once('=') {
Some((name, _)) => format!("{name}=…"),
None => param.to_owned(),
})
.collect::<Vec<_>>()
.join("&")
}
// When extra is true, we ignore the whitelist above and also print the mounts // When extra is true, we ignore the whitelist above and also print the mounts
pub struct BetterLogging { pub struct BetterLogging {
extra: bool, extra: bool,
@@ -381,8 +395,9 @@ impl Fairing for BetterLogging {
}; };
match uri.query() { match uri.query() {
Some(q) => { Some(q) => {
let q = q.as_str(); let query = redact_query_values(q.as_str());
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug()); let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)];
info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug());
} }
None => info!(target: "request", "{method} {}", path.escape_debug()), None => info!(target: "request", "{method} {}", path.escape_debug()),
} }
@@ -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}="));
}
}