Browse Source

Add global master password policy setting

Adds MASTER_PASSWORD_POLICY, an optional server-wide master password
policy in the same JSON format as SSO_MASTER_PASSWORD_POLICY. It is
merged with any organization policies (strictest wins) and returned
during login and SSO enrollment, so the clients enforce it for all
users, not only organization members or SSO users.
pull/7915/head
Justin Troebinger 2 weeks ago
parent
commit
4615f909ee
  1. 5
      .env.template
  2. 19
      src/api/core/organizations.rs
  3. 11
      src/api/mod.rs
  4. 28
      src/config.rs

5
.env.template

@ -244,6 +244,11 @@
## This setting applies globally to all users.
# INCOMPLETE_2FA_TIME_LIMIT=3
## Optional global master password policy, enforced for all users in addition to any
## organization policies. Uses the same JSON format as SSO_MASTER_PASSWORD_POLICY.
## Note that the Bitwarden clients do not allow a minimum length below 12 characters.
# MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}'
## Disable icon downloading
## Set to true to disable icon downloading in the internal icon service.
## This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external

19
src/api/core/organizations.rs

@ -1974,8 +1974,8 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn)
// Called during the SSO enrollment return the default policy
#[get("/organizations/00000000-01DC-01DC-01DC-000000000000/policies/master-password", rank = 1)]
fn get_dummy_master_password_policy() -> JsonResult {
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
let (enabled, data) = match config_master_password_policy() {
Some(policy) => (true, policy.to_string()),
_ => (false, "null".to_owned()),
};
let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data);
@ -1987,8 +1987,8 @@ fn get_dummy_master_password_policy() -> JsonResult {
async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
let policy =
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| {
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
let (enabled, data) = match config_master_password_policy() {
Some(policy) => (true, policy.to_string()),
_ => (false, "null".to_owned()),
};
@ -1998,6 +1998,17 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH
Ok(Json(policy.to_json()))
}
// The global policy wins over the SSO one since it applies to all users
fn config_master_password_policy() -> Option<Value> {
CONFIG.master_password_policy_value().or_else(|| {
if CONFIG.sso_enabled() {
CONFIG.sso_master_password_policy_value()
} else {
None
}
})
}
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 3)]
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {

11
src/api/mod.rs

@ -78,7 +78,7 @@ impl PasswordOrOtpData {
#[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", default)]
pub struct MasterPasswordPolicy {
min_complexity: Option<u8>,
min_length: Option<u32>,
@ -91,7 +91,7 @@ pub struct MasterPasswordPolicy {
// Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy
async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
let master_password_policies: Vec<MasterPasswordPolicy> =
let mut master_password_policies: Vec<MasterPasswordPolicy> =
OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy(
&user.uuid,
OrgPolicyType::MasterPassword,
@ -102,6 +102,13 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
.filter_map(|p| serde_json::from_str(&p.data).ok())
.collect();
// The global policy applies to all users, on top of whatever their organizations require
if let Some(global_policy) = CONFIG.master_password_policy_value()
&& let Ok(global_policy) = serde_json::from_value::<MasterPasswordPolicy>(global_policy)
{
master_password_policies.push(global_policy);
}
let mut mpp_json = if !master_password_policies.is_empty() {
json!(master_password_policies.into_iter().reduce(|acc, policy| {
MasterPasswordPolicy {

28
src/config.rs

@ -628,6 +628,11 @@ make_config! {
/// This setting applies globally to all users.
incomplete_2fa_time_limit: u16, true, def, 3;
/// Master password policy |> Optional global master password policy, enforced for all users
/// in addition to any organization policies. Uses the same JSON format as the SSO master
/// password policy. Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}'
master_password_policy: String, true, option;
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
/// network requests. $ICON_CACHE_TTL must also be set to 0; otherwise, the existing icons
@ -1154,9 +1159,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
validate_internal_sso_issuer_url(&cfg.sso_authority)?;
validate_internal_sso_redirect_url(&cfg.sso_callback_path)?;
validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?;
validate_master_password_policy("sso_master_password_policy", cfg.sso_master_password_policy.as_ref())?;
}
validate_master_password_policy("master_password_policy", cfg.master_password_policy.as_ref())?;
if cfg._enable_yubico {
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() {
err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")
@ -1385,17 +1392,18 @@ fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result<open
}
}
fn validate_sso_master_password_policy(
sso_master_password_policy: Option<&String>,
fn validate_master_password_policy(
setting_name: &str,
master_password_policy: Option<&String>,
) -> Result<Option<serde_json::Value>, Error> {
let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp));
let policy = master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp));
match policy {
None => Ok(None),
Some(Ok(jsobject @ serde_json::Value::Object(_))) => Ok(Some(jsobject)),
Some(Ok(_)) => err!("Invalid sso_master_password_policy: parsed value is not a JSON object"),
Some(Ok(_)) => err!(format!("Invalid {setting_name}: parsed value is not a JSON object")),
Some(Err(error)) => {
err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''"))
err!(format!("Invalid {setting_name} ({error}), Ensure that it's correctly escaped with ''"))
}
}
}
@ -1767,7 +1775,13 @@ impl Config {
}
pub fn sso_master_password_policy_value(&self) -> Option<serde_json::Value> {
validate_sso_master_password_policy(self.sso_master_password_policy().as_ref()).ok().flatten()
validate_master_password_policy("sso_master_password_policy", self.sso_master_password_policy().as_ref())
.ok()
.flatten()
}
pub fn master_password_policy_value(&self) -> Option<serde_json::Value> {
validate_master_password_policy("master_password_policy", self.master_password_policy().as_ref()).ok().flatten()
}
pub fn sso_scopes_vec(&self) -> Vec<String> {

Loading…
Cancel
Save