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))
Comment on lines +329 to +334
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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))
// Generate a HashMap to get the correct UserOrgType per user to determine the manage permission
// We use the user_uuid instead of the 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.user_uuid, uo.atype))

While the comment states that it uses the uuid (of the UserOrganization) instead of the user_uuid later the user_uuid is passed to get the users_org_type probably because CollectionUser only knows about user_uuid

Copy link
Contributor

@tessus tessus Dec 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been reading the code a bit and I think more comments would help.

e.g. in the struct UserOrganization, what's the uuid and user_uuid for? Someone can only understand that, if they read and understand the entire code.

And as you have shown here, the usage of these members is apparently not always that clear.

Don't take this the wrong way, I'd add the comments myself, but I doubt I understand all the different abstraction layers and levels in their entirety. I am also not saying that you should add those comments, but rather that I'd like to see more comments that explain certain connections and layers.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm almost certain i changed this to have it working at all because it wasn't. I'll have to revisit it.

Copy link
Contributor

@stefan0xC stefan0xC Dec 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I did not test if it still works. I just noticed the discrepancy when trying to apply your changes upon my newtype branch (#5320) because it would not even compile with the wrong id.
update: I have not tested all possible combinations but it seems to work just fine.

@tessus I'm not sure if comments would help. As I've shown they can be wrong or misleading. But to answer your question: Generally the uuid refers to an entity itself (e.g. the UserOrganization relation or whatever is stored in the users_organizations table) while it does not with a prefix (e.g. user_uuid refers to a User entity). I think the database schema is more consistent about this than the rest of the code base.

Copy link
Contributor

@stefan0xC stefan0xC Dec 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is an visualization of the relevant database relations
output9

Created with visualize-sqlite and dot...

...after dropping the other tables from a fresh db.sqlite3:

cp data/db.sqlite3 copy_of_db.sqlite3
for t in __diesel_schema_migrations attachments auth_requests ciphers ciphers_collections devices emergency_access event favorites folders folders_ciphers invitations org_policies organization_api_key sends twofactor twofactor_duo_ctx twofactor_incomplete
do 
    sqlite3 copy_of_db.sqlite3 "drop table $t"
done

visualize-sqlite copy_of_db.sqlite3  | dot -Tpng > image.png

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. Thanks for that. I was wondering why an n:m table would have its own uuid. However looking at the diagram, the naming is rather inconsistent and a bit confusing.

.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)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you are passing the user_uuid because that's what CollectionUser knows about:

pub struct CollectionUser {
pub user_uuid: String,

Copy link
Contributor

@stefan0xC stefan0xC Dec 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I looked further into it and I think part of what makes it tricky is that we have two functions that retrieve a CollectionUser with an user_uuid pointing to users_organizations.uuid instead of users.uuid which is probably why this is so confusing:

pub async fn find_by_organization(org_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
db_run! { conn: {
users_collections::table
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
.filter(collections::org_uuid.eq(org_uuid))
.inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)))
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords))
.load::<CollectionUserDb>(conn)

The second one at least is named accordingly
pub async fn find_by_collection_swap_user_uuid_with_org_user_uuid(
collection_uuid: &str,
conn: &mut DbConn,
) -> Vec<Self> {
db_run! { conn: {
users_collections::table
.filter(users_collections::collection_uuid.eq(collection_uuid))
.inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)))
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords))
.load::<CollectionUserDb>(conn)

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