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/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/admin.rs b/src/api/admin.rs index f3fb0b1f..c653f4c8 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/accounts.rs b/src/api/core/accounts.rs index fef3323c..a959064b 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, @@ -43,9 +44,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, @@ -53,7 +53,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, @@ -1157,6 +1159,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/api/core/organizations.rs b/src/api/core/organizations.rs index 0c4499b1..3b694bd2 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}, @@ -142,6 +143,7 @@ struct NewCollectionGroupData { hide_passwords: bool, id: GroupId, read_only: bool, + manage: bool, } #[derive(Deserialize)] @@ -150,6 +152,7 @@ struct NewCollectionMemberData { hide_passwords: bool, id: MembershipId, read_only: bool, + manage: bool, } #[derive(Deserialize)] @@ -375,18 +378,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(); @@ -450,7 +448,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?; } @@ -464,12 +462,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())) @@ -526,7 +531,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?; @@ -540,7 +547,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)) @@ -698,10 +706,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)), ) }) @@ -771,7 +779,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(()) @@ -891,6 +899,7 @@ struct CollectionData { id: CollectionId, read_only: bool, hide_passwords: bool, + manage: bool, } #[derive(Deserialize)] @@ -899,6 +908,7 @@ struct MembershipData { id: MembershipId, read_only: bool, hide_passwords: bool, + manage: bool, } #[derive(Deserialize)] @@ -997,8 +1007,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()), ) @@ -1037,6 +1047,7 @@ async fn send_invite( &collection.uuid, col.read_only, col.hide_passwords, + col.manage, &mut conn, ) .await?; @@ -1124,14 +1135,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?; @@ -1163,21 +1167,24 @@ async fn accept_invite( // 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)?; - let user = headers.user; + + // 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") + } // 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"), + if !claims.member_id.eq(&member_id) { + err!("Error accepting the invitation", "Claim does not match the member_id") } - if user.email != claims.email { - err!("Invitation claim does not match the user") - } + let member = &claims.member_id; + let org = &claims.org_id; Invitation::take(&claims.email, &mut conn).await; - if let (Some(member), Some(org)) = (&claims.member_id, &claims.org_id) { + // 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") }; @@ -1198,7 +1205,7 @@ async fn accept_invite( Ok(_) => {} Err(OrgPolicyErr::TwoFactorMissing) => { if CONFIG.email_2fa_auto_fallback() { - two_factor::email::activate_email_2fa(&user, &mut conn).await?; + 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"); } @@ -1219,18 +1226,16 @@ async fn accept_invite( } 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?; } } @@ -1535,6 +1540,7 @@ async fn edit_member( &collection.uuid, col.read_only, col.hide_passwords, + col.manage, &mut conn, ) .await?; @@ -1852,23 +1858,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(); @@ -2183,8 +2183,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()), ) @@ -2527,11 +2527,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/core/public.rs b/src/api/core/public.rs index cafbf97b..9cdd594f 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/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/auth.rs b/src/auth.rs index d5aea41e..80a54f61 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -272,16 +272,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/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/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/device.rs b/src/db/models/device.rs index 55af538e..3b4b501f 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -2,9 +2,10 @@ use chrono::{NaiveDateTime, Utc}; use data_encoding::{BASE64, BASE64URL}; use derive_more::{Display, From}; +use serde_json::Value; -use super::UserId; -use crate::crypto; +use super::{AuthRequest, UserId}; +use crate::{crypto, util::format_date}; use macros::IdFromParam; db_object! { @@ -25,7 +26,6 @@ db_object! { pub push_token: Option, pub refresh_token: String, - pub twofactor_remember: Option, } } @@ -51,6 +51,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 { let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); self.twofactor_remember = Some(twofactor_remember.clone()); @@ -71,6 +83,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; @@ -117,6 +159,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 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 40a74995..4f4ccd13 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 f5446ff2..a8d4a76a 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, } } @@ -312,6 +313,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 07792e1e..1ee20c6f 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, } } @@ -312,6 +313,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 07792e1e..1ee20c6f 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, } } @@ -312,6 +313,7 @@ table! { groups_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, + manage -> Bool, } } diff --git a/src/mail.rs b/src/mail.rs index 42b5aed4..27cdf5f8 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 CONFIG.sso_enabled() && CONFIG.sso_only() {