Add partial role support for manager only using web-vault v2024.12.0 (#5219)

* Add partial role support for manager only

- Add the custom role which replaces the manager role
- Added mini-details endpoint used by v2024.11.1

These changes try to add the custom role in such a way that it stays compatible with the older manager role.
It will convert a manager role into a custom role, and if a manager has `access-all` rights, it will enable the correct custom roles.
Upon saving it will convert these back to the old format.

What this does is making sure you are able to revert back to an older version of Vaultwarden without issues.
This way we can support newer web-vault's and still be compatible with a previous Vaultwarden version if needed.

In the future this needs to be changed to full role support though.

Fixed the 2FA hide CSS since the order of options has changed

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix hide passkey login

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix hide create account

Signed-off-by: BlackDex <black.dex@gmail.com>

* Small changes for v2024.12.0

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix hide create account link

Signed-off-by: BlackDex <black.dex@gmail.com>

* Add pre-release web-vault

Signed-off-by: BlackDex <black.dex@gmail.com>

* Rename function to mention swapping uuid's

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
This commit is contained in:
Mathijs van Veluw
2025-01-04 19:31:59 +01:00
committed by GitHub
parent dfd9e65396
commit 4816f77fd7
10 changed files with 330 additions and 120 deletions

View File

@ -48,6 +48,7 @@ pub fn routes() -> Vec<Route> {
confirm_invite,
bulk_confirm_invite,
accept_invite,
get_org_user_mini_details,
get_user,
edit_user,
put_organization_user,
@ -77,6 +78,7 @@ pub fn routes() -> Vec<Route> {
restore_organization_user,
bulk_restore_organization_user,
get_groups,
get_groups_details,
post_groups,
get_group,
put_group,
@ -98,6 +100,7 @@ pub fn routes() -> Vec<Route> {
get_org_export,
api_key,
rotate_api_key,
get_billing_metadata,
]
}
@ -322,7 +325,14 @@ async fn get_org_collections_details(org_id: &str, headers: ManagerHeadersLoose,
};
// get all collection memberships for the current organization
let coll_users = CollectionUser::find_by_organization(org_id, &mut conn).await;
let coll_users = CollectionUser::find_by_organization_swap_user_uuid_with_org_user_uuid(org_id, &mut conn).await;
// Generate a HashMap to get the correct UserOrgType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
let users_org_type: HashMap<String, i32> = UserOrganization::find_confirmed_by_org(org_id, &mut conn)
.await
.into_iter()
.map(|uo| (uo.uuid, uo.atype))
.collect();
// check if current user has full access to the organization (either directly or via any group)
let has_full_access_to_org = user_org.access_all
@ -336,11 +346,22 @@ async fn get_org_collections_details(org_id: &str, headers: ManagerHeadersLoose,
|| (CONFIG.org_groups_enabled()
&& GroupUser::has_access_to_collection_by_member(&col.uuid, &user_org.uuid, &mut conn).await);
// Not assigned collections should not be returned
if !assigned {
continue;
}
// get the users assigned directly to the given collection
let users: Vec<Value> = coll_users
.iter()
.filter(|collection_user| collection_user.collection_uuid == col.uuid)
.map(|collection_user| SelectionReadOnly::to_collection_user_details_read_only(collection_user).to_json())
.map(|collection_user| {
SelectionReadOnly::to_collection_user_details_read_only(
collection_user,
*users_org_type.get(&collection_user.user_uuid).unwrap_or(&(UserOrgType::User as i32)),
)
.to_json()
})
.collect();
// get the group details for the given collection
@ -645,12 +666,24 @@ async fn get_org_collection_detail(
Vec::with_capacity(0)
};
// Generate a HashMap to get the correct UserOrgType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
let users_org_type: HashMap<String, i32> = UserOrganization::find_confirmed_by_org(org_id, &mut conn)
.await
.into_iter()
.map(|uo| (uo.uuid, uo.atype))
.collect();
let users: Vec<Value> =
CollectionUser::find_by_collection_swap_user_uuid_with_org_user_uuid(&collection.uuid, &mut conn)
.await
.iter()
.map(|collection_user| {
SelectionReadOnly::to_collection_user_details_read_only(collection_user).to_json()
SelectionReadOnly::to_collection_user_details_read_only(
collection_user,
*users_org_type.get(&collection_user.user_uuid).unwrap_or(&(UserOrgType::User as i32)),
)
.to_json()
})
.collect();
@ -830,13 +863,19 @@ struct InviteData {
collections: Option<Vec<CollectionData>>,
#[serde(default)]
access_all: bool,
#[serde(default)]
permissions: HashMap<String, Value>,
}
#[post("/organizations/<org_id>/users/invite", data = "<data>")]
async fn send_invite(org_id: &str, data: Json<InviteData>, headers: AdminHeaders, mut conn: DbConn) -> EmptyResult {
let data: InviteData = data.into_inner();
let mut data: InviteData = data.into_inner();
let new_type = match UserOrgType::from_str(&data.r#type.into_string()) {
// 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
let raw_type = &data.r#type.into_string();
// UserOrgType::from_str will convert custom (4) to manager (3)
let new_type = match UserOrgType::from_str(raw_type) {
Some(new_type) => new_type as i32,
None => err!("Invalid type"),
};
@ -845,6 +884,17 @@ async fn send_invite(org_id: &str, data: Json<InviteData>, headers: AdminHeaders
err!("Only Owners can invite Managers, Admins or Owners")
}
// 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
// If the box is not checked, the user will still be a manager, but not with the access_all permission
if raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))
{
data.access_all = true;
}
for email in data.emails.iter() {
let mut user_org_status = UserOrgStatus::Invited as i32;
let user = match User::find_by_mail(email, &mut conn).await {
@ -1254,7 +1304,21 @@ async fn _confirm_invite(
save_result
}
#[get("/organizations/<org_id>/users/<org_user_id>?<data..>")]
#[get("/organizations/<org_id>/users/mini-details", rank = 1)]
async fn get_org_user_mini_details(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json<Value> {
let mut users_json = Vec::new();
for u in UserOrganization::find_by_org(org_id, &mut conn).await {
users_json.push(u.to_json_mini_details(&mut conn).await);
}
Json(json!({
"data": users_json,
"object": "list",
"continuationToken": null,
}))
}
#[get("/organizations/<org_id>/users/<org_user_id>?<data..>", rank = 2)]
async fn get_user(
org_id: &str,
org_user_id: &str,
@ -1282,6 +1346,8 @@ struct EditUserData {
groups: Option<Vec<String>>,
#[serde(default)]
access_all: bool,
#[serde(default)]
permissions: HashMap<String, Value>,
}
#[put("/organizations/<org_id>/users/<org_user_id>", data = "<data>", rank = 1)]
@ -1303,14 +1369,30 @@ async fn edit_user(
headers: AdminHeaders,
mut conn: DbConn,
) -> EmptyResult {
let data: EditUserData = data.into_inner();
let mut data: EditUserData = data.into_inner();
let Some(new_type) = UserOrgType::from_str(&data.r#type.into_string()) else {
// 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
let raw_type = &data.r#type.into_string();
// UserOrgType::from_str will convert custom (4) to manager (3)
let Some(new_type) = UserOrgType::from_str(raw_type) else {
err!("Invalid type")
};
let Some(mut user_to_edit) = UserOrganization::find_by_uuid_and_org(org_user_id, org_id, &mut conn).await else {
err!("The specified user isn't member of the organization")
// 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
// If the box is not checked, the user will still be a manager, but not with the access_all permission
if raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))
{
data.access_all = true;
}
let mut user_to_edit = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, &mut conn).await {
Some(user) => user,
None => err!("The specified user isn't member of the organization"),
};
if new_type != user_to_edit.atype
@ -1901,6 +1983,12 @@ fn get_plans_tax_rates(_headers: Headers) -> Json<Value> {
Json(_empty_data_json())
}
#[get("/organizations/<_org_id>/billing/metadata")]
fn get_billing_metadata(_org_id: &str, _headers: Headers) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(_empty_data_json())
}
fn _empty_data_json() -> Value {
json!({
"object": "list",
@ -2299,6 +2387,11 @@ async fn get_groups(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbCon
})))
}
#[get("/organizations/<org_id>/groups/details", rank = 1)]
async fn get_groups_details(org_id: &str, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
get_groups(org_id, headers, conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GroupRequest {
@ -2331,6 +2424,7 @@ struct SelectionReadOnly {
id: String,
read_only: bool,
hide_passwords: bool,
manage: bool,
}
impl SelectionReadOnly {
@ -2339,18 +2433,31 @@ impl SelectionReadOnly {
}
pub fn to_collection_group_details_read_only(collection_group: &CollectionGroup) -> SelectionReadOnly {
// If both read_only and hide_passwords are false, then manage should be true
// You can't have an entry with read_only and manage, or hide_passwords and manage
// Or an entry with everything to false
SelectionReadOnly {
id: collection_group.groups_uuid.clone(),
read_only: collection_group.read_only,
hide_passwords: collection_group.hide_passwords,
manage: !collection_group.read_only && !collection_group.hide_passwords,
}
}
pub fn to_collection_user_details_read_only(collection_user: &CollectionUser) -> SelectionReadOnly {
pub fn to_collection_user_details_read_only(
collection_user: &CollectionUser,
user_org_type: i32,
) -> SelectionReadOnly {
// Vaultwarden allows manage access for Admins and Owners by default
// For managers (Or custom role) it depends if they have read_ony or hide_passwords set to true or not
SelectionReadOnly {
id: collection_user.user_uuid.clone(),
read_only: collection_user.read_only,
hide_passwords: collection_user.hide_passwords,
manage: user_org_type >= UserOrgType::Admin
|| (user_org_type == UserOrgType::Manager
&& !collection_user.read_only
&& !collection_user.hide_passwords),
}
}
@ -2534,7 +2641,7 @@ async fn bulk_delete_groups(
Ok(())
}
#[get("/organizations/<org_id>/groups/<group_id>")]
#[get("/organizations/<org_id>/groups/<group_id>", rank = 2)]
async fn get_group(org_id: &str, group_id: &str, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
@ -2904,7 +3011,7 @@ async fn put_reset_password_enrollment(
if reset_request.reset_password_key.is_none()
&& OrgPolicy::org_is_reset_password_auto_enroll(org_id, &mut conn).await
{
err!("Reset password can't be withdrawed due to an enterprise policy");
err!("Reset password can't be withdrawn due to an enterprise policy");
}
if reset_request.reset_password_key.is_some() {

View File

@ -1,4 +1,3 @@
use once_cell::sync::Lazy;
use std::path::{Path, PathBuf};
use rocket::{
@ -14,7 +13,7 @@ use crate::{
api::{core::now, ApiResult, EmptyResult},
auth::decode_file_download,
error::Error,
util::{get_web_vault_version, Cached, SafeString},
util::{Cached, SafeString},
CONFIG,
};
@ -54,43 +53,7 @@ fn not_found() -> ApiResult<Html<String>> {
#[get("/css/vaultwarden.css")]
fn vaultwarden_css() -> Cached<Css<String>> {
// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then.
// The default is based upon the version since this feature is added.
static WEB_VAULT_VERSION: Lazy<u32> = Lazy::new(|| {
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
let vault_version = get_web_vault_version();
let (major, minor, patch) = match re.captures(&vault_version) {
Some(c) if c.len() == 4 => (
c.get(1).unwrap().as_str().parse().unwrap(),
c.get(2).unwrap().as_str().parse().unwrap(),
c.get(3).unwrap().as_str().parse().unwrap(),
),
_ => (2024, 6, 2),
};
format!("{major}{minor:02}{patch:02}").parse::<u32>().unwrap()
});
// Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then.
// The default is based upon the version since this feature is added.
static VW_VERSION: Lazy<u32> = Lazy::new(|| {
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
let vw_version = crate::VERSION.unwrap_or("1.32.1");
let (major, minor, patch) = match re.captures(vw_version) {
Some(c) if c.len() == 4 => (
c.get(1).unwrap().as_str().parse().unwrap(),
c.get(2).unwrap().as_str().parse().unwrap(),
c.get(3).unwrap().as_str().parse().unwrap(),
),
_ => (1, 32, 1),
};
format!("{major}{minor:02}{patch:02}").parse::<u32>().unwrap()
});
let css_options = json!({
"web_vault_version": *WEB_VAULT_VERSION,
"vw_version": *VW_VERSION,
"signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(),
"mail_enabled": CONFIG.mail_enabled(),
"yubico_enabled": CONFIG._enable_yubico() && (CONFIG.yubico_client_id().is_some() == CONFIG.yubico_secret_key().is_some()),