Browse Source

Merge e0b6fbf61b into 660faee68e

pull/7420/merge
Tom 1 day ago
committed by GitHub
parent
commit
0416274cd3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 128
      src/api/admin.rs
  2. 35
      src/api/core/organizations.rs
  3. 2
      src/db/models/mod.rs
  4. 110
      src/db/models/org_policy.rs
  5. 97
      src/static/scripts/admin_organizations.js
  6. 93
      src/static/templates/admin/organizations.hbs

128
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<Route> {
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<H
org["event_count"] = json!(Event::count_by_org(&o.uuid, &conn).await);
org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn).await);
org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn).await));
let policies = OrgPolicy::find_by_org(&o.uuid, &conn).await;
let single_org_enabled = policies
.iter()
.find(|policy| policy.has_type(OrgPolicyType::SingleOrg))
.is_some_and(|policy| policy.enabled);
let maximum_vault_timeout_policy =
policies.iter().find(|policy| policy.has_type(OrgPolicyType::MaximumVaultTimeout));
let maximum_vault_timeout_data = maximum_vault_timeout_policy
.and_then(|policy| serde_json::from_str::<MaximumVaultTimeoutPolicyData>(&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<H
Ok(Html(text))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AdminMaximumVaultTimeoutPolicyData {
enabled: bool,
#[serde(flatten)]
data: MaximumVaultTimeoutPolicyData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AdminOrganizationPoliciesData {
maximum_vault_timeout: AdminMaximumVaultTimeoutPolicyData,
disable_personal_vault_export: bool,
}
async fn save_admin_org_policy(
org_id: &OrganizationId,
policy_type: OrgPolicyType,
enabled: bool,
data: String,
token: &AdminToken,
conn: &DbConn,
) -> 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/<org_id>/policies", format = "application/json", data = "<data>")]
async fn update_organization_policies(
org_id: OrganizationId,
data: Json<AdminOrganizationPoliciesData>,
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/<org_id>/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")?;

35
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.

2
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,

110
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<MaximumVaultTimeoutType>,
pub minutes: u32,
pub action: Option<MaximumVaultTimeoutAction>,
}
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")]

97
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);
}
});
});

93
src/static/templates/admin/organizations.hbs

@ -44,6 +44,17 @@
<span class="d-block"><strong>Events:</strong> {{event_count}}</span>
</td>
<td class="text-end px-1 small">
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right"
data-bs-toggle="modal" data-bs-target="#orgPoliciesModal"
data-vw-edit-org-policies
data-vw-org-uuid="{{id}}"
data-vw-org-name="{{name}}"
data-vw-single-org-enabled="{{policies.singleOrgEnabled}}"
data-vw-timeout-enabled="{{policies.maximumVaultTimeout.enabled}}"
data-vw-timeout-type="{{policies.maximumVaultTimeout.data.type}}"
data-vw-timeout-minutes="{{policies.maximumVaultTimeout.data.minutes}}"
data-vw-timeout-action="{{policies.maximumVaultTimeout.data.action}}"
data-vw-export-disabled="{{policies.disablePersonalVaultExport}}">Policies</button><br>
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-delete-organization data-vw-org-uuid="{{id}}" data-vw-org-name="{{name}}" data-vw-billing-email="{{billingEmail}}">Delete Organization</button><br>
</td>
</tr>
@ -58,6 +69,88 @@
</div>
</main>
<div class="modal fade" id="orgPoliciesModal" tabindex="-1" aria-labelledby="orgPoliciesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form id="org-policies-form" novalidate>
<div class="modal-header">
<div>
<h5 class="modal-title" id="orgPoliciesModalLabel">Enterprise policies</h5>
<div class="small text-body-secondary" id="org-policies-org-name"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="org-policies-org-uuid">
<div class="alert alert-warning d-none" id="vault-timeout-single-org-warning" role="alert">
The Single Organization policy must be enabled in the organization before Vault Timeout can be enabled.
</div>
<fieldset class="border rounded p-3 mb-3">
<legend class="float-none w-auto px-2 fs-6">Vault timeout</legend>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch" id="vault-timeout-enabled">
<label class="form-check-label" for="vault-timeout-enabled">Enable policy</label>
</div>
<p class="small text-body-secondary">
Sets the maximum session timeout for organization members. The policy applies to administrators and users, but not owners.
</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="vault-timeout-type">Maximum allowed timeout</label>
<select class="form-select" id="vault-timeout-type" required>
<option value="immediately">Immediately</option>
<option value="custom">Custom</option>
<option value="onSystemLock">On system lock</option>
<option value="onAppRestart">On app restart</option>
<option value="never">Never (no maximum)</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="vault-timeout-action">Session timeout action</label>
<select class="form-select" id="vault-timeout-action">
<option value="">User preference</option>
<option value="lock">Lock</option>
<option value="logOut">Log out</option>
</select>
</div>
<div class="col-12" id="vault-timeout-custom-fields">
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="vault-timeout-hours">Hours</label>
<input class="form-control" type="number" min="0" step="1" id="vault-timeout-hours" required>
</div>
<div class="col-sm-6">
<label class="form-label" for="vault-timeout-minutes">Minutes</label>
<input class="form-control" type="number" min="0" max="59" step="1" id="vault-timeout-minutes" required>
<div class="invalid-feedback">Use 0–59 minutes; the total timeout must be at least one minute.</div>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset class="border rounded p-3">
<legend class="float-none w-auto px-2 fs-6">Remove individual vault export</legend>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="disable-personal-vault-export">
<label class="form-check-label" for="disable-personal-vault-export">Enable policy</label>
</div>
<p class="small text-body-secondary mt-2 mb-0">
Prevents non-owner and non-admin members from exporting their individual vault. Organization exports are not affected.
</p>
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save policies</button>
</div>
</form>
</div>
</div>
</div>
<link rel="stylesheet" href="{{urlpath}}/vw_static/datatables.css" />
<script src="{{urlpath}}/vw_static/jquery-4.0.0.slim.js"></script>
<script src="{{urlpath}}/vw_static/datatables.js"></script>

Loading…
Cancel
Save