Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 118 additions & 11 deletions src/api/core/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -98,6 +100,7 @@ pub fn routes() -> Vec<Route> {
get_org_export,
api_key,
rotate_api_key,
get_billing_metadata,
]
}

Expand Down Expand Up @@ -323,6 +326,13 @@ 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;
// 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
Expand 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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 be 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"),
};
Expand All @@ -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 send 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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Expand All @@ -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 be 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 send 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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2331,6 +2424,7 @@ struct SelectionReadOnly {
id: String,
read_only: bool,
hide_passwords: bool,
manage: bool,
}

impl SelectionReadOnly {
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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");
Expand Down
39 changes: 1 addition & 38 deletions src/api/web.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use once_cell::sync::Lazy;
use std::path::{Path, PathBuf};

use rocket::{
Expand All @@ -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,
};

Expand Down Expand Up @@ -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()),
Expand Down
43 changes: 42 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use reqwest::Url;
use crate::{
db::DbConnType,
error::Error,
util::{get_env, get_env_bool, parse_experimental_client_feature_flags},
util::{get_env, get_env_bool, get_web_vault_version, parse_experimental_client_feature_flags},
};

static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
Expand Down Expand Up @@ -1326,6 +1326,8 @@ where
// Register helpers
hb.register_helper("case", Box::new(case_helper));
hb.register_helper("to_json", Box::new(to_json));
hb.register_helper("webver", Box::new(webver));
hb.register_helper("vwver", Box::new(vwver));

macro_rules! reg {
($name:expr) => {{
Expand Down Expand Up @@ -1429,3 +1431,42 @@ fn to_json<'reg, 'rc>(
out.write(&json)?;
Ok(())
}

// 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<semver::Version> = Lazy::new(|| {
let vault_version = get_web_vault_version();
// Use a single regex capture to extract version components
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
re.captures(&vault_version)
.and_then(|c| {
(c.len() == 4).then(|| {
format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str())
})
})
.and_then(|v| semver::Version::parse(&v).ok())
.unwrap_or_else(|| semver::Version::parse("2024.6.2").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<semver::Version> = Lazy::new(|| {
let vw_version = crate::VERSION.unwrap_or("1.32.5");
// Use a single regex capture to extract version components
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
re.captures(vw_version)
.and_then(|c| {
(c.len() == 4).then(|| {
format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str())
})
})
.and_then(|v| semver::Version::parse(&v).ok())
.unwrap_or_else(|| semver::Version::parse("1.32.5").unwrap())
});

handlebars::handlebars_helper!(webver: | web_vault_version: String |
semver::VersionReq::parse(&web_vault_version).expect("Invalid web-vault version compare string").matches(&WEB_VAULT_VERSION)
);
handlebars::handlebars_helper!(vwver: | vw_version: String |
semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION)
);
Loading