From ef2695de0cb81feaa5cab8045f0bff71ab3e8c71 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk <509385+stefan0xC@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:21:44 +0100 Subject: [PATCH 1/3] improve admin invite (#5403) * check for admin invite * refactor the invitation logic * cleanup check for undefined token * prevent wrong user from accepting invitation --- src/api/admin.rs | 9 ++- src/api/core/organizations.rs | 130 ++++++++++++++++------------------ src/api/core/public.rs | 10 +-- src/auth.rs | 8 +-- src/mail.rs | 16 ++--- 5 files changed, 77 insertions(+), 96 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index c215d987..31159816 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -99,6 +99,7 @@ const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z"; const BASE_TEMPLATE: &str = "admin/base"; const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000"; +pub const FAKE_ADMIN_UUID: &str = "00000000-0000-0000-0000-000000000000"; fn admin_path() -> String { format!("{}{}", CONFIG.domain_path(), ADMIN_PATH) @@ -299,7 +300,9 @@ async fn invite_user(data: Json, _token: AdminToken, mut conn: DbCon async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult { if CONFIG.mail_enabled() { - mail::send_invite(user, None, None, &CONFIG.invitation_org_name(), None).await + let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); + let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); + mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await } else { let invitation = Invitation::new(&user.email); invitation.save(conn).await @@ -475,7 +478,9 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, mut conn: DbCon } if CONFIG.mail_enabled() { - mail::send_invite(&user, None, None, &CONFIG.invitation_org_name(), None).await + let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); + let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); + mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await } else { Ok(()) } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 45e5e0a3..efaa4ac1 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -4,6 +4,7 @@ use rocket::Route; use serde_json::Value; use std::collections::{HashMap, HashSet}; +use crate::api::admin::FAKE_ADMIN_UUID; use crate::{ api::{ core::{log_event, two_factor, CipherSyncData, CipherSyncType}, @@ -971,8 +972,8 @@ async fn send_invite( if let Err(e) = mail::send_invite( &user, - Some(org_id.clone()), - Some(new_member.uuid.clone()), + org_id.clone(), + new_member.uuid.clone(), &org_name, Some(headers.user.email.clone()), ) @@ -1098,14 +1099,7 @@ async fn _reinvite_member( }; if CONFIG.mail_enabled() { - mail::send_invite( - &user, - Some(org_id.clone()), - Some(member.uuid), - &org_name, - Some(invited_by_email.to_string()), - ) - .await?; + mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_string())).await?; } else if user.password_hash.is_empty() { let invitation = Invitation::new(&user.email); invitation.save(conn).await?; @@ -1131,79 +1125,81 @@ async fn accept_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, + headers: Headers, mut conn: DbConn, ) -> EmptyResult { // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner(); let claims = decode_invite(&data.token)?; - // If a claim does not have a member_id or it does not match the one in from the URI, something is wrong. - match &claims.member_id { - Some(ou_id) if ou_id.eq(&member_id) => {} - _ => err!("Error accepting the invitation", "Claim does not match the member_id"), + // Don't allow other users from accepting an invitation. + if !claims.email.eq(&headers.user.email) { + err!("Invitation was issued to a different account", "Claim does not match user_id") } - match User::find_by_mail(&claims.email, &mut conn).await { - Some(user) => { - Invitation::take(&claims.email, &mut conn).await; + // If a claim does not have a member_id or it does not match the one in from the URI, something is wrong. + if !claims.member_id.eq(&member_id) { + err!("Error accepting the invitation", "Claim does not match the member_id") + } - if let (Some(member), Some(org)) = (&claims.member_id, &claims.org_id) { - let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else { - err!("Error accepting the invitation") - }; + let member = &claims.member_id; + let org = &claims.org_id; - if member.status != MembershipStatus::Invited as i32 { - err!("User already accepted the invitation") - } + Invitation::take(&claims.email, &mut conn).await; - let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await; - if data.reset_password_key.is_none() && master_password_required { - err!("Reset password key is required, but not provided."); - } + // skip invitation logic when we were invited via the /admin panel + if **member != FAKE_ADMIN_UUID { + let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else { + err!("Error accepting the invitation") + }; - // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type - // It returns different error messages per function. - if member.atype < MembershipType::Admin { - match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await { - Ok(_) => {} - Err(OrgPolicyErr::TwoFactorMissing) => { - if CONFIG.email_2fa_auto_fallback() { - two_factor::email::activate_email_2fa(&user, &mut conn).await?; - } else { - err!("You cannot join this organization until you enable two-step login on your user account"); - } - } - Err(OrgPolicyErr::SingleOrgEnforced) => { - err!("You cannot join this organization because you are a member of an organization which forbids it"); - } + if member.status != MembershipStatus::Invited as i32 { + err!("User already accepted the invitation") + } + + let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await; + if data.reset_password_key.is_none() && master_password_required { + err!("Reset password key is required, but not provided."); + } + + // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type + // It returns different error messages per function. + if member.atype < MembershipType::Admin { + match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if CONFIG.email_2fa_auto_fallback() { + two_factor::email::activate_email_2fa(&headers.user, &mut conn).await?; + } else { + err!("You cannot join this organization until you enable two-step login on your user account"); } } - - member.status = MembershipStatus::Accepted as i32; - - if master_password_required { - member.reset_password_key = data.reset_password_key; + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot join this organization because you are a member of an organization which forbids it"); } - - member.save(&mut conn).await?; } } - None => err!("Invited user not found"), + + member.status = MembershipStatus::Accepted as i32; + + if master_password_required { + member.reset_password_key = data.reset_password_key; + } + + member.save(&mut conn).await?; } if CONFIG.mail_enabled() { - let mut org_name = CONFIG.invitation_org_name(); - if let Some(org_id) = &claims.org_id { - org_name = match Organization::find_by_uuid(org_id, &mut conn).await { + if let Some(invited_by_email) = &claims.invited_by_email { + let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await { Some(org) => org.name, None => err!("Organization not found."), }; - }; - if let Some(invited_by_email) = &claims.invited_by_email { // User was invited to an organization, so they must be confirmed manually after acceptance mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?; } else { // User was invited from /admin, so they are automatically confirmed + let org_name = CONFIG.invitation_org_name(); mail::send_invite_confirmed(&claims.email, &org_name).await?; } } @@ -1825,23 +1821,17 @@ async fn list_policies(org_id: OrganizationId, _headers: AdminHeaders, mut conn: #[get("/organizations//policies/token?")] async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbConn) -> JsonResult { - // web-vault 2024.6.2 seems to send these values and cause logs to output errors - // Catch this and prevent errors in the logs - // TODO: CleanUp after 2024.6.x is not used anymore. - if org_id.as_ref() == "undefined" && token == "undefined" { - return Ok(Json(json!({}))); - } - let invite = decode_invite(token)?; - let Some(invite_org_id) = invite.org_id else { - err!("Invalid token") - }; - - if invite_org_id != org_id { + if invite.org_id != org_id { err!("Token doesn't match request organization"); } + // exit early when we have been invited via /admin panel + if org_id.as_ref() == FAKE_ADMIN_UUID { + return Ok(Json(json!({}))); + } + // TODO: We receive the invite token as ?token=<>, validate it contains the org id let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await; let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); @@ -2141,8 +2131,8 @@ async fn import(org_id: OrganizationId, data: Json, headers: Head mail::send_invite( &user, - Some(org_id.clone()), - Some(new_member.uuid.clone()), + org_id.clone(), + new_member.uuid.clone(), &org_name, Some(headers.user.email.clone()), ) diff --git a/src/api/core/public.rs b/src/api/core/public.rs index cd524db6..1c85ae1b 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -119,14 +119,8 @@ async fn ldap_import(data: Json, token: PublicToken, mut conn: Db None => err!("Error looking up organization"), }; - if let Err(e) = mail::send_invite( - &user, - Some(org_id.clone()), - Some(new_member.uuid.clone()), - &org_name, - Some(org_email), - ) - .await + if let Err(e) = + mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await { // Upon error delete the user, invite and org member records when needed if user_created { diff --git a/src/auth.rs b/src/auth.rs index 2d5f79fc..e1dd71f6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -194,16 +194,16 @@ pub struct InviteJwtClaims { pub sub: UserId, pub email: String, - pub org_id: Option, - pub member_id: Option, + pub org_id: OrganizationId, + pub member_id: MembershipId, pub invited_by_email: Option, } pub fn generate_invite_claims( user_id: UserId, email: String, - org_id: Option, - member_id: Option, + org_id: OrganizationId, + member_id: MembershipId, invited_by_email: Option, ) -> InviteJwtClaims { let time_now = Utc::now(); diff --git a/src/mail.rs b/src/mail.rs index 68ab7413..410bac4b 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -259,8 +259,8 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) -> pub async fn send_invite( user: &User, - org_id: Option, - member_id: Option, + org_id: OrganizationId, + member_id: MembershipId, org_name: &str, invited_by_email: Option, ) -> EmptyResult { @@ -272,22 +272,14 @@ pub async fn send_invite( invited_by_email, ); let invite_token = encode_jwt(&claims); - let org_id = match org_id { - Some(ref org_id) => org_id.as_ref(), - None => "_", - }; - let member_id = match member_id { - Some(ref member_id) => member_id.as_ref(), - None => "_", - }; let mut query = url::Url::parse("https://query.builder").unwrap(); { let mut query_params = query.query_pairs_mut(); query_params .append_pair("email", &user.email) .append_pair("organizationName", org_name) - .append_pair("organizationId", org_id) - .append_pair("organizationUserId", member_id) + .append_pair("organizationId", &org_id) + .append_pair("organizationUserId", &member_id) .append_pair("token", &invite_token); if user.private_key.is_some() { query_params.append_pair("orgUserHasExistingUser", "true"); From d1dee046155b73b5a9bc05b1c75b197be8bd9110 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Tue, 21 Jan 2025 23:33:41 +0100 Subject: [PATCH 2/3] Add manage role for collections and groups (#5386) * Add manage role for collections and groups This commit will add the manage role/column to collections and groups. We need this to allow users part of a collection either directly or via groups to be able to delete ciphers. Without this, they are only able to either edit or view them when using new clients, since these check the manage role. Still trying to keep it compatible with previous versions and able to revert to an older Vaultwarden version and the `access_all` feature of the older installations. In a future version we should really check and fix these rights and create some kind of migration step to also remove the `access_all` feature and convert that to a `manage` option. But this commit at least creates the base for this already. This should resolve #5367 Signed-off-by: BlackDex * Fix an issue with access_all If owners or admins do not have the `access_all` flag set, in case they do not want to see all collection on the password manager view, they didn't see any collections at all anymore. This should fix that they are still able to view all the collections and have access to it. Signed-off-by: BlackDex --------- Signed-off-by: BlackDex --- .../2025-01-09-172300_add_manage/down.sql | 0 .../mysql/2025-01-09-172300_add_manage/up.sql | 5 ++ .../2025-01-09-172300_add_manage/down.sql | 0 .../2025-01-09-172300_add_manage/up.sql | 5 ++ .../2025-01-09-172300_add_manage/down.sql | 0 .../2025-01-09-172300_add_manage/up.sql | 5 ++ src/api/core/organizations.rs | 52 ++++++----- src/api/icons.rs | 4 +- src/api/notifications.rs | 2 - src/db/models/cipher.rs | 50 ++++++----- src/db/models/collection.rs | 90 +++++++++++++++---- src/db/models/group.rs | 19 +++- src/db/models/organization.rs | 16 ++-- src/db/schemas/mysql/schema.rs | 2 + src/db/schemas/postgresql/schema.rs | 2 + src/db/schemas/sqlite/schema.rs | 2 + 16 files changed, 184 insertions(+), 70 deletions(-) create mode 100644 migrations/mysql/2025-01-09-172300_add_manage/down.sql create mode 100644 migrations/mysql/2025-01-09-172300_add_manage/up.sql create mode 100644 migrations/postgresql/2025-01-09-172300_add_manage/down.sql create mode 100644 migrations/postgresql/2025-01-09-172300_add_manage/up.sql create mode 100644 migrations/sqlite/2025-01-09-172300_add_manage/down.sql create mode 100644 migrations/sqlite/2025-01-09-172300_add_manage/up.sql diff --git a/migrations/mysql/2025-01-09-172300_add_manage/down.sql b/migrations/mysql/2025-01-09-172300_add_manage/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/mysql/2025-01-09-172300_add_manage/up.sql b/migrations/mysql/2025-01-09-172300_add_manage/up.sql new file mode 100644 index 00000000..e234cc6e --- /dev/null +++ b/migrations/mysql/2025-01-09-172300_add_manage/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE users_collections +ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE collections_groups +ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2025-01-09-172300_add_manage/down.sql b/migrations/postgresql/2025-01-09-172300_add_manage/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/postgresql/2025-01-09-172300_add_manage/up.sql b/migrations/postgresql/2025-01-09-172300_add_manage/up.sql new file mode 100644 index 00000000..e234cc6e --- /dev/null +++ b/migrations/postgresql/2025-01-09-172300_add_manage/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE users_collections +ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE collections_groups +ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/2025-01-09-172300_add_manage/down.sql b/migrations/sqlite/2025-01-09-172300_add_manage/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2025-01-09-172300_add_manage/up.sql b/migrations/sqlite/2025-01-09-172300_add_manage/up.sql new file mode 100644 index 00000000..4b4b07a5 --- /dev/null +++ b/migrations/sqlite/2025-01-09-172300_add_manage/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE users_collections +ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE + +ALTER TABLE collections_groups +ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index efaa4ac1..bd4c1a77 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -140,6 +140,7 @@ struct NewCollectionGroupData { hide_passwords: bool, id: GroupId, read_only: bool, + manage: bool, } #[derive(Deserialize)] @@ -148,6 +149,7 @@ struct NewCollectionMemberData { hide_passwords: bool, id: MembershipId, read_only: bool, + manage: bool, } #[derive(Deserialize)] @@ -362,18 +364,13 @@ async fn get_org_collections_details( || (CONFIG.org_groups_enabled() && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.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 = col_users .iter() - .filter(|collection_user| collection_user.collection_uuid == col.uuid) - .map(|collection_user| { - collection_user.to_json_details_for_user( - *membership_type.get(&collection_user.membership_uuid).unwrap_or(&(MembershipType::User as i32)), + .filter(|collection_member| collection_member.collection_uuid == col.uuid) + .map(|collection_member| { + collection_member.to_json_details_for_member( + *membership_type.get(&collection_member.membership_uuid).unwrap_or(&(MembershipType::User as i32)), ) }) .collect(); @@ -437,7 +434,7 @@ async fn post_organization_collections( .await; for group in data.groups { - CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords) + CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage) .save(&mut conn) .await?; } @@ -451,12 +448,19 @@ async fn post_organization_collections( continue; } - CollectionUser::save(&member.user_uuid, &collection.uuid, user.read_only, user.hide_passwords, &mut conn) - .await?; + CollectionUser::save( + &member.user_uuid, + &collection.uuid, + user.read_only, + user.hide_passwords, + user.manage, + &mut conn, + ) + .await?; } if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all { - CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, &mut conn).await?; + CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, false, &mut conn).await?; } Ok(Json(collection.to_json())) @@ -513,7 +517,9 @@ async fn post_organization_collection_update( CollectionGroup::delete_all_by_collection(&col_id, &mut conn).await?; for group in data.groups { - CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords).save(&mut conn).await?; + CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage) + .save(&mut conn) + .await?; } CollectionUser::delete_all_by_collection(&col_id, &mut conn).await?; @@ -527,7 +533,8 @@ async fn post_organization_collection_update( continue; } - CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, &mut conn).await?; + CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &mut conn) + .await?; } Ok(Json(collection.to_json_details(&headers.user.uuid, None, &mut conn).await)) @@ -685,10 +692,10 @@ async fn get_org_collection_detail( CollectionUser::find_by_collection_swap_user_uuid_with_member_uuid(&collection.uuid, &mut conn) .await .iter() - .map(|collection_user| { - collection_user.to_json_details_for_user( + .map(|collection_member| { + collection_member.to_json_details_for_member( *membership_type - .get(&collection_user.membership_uuid) + .get(&collection_member.membership_uuid) .unwrap_or(&(MembershipType::User as i32)), ) }) @@ -758,7 +765,7 @@ async fn put_collection_users( continue; } - CollectionUser::save(&user.user_uuid, &col_id, d.read_only, d.hide_passwords, &mut conn).await?; + CollectionUser::save(&user.user_uuid, &col_id, d.read_only, d.hide_passwords, d.manage, &mut conn).await?; } Ok(()) @@ -866,6 +873,7 @@ struct CollectionData { id: CollectionId, read_only: bool, hide_passwords: bool, + manage: bool, } #[derive(Deserialize)] @@ -874,6 +882,7 @@ struct MembershipData { id: MembershipId, read_only: bool, hide_passwords: bool, + manage: bool, } #[derive(Deserialize)] @@ -1012,6 +1021,7 @@ async fn send_invite( &collection.uuid, col.read_only, col.hide_passwords, + col.manage, &mut conn, ) .await?; @@ -1504,6 +1514,7 @@ async fn edit_member( &collection.uuid, col.read_only, col.hide_passwords, + col.manage, &mut conn, ) .await?; @@ -2475,11 +2486,12 @@ struct SelectedCollection { id: CollectionId, read_only: bool, hide_passwords: bool, + manage: bool, } impl SelectedCollection { pub fn to_collection_group(&self, groups_uuid: GroupId) -> CollectionGroup { - CollectionGroup::new(self.id.clone(), groups_uuid, self.read_only, self.hide_passwords) + CollectionGroup::new(self.id.clone(), groups_uuid, self.read_only, self.hide_passwords, self.manage) } } diff --git a/src/api/icons.rs b/src/api/icons.rs index 921d48b9..fc4e0ccf 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -291,9 +291,7 @@ fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &m TAG_HEAD if token.closing => { break; } - _ => { - continue; - } + _ => {} } } diff --git a/src/api/notifications.rs b/src/api/notifications.rs index a8083a9f..de97be6f 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -157,7 +157,6 @@ fn websockets_hub<'r>( if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) { yield Message::binary(INITIAL_RESPONSE); - continue; } } @@ -225,7 +224,6 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) { yield Message::binary(INITIAL_RESPONSE); - continue; } } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 3b8d2384..c751491e 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -158,16 +158,16 @@ impl Cipher { // We don't need these values at all for Organizational syncs // Skip any other database calls if this is the case and just return false. - let (read_only, hide_passwords) = if sync_type == CipherSyncType::User { + let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { match self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { - Some((ro, hp)) => (ro, hp), + Some((ro, hp, mn)) => (ro, hp, mn), None => { error!("Cipher ownership assertion failure"); - (true, true) + (true, true, false) } } } else { - (false, false) + (false, false, false) }; let fields_json: Vec<_> = self @@ -567,14 +567,14 @@ impl Cipher { /// Returns the user's access restrictions to this cipher. A return value /// of None means that this cipher does not belong to the user, and is /// not in any collection the user has access to. Otherwise, the user has - /// access to this cipher, and Some(read_only, hide_passwords) represents + /// access to this cipher, and Some(read_only, hide_passwords, manage) represents /// the access restrictions. pub async fn get_access_restrictions( &self, user_uuid: &UserId, cipher_sync_data: Option<&CipherSyncData>, conn: &mut DbConn, - ) -> Option<(bool, bool)> { + ) -> Option<(bool, bool, bool)> { // Check whether this cipher is directly owned by the user, or is in // a collection that the user has full access to. If so, there are no // access restrictions. @@ -582,21 +582,21 @@ impl Cipher { || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await { - return Some((false, false)); + return Some((false, false, true)); } let rows = if let Some(cipher_sync_data) = cipher_sync_data { - let mut rows: Vec<(bool, bool)> = Vec::new(); + let mut rows: Vec<(bool, bool, bool)> = Vec::new(); if let Some(collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { for collection in collections { //User permissions - if let Some(uc) = cipher_sync_data.user_collections.get(collection) { - rows.push((uc.read_only, uc.hide_passwords)); + if let Some(cu) = cipher_sync_data.user_collections.get(collection) { + rows.push((cu.read_only, cu.hide_passwords, cu.manage)); } //Group permissions if let Some(cg) = cipher_sync_data.user_collections_groups.get(collection) { - rows.push((cg.read_only, cg.hide_passwords)); + rows.push((cg.read_only, cg.hide_passwords, cg.manage)); } } } @@ -623,15 +623,21 @@ impl Cipher { // booleans and this behavior isn't portable anyway. let mut read_only = true; let mut hide_passwords = true; - for (ro, hp) in rows.iter() { + let mut manage = false; + for (ro, hp, mn) in rows.iter() { read_only &= ro; hide_passwords &= hp; + manage &= mn; } - Some((read_only, hide_passwords)) + Some((read_only, hide_passwords, manage)) } - async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &mut DbConn) -> Vec<(bool, bool)> { + async fn get_user_collections_access_flags( + &self, + user_uuid: &UserId, + conn: &mut DbConn, + ) -> Vec<(bool, bool, bool)> { db_run! {conn: { // Check whether this cipher is in any collections accessible to the // user. If so, retrieve the access flags for each collection. @@ -642,13 +648,17 @@ impl Cipher { .inner_join(users_collections::table.on( ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) .and(users_collections::user_uuid.eq(user_uuid)))) - .select((users_collections::read_only, users_collections::hide_passwords)) - .load::<(bool, bool)>(conn) + .select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) + .load::<(bool, bool, bool)>(conn) .expect("Error getting user access restrictions") }} } - async fn get_group_collections_access_flags(&self, user_uuid: &UserId, conn: &mut DbConn) -> Vec<(bool, bool)> { + async fn get_group_collections_access_flags( + &self, + user_uuid: &UserId, + conn: &mut DbConn, + ) -> Vec<(bool, bool, bool)> { if !CONFIG.org_groups_enabled() { return Vec::new(); } @@ -668,15 +678,15 @@ impl Cipher { users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) .filter(users_organizations::user_uuid.eq(user_uuid)) - .select((collections_groups::read_only, collections_groups::hide_passwords)) - .load::<(bool, bool)>(conn) + .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) + .load::<(bool, bool, bool)>(conn) .expect("Error getting group access restrictions") }} } pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool { match self.get_access_restrictions(user_uuid, None, conn).await { - Some((read_only, _hide_passwords)) => !read_only, + Some((read_only, _hide_passwords, manage)) => !read_only || manage, None => false, } } diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index 2302b493..2286ee04 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -27,6 +27,7 @@ db_object! { pub collection_uuid: CollectionId, pub read_only: bool, pub hide_passwords: bool, + pub manage: bool, } #[derive(Identifiable, Queryable, Insertable)] @@ -83,18 +84,26 @@ impl Collection { cipher_sync_data: Option<&crate::api::core::CipherSyncData>, conn: &mut DbConn, ) -> Value { - let (read_only, hide_passwords, can_manage) = if let Some(cipher_sync_data) = cipher_sync_data { + let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data { match cipher_sync_data.members.get(&self.org_uuid) { - // Only for Manager types Bitwarden returns true for the can_manage option - // Owners and Admins always have true + // Only for Manager types Bitwarden returns true for the manage option + // Owners and Admins always have true. Users are not able to have full access Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) => { // Only let a manager manage collections when the have full read/write access let is_manager = m.atype == MembershipType::Manager; - if let Some(uc) = cipher_sync_data.user_collections.get(&self.uuid) { - (uc.read_only, uc.hide_passwords, is_manager && !uc.read_only && !uc.hide_passwords) + if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { + ( + cu.read_only, + cu.hide_passwords, + cu.manage || (is_manager && !cu.read_only && !cu.hide_passwords), + ) } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) { - (cg.read_only, cg.hide_passwords, is_manager && !cg.read_only && !cg.hide_passwords) + ( + cg.read_only, + cg.hide_passwords, + cg.manage || (is_manager && !cg.read_only && !cg.hide_passwords), + ) } else { (false, false, false) } @@ -104,17 +113,14 @@ impl Collection { } else { match Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await { Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), + Some(_) if self.is_manageable_by_user(user_uuid, conn).await => (false, false, true), Some(m) => { let is_manager = m.atype == MembershipType::Manager; let read_only = !self.is_writable_by_user(user_uuid, conn).await; let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await; (read_only, hide_passwords, is_manager && !read_only && !hide_passwords) } - _ => ( - !self.is_writable_by_user(user_uuid, conn).await, - self.hide_passwords_for_user(user_uuid, conn).await, - false, - ), + _ => (true, true, false), } }; @@ -122,7 +128,7 @@ impl Collection { json_object["object"] = json!("collectionDetails"); json_object["readOnly"] = json!(read_only); json_object["hidePasswords"] = json!(hide_passwords); - json_object["manage"] = json!(can_manage); + json_object["manage"] = json!(manage); json_object } @@ -507,6 +513,52 @@ impl Collection { .unwrap_or(0) != 0 }} } + + pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool { + let user_uuid = user_uuid.to_string(); + db_run! { conn: { + collections::table + .left_join(users_collections::table.on( + users_collections::collection_uuid.eq(collections::uuid).and( + users_collections::user_uuid.eq(user_uuid.clone()) + ) + )) + .left_join(users_organizations::table.on( + collections::org_uuid.eq(users_organizations::org_uuid).and( + users_organizations::user_uuid.eq(user_uuid) + ) + )) + .left_join(groups_users::table.on( + groups_users::users_organizations_uuid.eq(users_organizations::uuid) + )) + .left_join(groups::table.on( + groups::uuid.eq(groups_users::groups_uuid) + )) + .left_join(collections_groups::table.on( + collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( + collections_groups::collections_uuid.eq(collections::uuid) + ) + )) + .filter(collections::uuid.eq(&self.uuid)) + .filter( + users_collections::collection_uuid.eq(&self.uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection + users_organizations::access_all.eq(true).or( // access_all in Organization + users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner + )).or( + groups::access_all.eq(true) // access_all in groups + ).or( // access via groups + groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( + collections_groups::collections_uuid.is_not_null().and( + collections_groups::manage.eq(true)) + ) + ) + ) + .count() + .first::(conn) + .ok() + .unwrap_or(0) != 0 + }} + } } /// Database methods @@ -537,7 +589,7 @@ impl CollectionUser { .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)) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .load::(conn) .expect("Error loading users_collections") .from_db() @@ -550,6 +602,7 @@ impl CollectionUser { collection_uuid: &CollectionId, read_only: bool, hide_passwords: bool, + manage: bool, conn: &mut DbConn, ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; @@ -562,6 +615,7 @@ impl CollectionUser { users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), )) .execute(conn) { @@ -576,6 +630,7 @@ impl CollectionUser { users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), )) .execute(conn) .map_res("Error adding user to collection") @@ -590,12 +645,14 @@ impl CollectionUser { users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), )) .on_conflict((users_collections::user_uuid, users_collections::collection_uuid)) .do_update() .set(( users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), + users_collections::manage.eq(manage), )) .execute(conn) .map_res("Error adding user to collection") @@ -636,7 +693,7 @@ impl CollectionUser { 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)) + .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .load::(conn) .expect("Error loading users_collections") .from_db() @@ -787,15 +844,17 @@ pub struct CollectionMembership { pub collection_uuid: CollectionId, pub read_only: bool, pub hide_passwords: bool, + pub manage: bool, } impl CollectionMembership { - pub fn to_json_details_for_user(&self, membership_type: i32) -> Value { + pub fn to_json_details_for_member(&self, membership_type: i32) -> Value { json!({ "id": self.membership_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, "manage": membership_type >= MembershipType::Admin + || self.manage || (membership_type == MembershipType::Manager && !self.read_only && !self.hide_passwords), @@ -810,6 +869,7 @@ impl From for CollectionMembership { collection_uuid: c.collection_uuid, read_only: c.read_only, hide_passwords: c.hide_passwords, + manage: c.manage, } } } diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 5a72418d..e85b8c05 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -29,6 +29,7 @@ db_object! { pub groups_uuid: GroupId, pub read_only: bool, pub hide_passwords: bool, + pub manage: bool, } #[derive(Identifiable, Queryable, Insertable)] @@ -92,7 +93,7 @@ impl Group { "id": entry.collections_uuid, "readOnly": entry.read_only, "hidePasswords": entry.hide_passwords, - "manage": !entry.read_only && !entry.hide_passwords, + "manage": entry.manage, }) }) .collect(); @@ -118,12 +119,19 @@ impl Group { } impl CollectionGroup { - pub fn new(collections_uuid: CollectionId, groups_uuid: GroupId, read_only: bool, hide_passwords: bool) -> Self { + pub fn new( + collections_uuid: CollectionId, + groups_uuid: GroupId, + read_only: bool, + hide_passwords: bool, + manage: bool, + ) -> Self { Self { collections_uuid, groups_uuid, read_only, hide_passwords, + manage, } } @@ -131,11 +139,12 @@ impl CollectionGroup { // 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 + // For backwards compaibility and migration proposes we keep checking read_only and hide_password json!({ "id": self.groups_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, - "manage": !self.read_only && !self.hide_passwords, + "manage": self.manage || (!self.read_only && !self.hide_passwords), }) } } @@ -319,6 +328,7 @@ impl CollectionGroup { collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(&self.read_only), collections_groups::hide_passwords.eq(&self.hide_passwords), + collections_groups::manage.eq(&self.manage), )) .execute(conn) { @@ -333,6 +343,7 @@ impl CollectionGroup { collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(&self.read_only), collections_groups::hide_passwords.eq(&self.hide_passwords), + collections_groups::manage.eq(&self.manage), )) .execute(conn) .map_res("Error adding group to collection") @@ -347,12 +358,14 @@ impl CollectionGroup { collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(self.read_only), collections_groups::hide_passwords.eq(self.hide_passwords), + collections_groups::manage.eq(self.manage), )) .on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid)) .do_update() .set(( collections_groups::read_only.eq(self.read_only), collections_groups::hide_passwords.eq(self.hide_passwords), + collections_groups::manage.eq(self.manage), )) .execute(conn) .map_res("Error adding group to collection") diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index af273804..6e6ee77a 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -522,13 +522,13 @@ impl Membership { .await .into_iter() .filter_map(|c| { - let (read_only, hide_passwords, can_manage) = if self.has_full_access() { + let (read_only, hide_passwords, manage) = if self.has_full_access() { (false, false, self.atype >= MembershipType::Manager) } else if let Some(cu) = cu.get(&c.uuid) { ( cu.read_only, cu.hide_passwords, - self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords, + cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords), ) // If previous checks failed it might be that this user has access via a group, but we should not return those elements here // Those are returned via a special group endpoint @@ -542,7 +542,7 @@ impl Membership { "id": c.uuid, "readOnly": read_only, "hidePasswords": hide_passwords, - "manage": can_manage, + "manage": manage, })) }) .collect() @@ -611,6 +611,7 @@ impl Membership { "id": self.uuid, "readOnly": col_user.read_only, "hidePasswords": col_user.hide_passwords, + "manage": col_user.manage, }) } @@ -622,11 +623,12 @@ impl Membership { CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn).await; collections .iter() - .map(|c| { + .map(|cu| { json!({ - "id": c.collection_uuid, - "readOnly": c.read_only, - "hidePasswords": c.hide_passwords, + "id": cu.collection_uuid, + "readOnly": cu.read_only, + "hidePasswords": cu.hide_passwords, + "manage": cu.manage, }) }) .collect() diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index fa84ed05..573e4503 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -226,6 +226,7 @@ table! { collection_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, + manage -> Bool, } } @@ -295,6 +296,7 @@ table! { groups_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, + manage -> Bool, } } diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index d1ea4b02..a3707adf 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -226,6 +226,7 @@ table! { collection_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, + manage -> Bool, } } @@ -295,6 +296,7 @@ table! { groups_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, + manage -> Bool, } } diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index d1ea4b02..a3707adf 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -226,6 +226,7 @@ table! { collection_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, + manage -> Bool, } } @@ -295,6 +296,7 @@ table! { groups_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, + manage -> Bool, } } From c0be36a17fa8f239a8b109024b141a6ebdcd9439 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk <509385+stefan0xC@users.noreply.github.com> Date: Thu, 23 Jan 2025 12:30:55 +0100 Subject: [PATCH 3/3] update web-vault to v2025.1.1 and add /api/devices (#5422) * add /api/devices endpoints * load pending device requests * order pending authrequests by creation date * update web-vault to v2025.1.1 --- docker/DockerSettings.yaml | 4 +-- docker/Dockerfile.alpine | 12 ++++---- docker/Dockerfile.debian | 12 ++++---- src/api/core/accounts.rs | 28 +++++++++++++++-- src/db/models/auth_request.rs | 24 ++++++++++++++- src/db/models/device.rs | 58 +++++++++++++++++++++++++++++++++-- 6 files changed, 117 insertions(+), 21 deletions(-) diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index a38e327a..7a2ea17b 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,6 +1,6 @@ --- -vault_version: "v2025.1.0" -vault_image_digest: "sha256:72d636334b4ad6fe9ba1d12e0cda562cd31772cf28772f6b2fe4121a537b72a8" +vault_version: "v2025.1.1" +vault_image_digest: "sha256:cb6b2095a4afc1d9d243a33f6d09211f40e3d82c7ae829fd025df5ff175a4918" # Cross Compile Docker Helper Scripts v1.6.1 # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index 2757f6cc..3335aa62 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2025.1.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.1.0 -# [docker.io/vaultwarden/web-vault@sha256:72d636334b4ad6fe9ba1d12e0cda562cd31772cf28772f6b2fe4121a537b72a8] +# $ docker pull docker.io/vaultwarden/web-vault:v2025.1.1 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.1.1 +# [docker.io/vaultwarden/web-vault@sha256:cb6b2095a4afc1d9d243a33f6d09211f40e3d82c7ae829fd025df5ff175a4918] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:72d636334b4ad6fe9ba1d12e0cda562cd31772cf28772f6b2fe4121a537b72a8 -# [docker.io/vaultwarden/web-vault:v2025.1.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:cb6b2095a4afc1d9d243a33f6d09211f40e3d82c7ae829fd025df5ff175a4918 +# [docker.io/vaultwarden/web-vault:v2025.1.1] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:72d636334b4ad6fe9ba1d12e0cda562cd31772cf28772f6b2fe4121a537b72a8 AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:cb6b2095a4afc1d9d243a33f6d09211f40e3d82c7ae829fd025df5ff175a4918 AS vault ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index ff1ff453..cf45a36d 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2025.1.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.1.0 -# [docker.io/vaultwarden/web-vault@sha256:72d636334b4ad6fe9ba1d12e0cda562cd31772cf28772f6b2fe4121a537b72a8] +# $ docker pull docker.io/vaultwarden/web-vault:v2025.1.1 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.1.1 +# [docker.io/vaultwarden/web-vault@sha256:cb6b2095a4afc1d9d243a33f6d09211f40e3d82c7ae829fd025df5ff175a4918] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:72d636334b4ad6fe9ba1d12e0cda562cd31772cf28772f6b2fe4121a537b72a8 -# [docker.io/vaultwarden/web-vault:v2025.1.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:cb6b2095a4afc1d9d243a33f6d09211f40e3d82c7ae829fd025df5ff175a4918 +# [docker.io/vaultwarden/web-vault:v2025.1.1] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:72d636334b4ad6fe9ba1d12e0cda562cd31772cf28772f6b2fe4121a537b72a8 AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:cb6b2095a4afc1d9d243a33f6d09211f40e3d82c7ae829fd025df5ff175a4918 AS vault ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 89dcadea..473c0c86 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -30,6 +30,7 @@ pub fn routes() -> Vec { profile, put_profile, post_profile, + put_avatar, get_public_keys, post_keys, post_password, @@ -42,9 +43,8 @@ pub fn routes() -> Vec { post_verify_email_token, post_delete_recover, post_delete_recover_token, - post_device_token, - delete_account, post_delete_account, + delete_account, revision_date, password_hint, prelogin, @@ -52,7 +52,9 @@ pub fn routes() -> Vec { api_key, rotate_api_key, get_known_device, - put_avatar, + get_all_devices, + get_device, + post_device_token, put_device_token, put_clear_device_token, post_clear_device_token, @@ -1068,6 +1070,26 @@ impl<'r> FromRequest<'r> for KnownDevice { } } +#[get("/devices")] +async fn get_all_devices(headers: Headers, mut conn: DbConn) -> JsonResult { + let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &mut conn).await; + let devices = devices.iter().map(|device| device.to_json()).collect::>(); + + Ok(Json(json!({ + "data": devices, + "continuationToken": null, + "object": "list" + }))) +} + +#[get("/devices/identifier/")] +async fn get_device(device_id: DeviceId, headers: Headers, mut conn: DbConn) -> JsonResult { + let Some(device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &mut conn).await else { + err!("No device found"); + }; + Ok(Json(device.to_json())) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PushToken { diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index dd4f098a..d8ca3fac 100644 --- a/src/db/models/auth_request.rs +++ b/src/db/models/auth_request.rs @@ -1,8 +1,9 @@ use super::{DeviceId, OrganizationId, UserId}; -use crate::crypto::ct_eq; +use crate::{crypto::ct_eq, util::format_date}; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use macros::UuidFromParam; +use serde_json::Value; db_object! { #[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] @@ -64,6 +65,13 @@ impl AuthRequest { authentication_date: None, } } + + pub fn to_json_for_pending_device(&self) -> Value { + json!({ + "id": self.uuid, + "creationDate": format_date(&self.creation_date), + }) + } } use crate::db::DbConn; @@ -133,6 +141,20 @@ impl AuthRequest { }} } + pub async fn find_by_user_and_requested_device( + user_uuid: &UserId, + device_uuid: &DeviceId, + conn: &mut DbConn, + ) -> Option { + db_run! {conn: { + auth_requests::table + .filter(auth_requests::user_uuid.eq(user_uuid)) + .filter(auth_requests::request_device_identifier.eq(device_uuid)) + .order_by(auth_requests::creation_date.desc()) + .first::(conn).ok().from_db() + }} + } + pub async fn find_created_before(dt: &NaiveDateTime, conn: &mut DbConn) -> Vec { db_run! {conn: { auth_requests::table diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 0f1afd0f..74ef46d2 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,8 +1,9 @@ use chrono::{NaiveDateTime, Utc}; use derive_more::{Display, From}; +use serde_json::Value; -use super::UserId; -use crate::{crypto, CONFIG}; +use super::{AuthRequest, UserId}; +use crate::{crypto, util::format_date, CONFIG}; use macros::IdFromParam; db_object! { @@ -23,7 +24,6 @@ db_object! { pub push_token: Option, pub refresh_token: String, - pub twofactor_remember: Option, } } @@ -49,6 +49,18 @@ impl Device { } } + pub fn to_json(&self) -> Value { + json!({ + "id": self.uuid, + "name": self.name, + "type": self.atype, + "identifier": self.push_uuid, + "creationDate": format_date(&self.created_at), + "isTrusted": false, + "object":"device" + }) + } + pub fn refresh_twofactor_remember(&mut self) -> String { use data_encoding::BASE64; let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); @@ -125,6 +137,36 @@ impl Device { } } +pub struct DeviceWithAuthRequest { + pub device: Device, + pub pending_auth_request: Option, +} + +impl DeviceWithAuthRequest { + pub fn to_json(&self) -> Value { + let auth_request = match &self.pending_auth_request { + Some(auth_request) => auth_request.to_json_for_pending_device(), + None => Value::Null, + }; + json!({ + "id": self.device.uuid, + "name": self.device.name, + "type": self.device.atype, + "identifier": self.device.push_uuid, + "creationDate": format_date(&self.device.created_at), + "devicePendingAuthRequest": auth_request, + "isTrusted": false, + "object": "device", + }) + } + + pub fn from(c: Device, a: Option) -> Self { + Self { + device: c, + pending_auth_request: a, + } + } +} use crate::db::DbConn; use crate::api::EmptyResult; @@ -171,6 +213,16 @@ impl Device { }} } + pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec { + let devices = Self::find_by_user(user_uuid, conn).await; + let mut result = Vec::new(); + for device in devices { + let auth_request = AuthRequest::find_by_user_and_requested_device(user_uuid, &device.uuid, conn).await; + result.push(DeviceWithAuthRequest::from(device, auth_request)); + } + result + } + pub async fn find_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec { db_run! { conn: { devices::table