From a3232c9855198219488576b6adea64f30cbf1442 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:35:05 +0200 Subject: [PATCH] Add admin controls for timeout and export policies --- src/api/admin.rs | 128 ++++++++++++++++++- src/api/core/organizations.rs | 35 ++++- src/db/models/mod.rs | 2 +- src/db/models/org_policy.rs | 110 +++++++++++++++- src/static/scripts/admin_organizations.js | 97 +++++++++++++- src/static/templates/admin/organizations.hbs | 93 ++++++++++++++ 6 files changed, 456 insertions(+), 9 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 7037bfb1..2f020609 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -24,8 +24,9 @@ use crate::{ db::{ ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version, models::{ - Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, - MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, + Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, MaximumVaultTimeoutPolicyData, + Membership, MembershipId, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationId, SsoUser, + TwoFactor, User, UserId, }, }, error::{Error, MapResult}, @@ -66,6 +67,7 @@ pub fn routes() -> Vec { test_smtp, users_overview, organizations_overview, + update_organization_policies, delete_organization, diagnostics, get_diagnostics_config, @@ -602,6 +604,30 @@ async fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult(&policy.data).ok()) + .unwrap_or_default(); + let disable_personal_vault_export = policies + .iter() + .find(|policy| policy.has_type(OrgPolicyType::DisablePersonalVaultExport)) + .is_some_and(|policy| policy.enabled); + + org["policies"] = json!({ + "singleOrgEnabled": single_org_enabled, + "maximumVaultTimeout": { + "enabled": maximum_vault_timeout_policy.is_some_and(|policy| policy.enabled), + "data": maximum_vault_timeout_data, + }, + "disablePersonalVaultExport": disable_personal_vault_export, + }); organizations_json.push(org); } @@ -609,6 +635,104 @@ async fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult EmptyResult { + let mut policy = OrgPolicy::find_by_org_and_type(org_id, policy_type, conn) + .await + .unwrap_or_else(|| OrgPolicy::new(org_id.clone(), policy_type, false, "null".to_owned())); + + if policy.enabled == enabled && policy.data == data { + return Ok(()); + } + + policy.enabled = enabled; + policy.data = data; + policy.save(conn).await?; + + log_event( + EventType::PolicyUpdated as i32, + policy.uuid.as_ref(), + org_id, + &ACTING_ADMIN_USER.into(), + 14, // Use UnknownBrowser type + &token.ip.ip, + conn, + ) + .await; + + Ok(()) +} + +#[post("/organizations//policies", format = "application/json", data = "")] +async fn update_organization_policies( + org_id: OrganizationId, + data: Json, + token: AdminToken, + conn: DbConn, +) -> EmptyResult { + if Organization::find_by_uuid(&org_id, &conn).await.is_none() { + err_code!("Organization doesn't exist", Status::NotFound.code); + } + + let mut data = data.into_inner(); + if let Err(message) = data.maximum_vault_timeout.data.validate_and_normalize() { + err_code!(message, Status::BadRequest.code); + } + + if data.maximum_vault_timeout.enabled { + let single_org_enabled = OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn) + .await + .is_some_and(|policy| policy.enabled); + if !single_org_enabled { + err_code!( + "The Single Organization policy must be enabled before enabling the Vault Timeout policy.", + Status::BadRequest.code + ); + } + } + + let timeout_data = serde_json::to_string(&data.maximum_vault_timeout.data)?; + save_admin_org_policy( + &org_id, + OrgPolicyType::MaximumVaultTimeout, + data.maximum_vault_timeout.enabled, + timeout_data, + &token, + &conn, + ) + .await?; + save_admin_org_policy( + &org_id, + OrgPolicyType::DisablePersonalVaultExport, + data.disable_personal_vault_export, + "null".to_owned(), + &token, + &conn, + ) + .await +} + #[post("/organizations//delete", format = "application/json")] async fn delete_organization(org_id: OrganizationId, _token: AdminToken, conn: DbConn) -> EmptyResult { let org = Organization::find_by_uuid(&org_id, &conn).await.map_res("Organization doesn't exist")?; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c7e79aed..bb037cb8 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -16,8 +16,9 @@ use crate::{ DbConn, models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, + Group, GroupId, GroupUser, Invitation, MaximumVaultTimeoutPolicyData, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, User, UserId, }, }, mail, @@ -2054,12 +2055,40 @@ async fn put_policy( if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - let data: PolicyData = data.into_inner().policy; + let mut data: PolicyData = data.into_inner().policy; let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; + if pol_type_enum == OrgPolicyType::MaximumVaultTimeout && data.enabled { + let single_org_policy_enabled = OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn) + .await + .is_some_and(|policy| policy.enabled); + if !single_org_policy_enabled { + err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.") + } + + let Some(timeout_data) = data.data.take() else { + err!("Vault Timeout policy data is required.") + }; + let mut timeout_data: MaximumVaultTimeoutPolicyData = serde_json::from_value(timeout_data)?; + if let Err(message) = timeout_data.validate_and_normalize() { + err!(message) + } + data.data = Some(serde_json::to_value(timeout_data)?); + } + + if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled { + let maximum_vault_timeout_enabled = + OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MaximumVaultTimeout, &conn) + .await + .is_some_and(|policy| policy.enabled); + if maximum_vault_timeout_enabled { + err!("Vault Timeout policy is enabled. It is not allowed to disable this policy.") + } + } + // Bitwarden only allows the Reset Password policy when Single Org policy is enabled // Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past // Now that groups are available we can enforce this option when wanted. diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..7aaf592b 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -29,7 +29,7 @@ pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; -pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; +pub use self::org_policy::{MaximumVaultTimeoutPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 88b7872c..ef594b1d 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -38,8 +38,8 @@ pub enum OrgPolicyType { DisableSend = 6, SendOptions = 7, ResetPassword = 8, - // MaximumVaultTimeout = 9, // Not supported (Not AGPLv3 Licensed) - // DisablePersonalVaultExport = 10, // Not supported (Not AGPLv3 Licensed) + MaximumVaultTimeout = 9, + DisablePersonalVaultExport = 10, // ActivateAutofill = 11, // AutomaticAppLogIn = 12, // FreeFamiliesSponsorshipPolicy = 13, @@ -51,6 +51,112 @@ pub enum OrgPolicyType { // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) } +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum MaximumVaultTimeoutType { + Immediately, + Custom, + OnSystemLock, + OnAppRestart, + Never, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum MaximumVaultTimeoutAction { + Lock, + LogOut, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MaximumVaultTimeoutPolicyData { + #[serde(rename = "type")] + pub timeout_type: Option, + pub minutes: u32, + pub action: Option, +} + +impl Default for MaximumVaultTimeoutPolicyData { + fn default() -> Self { + Self { + timeout_type: Some(MaximumVaultTimeoutType::Custom), + minutes: 8 * 60, + action: None, + } + } +} + +impl MaximumVaultTimeoutPolicyData { + /// Validate data supplied by the admin page and normalize the compatibility + /// value expected by clients which predate the timeout type field. + pub fn validate_and_normalize(&mut self) -> Result<(), &'static str> { + let timeout_type = self.timeout_type.unwrap_or(MaximumVaultTimeoutType::Custom); + self.timeout_type = Some(timeout_type); + + if timeout_type == MaximumVaultTimeoutType::Custom { + if self.minutes == 0 { + return Err("The custom vault timeout must be at least one minute."); + } + } else { + // Current Bitwarden clients write an eight-hour fallback for all + // non-custom types so older clients continue to behave safely. + self.minutes = 8 * 60; + } + + Ok(()) + } +} + +#[cfg(test)] +mod maximum_vault_timeout_tests { + use super::*; + + #[test] + fn serializes_client_policy_shape() { + let data = MaximumVaultTimeoutPolicyData { + timeout_type: Some(MaximumVaultTimeoutType::Custom), + minutes: 90, + action: Some(MaximumVaultTimeoutAction::LogOut), + }; + + assert_eq!(serde_json::to_value(data).unwrap(), json!({ "type": "custom", "minutes": 90, "action": "logOut" })); + } + + #[test] + fn rejects_zero_length_custom_timeout() { + let mut data = MaximumVaultTimeoutPolicyData { + timeout_type: Some(MaximumVaultTimeoutType::Custom), + minutes: 0, + action: None, + }; + + assert!(data.validate_and_normalize().is_err()); + } + + #[test] + fn normalizes_non_custom_timeout_for_older_clients() { + let mut data = MaximumVaultTimeoutPolicyData { + timeout_type: Some(MaximumVaultTimeoutType::Immediately), + minutes: 0, + action: Some(MaximumVaultTimeoutAction::Lock), + }; + + data.validate_and_normalize().unwrap(); + assert_eq!(data.minutes, 480); + } + + #[test] + fn accepts_legacy_policy_without_timeout_type() { + let mut data: MaximumVaultTimeoutPolicyData = + serde_json::from_value(json!({ "minutes": 60, "action": "lock" })).unwrap(); + + data.validate_and_normalize().unwrap(); + assert_eq!(data.timeout_type, Some(MaximumVaultTimeoutType::Custom)); + assert_eq!(data.minutes, 60); + } +} + // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/static/scripts/admin_organizations.js b/src/static/scripts/admin_organizations.js index c885344e..cd2b1efa 100644 --- a/src/static/scripts/admin_organizations.js +++ b/src/static/scripts/admin_organizations.js @@ -30,6 +30,95 @@ function deleteOrganization(event) { } } +function updateVaultTimeoutControls() { + const enabled = document.getElementById("vault-timeout-enabled").checked; + const timeoutType = document.getElementById("vault-timeout-type"); + const timeoutAction = document.getElementById("vault-timeout-action"); + const customFields = document.getElementById("vault-timeout-custom-fields"); + const hours = document.getElementById("vault-timeout-hours"); + const minutes = document.getElementById("vault-timeout-minutes"); + + timeoutType.disabled = !enabled; + timeoutAction.disabled = !enabled; + const customEnabled = enabled && timeoutType.value === "custom"; + customFields.classList.toggle("d-none", !customEnabled); + hours.disabled = !customEnabled; + minutes.disabled = !customEnabled; + + const singleOrgEnabled = document.getElementById("org-policies-form").dataset.vwSingleOrgEnabled === "true"; + document.getElementById("vault-timeout-single-org-warning").classList.toggle("d-none", !enabled || singleOrgEnabled); +} + +function loadOrganizationPolicies(event) { + const button = event.relatedTarget; + if (!button) { + return; + } + + const form = document.getElementById("org-policies-form"); + form.classList.remove("was-validated"); + form.dataset.vwSingleOrgEnabled = button.dataset.vwSingleOrgEnabled; + document.getElementById("org-policies-org-uuid").value = button.dataset.vwOrgUuid; + document.getElementById("org-policies-org-name").textContent = button.dataset.vwOrgName; + document.getElementById("vault-timeout-enabled").checked = button.dataset.vwTimeoutEnabled === "true"; + document.getElementById("vault-timeout-type").value = button.dataset.vwTimeoutType || "custom"; + document.getElementById("vault-timeout-action").value = button.dataset.vwTimeoutAction || ""; + document.getElementById("disable-personal-vault-export").checked = button.dataset.vwExportDisabled === "true"; + + const totalMinutes = Number.parseInt(button.dataset.vwTimeoutMinutes, 10) || 480; + document.getElementById("vault-timeout-hours").value = Math.floor(totalMinutes / 60); + document.getElementById("vault-timeout-minutes").value = totalMinutes % 60; + document.getElementById("vault-timeout-minutes").setCustomValidity(""); + updateVaultTimeoutControls(); +} + +function saveOrganizationPolicies(event) { + event.preventDefault(); + const form = event.target; + const timeoutEnabled = document.getElementById("vault-timeout-enabled").checked; + const singleOrgEnabled = form.dataset.vwSingleOrgEnabled === "true"; + if (timeoutEnabled && !singleOrgEnabled) { + document.getElementById("vault-timeout-single-org-warning").classList.remove("d-none"); + return; + } + + const timeoutType = document.getElementById("vault-timeout-type").value; + const hours = Number.parseInt(document.getElementById("vault-timeout-hours").value, 10); + const minutes = Number.parseInt(document.getElementById("vault-timeout-minutes").value, 10); + let totalMinutes = 480; + document.getElementById("vault-timeout-minutes").setCustomValidity(""); + if (timeoutType === "custom") { + totalMinutes = hours * 60 + minutes; + if (!Number.isInteger(hours) || hours < 0 || !Number.isInteger(minutes) || minutes < 0 || minutes > 59 || totalMinutes < 1) { + document.getElementById("vault-timeout-minutes").setCustomValidity("Invalid custom timeout"); + } else { + document.getElementById("vault-timeout-minutes").setCustomValidity(""); + } + } + + form.classList.add("was-validated"); + if (!form.checkValidity()) { + return; + } + + const action = document.getElementById("vault-timeout-action").value || null; + const body = JSON.stringify({ + maximumVaultTimeout: { + enabled: timeoutEnabled, + type: timeoutType, + minutes: totalMinutes, + action: action + }, + disablePersonalVaultExport: document.getElementById("disable-personal-vault-export").checked + }); + const orgUuid = document.getElementById("org-policies-org-uuid").value; + _post(`${BASE_URL}/admin/organizations/${orgUuid}/policies`, + "Organization policies saved correctly", + "Error saving organization policies", + body + ); +} + function initActions() { document.querySelectorAll("button[vw-delete-organization]").forEach(btn => { btn.addEventListener("click", deleteOrganization); @@ -42,6 +131,12 @@ function initActions() { // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { + const policiesModal = document.getElementById("orgPoliciesModal"); + policiesModal.addEventListener("show.bs.modal", loadOrganizationPolicies); + document.getElementById("org-policies-form").addEventListener("submit", saveOrganizationPolicies); + document.getElementById("vault-timeout-enabled").addEventListener("change", updateVaultTimeoutControls); + document.getElementById("vault-timeout-type").addEventListener("change", updateVaultTimeoutControls); + jQuery("#orgs-table").DataTable({ "drawCallback": function() { initActions(); @@ -67,4 +162,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { if (btnReload) { btnReload.addEventListener("click", reload); } -}); \ No newline at end of file +}); diff --git a/src/static/templates/admin/organizations.hbs b/src/static/templates/admin/organizations.hbs index c1e57d3d..01df1a68 100644 --- a/src/static/templates/admin/organizations.hbs +++ b/src/static/templates/admin/organizations.hbs @@ -44,6 +44,17 @@ Events: {{event_count}} +

@@ -58,6 +69,88 @@ + +