mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-13 20:48:36 +02:00
[PR 2] Add administrative cipher access for Custom Roles (#7938)
* Separate administrative cipher access * Return NotFound for admin attachments and ungate assigned ciphers
This commit is contained in:
8 files changed
+995
-261
No files matched your search
+576
-220
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,9 @@ use crate::{
|
|||||||
auth::{AdminHeaders, Headers},
|
auth::{AdminHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
models::{
|
||||||
|
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
util::parse_date,
|
util::parse_date,
|
||||||
@@ -220,7 +222,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
// user can actually access it instead of trusting the provided cipher uuid.
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(cipher_uuid) = &event.cipher_id
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||||
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
&& cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await
|
||||||
&& let Some(org_id) = cipher.organization_uuid
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
{
|
{
|
||||||
log_event_impl(
|
log_event_impl(
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
|
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
|
||||||
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
|
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
|
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
||||||
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
|
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
||||||
|
custom_roles_enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -47,6 +48,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
bulk_delete_organization_collections,
|
bulk_delete_organization_collections,
|
||||||
post_bulk_collections,
|
post_bulk_collections,
|
||||||
get_org_details,
|
get_org_details,
|
||||||
|
get_assigned_org_details,
|
||||||
get_org_domain_sso_verified,
|
get_org_domain_sso_verified,
|
||||||
get_members,
|
get_members,
|
||||||
send_invite,
|
send_invite,
|
||||||
@@ -841,7 +843,7 @@ async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: Db
|
|||||||
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
|
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !headers.membership.has_full_access() {
|
if !headers.membership.can_edit_any_collection() {
|
||||||
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,7 +861,12 @@ async fn get_org_details_impl(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<Value, crate::Error> {
|
) -> Result<Value, crate::Error> {
|
||||||
let ciphers = Cipher::find_by_org(org_id, conn).await;
|
let ciphers = Cipher::find_by_org(org_id, conn).await;
|
||||||
let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
|
let mut cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
|
||||||
|
// Every cipher of the organization is listed, so list every collection of them too
|
||||||
|
cipher_sync_data.cipher_collections = HashMap::new();
|
||||||
|
for (cipher_id, collection_id) in Cipher::get_collections_with_cipher_by_organization(org_id, conn).await {
|
||||||
|
cipher_sync_data.cipher_collections.entry(cipher_id).or_default().push(collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
||||||
for c in ciphers {
|
for c in ciphers {
|
||||||
@@ -868,6 +875,39 @@ async fn get_org_details_impl(
|
|||||||
Ok(json!(ciphers_json))
|
Ok(json!(ciphers_json))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Admin Console lists these instead of every cipher of the organization for a member who may not edit every
|
||||||
|
// cipher, like a Custom member with Delete any collection only. As in Bitwarden, any confirmed member may list them.
|
||||||
|
#[get("/ciphers/organization-details/assigned?<data..>")]
|
||||||
|
async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
|
let Some(member) =
|
||||||
|
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await
|
||||||
|
else {
|
||||||
|
err_code!("Resource not found.", "User is not a confirmed member of the organization", Status::NotFound.code);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (assigned, unassigned) = Cipher::find_assigned_to_member(&member, &conn).await;
|
||||||
|
let mut ciphers_json = Vec::with_capacity(assigned.len() + unassigned.len());
|
||||||
|
// The ciphers of the member's own vault, as they are there
|
||||||
|
let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await;
|
||||||
|
for cipher in assigned {
|
||||||
|
ciphers_json.push(
|
||||||
|
cipher
|
||||||
|
.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The ciphers in no collection, as the member may use them in the Admin Console
|
||||||
|
for cipher in unassigned {
|
||||||
|
ciphers_json.push(cipher.to_json_org_admin(&headers.host, &headers.user.uuid, &conn).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"data": ciphers_json,
|
||||||
|
"object": "list",
|
||||||
|
"continuationToken": null,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
||||||
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
|
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
|
||||||
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
||||||
@@ -1875,6 +1915,7 @@ async fn post_org_import(
|
|||||||
&conn,
|
&conn,
|
||||||
&nt,
|
&nt,
|
||||||
UpdateType::None,
|
UpdateType::None,
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
@@ -1908,29 +1949,37 @@ struct BulkCollectionsData {
|
|||||||
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: BulkCollectionsData = data.into_inner();
|
let data: BulkCollectionsData = data.into_inner();
|
||||||
|
|
||||||
if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() {
|
let Some(member) =
|
||||||
|
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await
|
||||||
|
else {
|
||||||
err!("You need to be a Member of the Organization to call this endpoint")
|
err!("You need to be a Member of the Organization to call this endpoint")
|
||||||
}
|
};
|
||||||
|
// A member who may edit any collection may use every collection of the organization
|
||||||
|
let can_edit_any_collection = member.can_edit_any_collection();
|
||||||
|
|
||||||
// Get all the collection available to the user in one query
|
// Get all the collection available to the user in one query
|
||||||
// Also filter based upon the provided collections
|
// Also filter based upon the provided collections
|
||||||
let user_collections: HashMap<CollectionId, Collection> =
|
let collections = if can_edit_any_collection {
|
||||||
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn)
|
Collection::find_by_organization(&data.organization_id, &conn).await
|
||||||
.await
|
} else {
|
||||||
.into_iter()
|
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn).await
|
||||||
.filter_map(|c| {
|
};
|
||||||
if data.collection_ids.contains(&c.uuid) {
|
let user_collections: HashMap<CollectionId, Collection> = collections
|
||||||
Some((c.uuid.clone(), c))
|
.into_iter()
|
||||||
} else {
|
.filter_map(|c| {
|
||||||
None
|
if data.collection_ids.contains(&c.uuid) {
|
||||||
}
|
Some((c.uuid.clone(), c))
|
||||||
})
|
} else {
|
||||||
.collect();
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Verify if all the collections requested exists and are writable for the user, else abort
|
// Verify if all the collections requested exists and are writable for the user, else abort
|
||||||
for collection_uuid in &data.collection_ids {
|
for collection_uuid in &data.collection_ids {
|
||||||
match user_collections.get(collection_uuid) {
|
match user_collections.get(collection_uuid) {
|
||||||
Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (),
|
Some(collection)
|
||||||
|
if can_edit_any_collection || collection.is_writable_by_user(&headers.user.uuid, &conn).await => {}
|
||||||
_ => err_code!("Resource not found", "User does not have access to a collection", 404),
|
_ => err_code!("Resource not found", "User does not have access to a collection", 404),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1939,7 +1988,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
|
|||||||
// Only act on existing cipher uuid's
|
// Only act on existing cipher uuid's
|
||||||
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example
|
||||||
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
|
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
|
||||||
&& cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await
|
&& cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await
|
||||||
{
|
{
|
||||||
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
|
||||||
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
|
||||||
|
|||||||
+158
-4
@@ -1,7 +1,11 @@
|
|||||||
mod query_logger;
|
mod query_logger;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
sync::{Arc, OnceLock},
|
future::Future,
|
||||||
|
sync::{
|
||||||
|
Arc, OnceLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,6 +127,7 @@ pub static ACTIVE_DB_TYPE: OnceLock<DbConnType> = OnceLock::new();
|
|||||||
pub struct DbConn {
|
pub struct DbConn {
|
||||||
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
||||||
permit: Option<OwnedSemaphorePermit>,
|
permit: Option<OwnedSemaphorePermit>,
|
||||||
|
transaction_open: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -150,6 +155,7 @@ impl Drop for DbConn {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
let permit = self.permit.take();
|
let permit = self.permit.take();
|
||||||
|
let transaction_open = self.transaction_open.load(Ordering::Acquire);
|
||||||
|
|
||||||
// Since connection can't be on the stack in an async fn during an
|
// Since connection can't be on the stack in an async fn during an
|
||||||
// await, we have to spawn a new blocking-safe thread...
|
// await, we have to spawn a new blocking-safe thread...
|
||||||
@@ -157,7 +163,13 @@ impl Drop for DbConn {
|
|||||||
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
|
// And then re-enter the runtime to wait on the async mutex, but in a blocking fashion.
|
||||||
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
|
let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned());
|
||||||
|
|
||||||
if let Some(conn) = conn.take() {
|
if let Some(mut conn) = conn.take() {
|
||||||
|
if transaction_open {
|
||||||
|
// A cancelled request must not return an open transaction to the pool.
|
||||||
|
if let Err(error) = conn.batch_execute("ROLLBACK") {
|
||||||
|
error!("Failed to roll back a cancelled database transaction: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
drop(conn);
|
drop(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +260,7 @@ impl DbPool {
|
|||||||
Ok(DbConn {
|
Ok(DbConn {
|
||||||
conn: Arc::new(Mutex::new(Some(c))),
|
conn: Arc::new(Mutex::new(Some(c))),
|
||||||
permit: Some(permit),
|
permit: Some(permit),
|
||||||
|
transaction_open: AtomicBool::new(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +333,29 @@ impl DbConnType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DbConn {
|
impl DbConn {
|
||||||
|
/// Run asynchronous model calls on this request's pooled connection in one transaction.
|
||||||
|
/// Send external notifications only after this returns successfully.
|
||||||
|
pub async fn transaction<F, Fut, T>(&self, operation: F) -> Result<T, Error>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Result<T, Error>>,
|
||||||
|
{
|
||||||
|
self.run(|conn| conn.batch_execute("BEGIN")).await.map_res("Error starting database transaction")?;
|
||||||
|
self.transaction_open.store(true, Ordering::Release);
|
||||||
|
let result = operation().await;
|
||||||
|
if result.is_ok() {
|
||||||
|
let committed = self.run(|conn| conn.batch_execute("COMMIT")).await;
|
||||||
|
let closed = committed.is_ok() || self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok();
|
||||||
|
if closed {
|
||||||
|
self.transaction_open.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
committed.map_res("Error committing database transaction")?;
|
||||||
|
} else if self.run(|conn| conn.batch_execute("ROLLBACK")).await.is_ok() {
|
||||||
|
self.transaction_open.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run<F, R>(&self, f: F) -> R
|
pub async fn run<F, R>(&self, f: F) -> R
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut DbConnInner) -> R + Send,
|
F: FnOnce(&mut DbConnInner) -> R + Send,
|
||||||
@@ -538,9 +574,61 @@ mod postgresql_migrations {
|
|||||||
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
||||||
#[cfg(all(test, sqlite))]
|
#[cfg(all(test, sqlite))]
|
||||||
pub mod test_db {
|
pub mod test_db {
|
||||||
use super::sqlite_migrations::MIGRATIONS;
|
use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS};
|
||||||
use diesel::connection::SimpleConnection;
|
use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection};
|
||||||
use diesel_migrations::MigrationHarness;
|
use diesel_migrations::MigrationHarness;
|
||||||
|
use std::sync::{Arc, atomic::AtomicBool};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct TestDb {
|
||||||
|
pool: Pool<DbConnManager>,
|
||||||
|
// Keep the named in-memory database alive across pooled connections.
|
||||||
|
_keeper: SqliteConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestDb {
|
||||||
|
fn new() -> Self {
|
||||||
|
let url = format!("sqlite://{}?mode=memory&cache=shared", crate::util::get_uuid());
|
||||||
|
let mut keeper = SqliteConnection::establish(&url).unwrap();
|
||||||
|
keeper.run_pending_migrations(MIGRATIONS).unwrap();
|
||||||
|
let pool = Pool::builder().max_size(2).build(DbConnManager::new(&url)).unwrap();
|
||||||
|
Self {
|
||||||
|
pool,
|
||||||
|
_keeper: keeper,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conn(&self) -> DbConn {
|
||||||
|
DbConn {
|
||||||
|
conn: Arc::new(Mutex::new(Some(self.pool.get().unwrap()))),
|
||||||
|
permit: None,
|
||||||
|
transaction_open: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONFIG must initialize in a child process: the DNS bootstrap test needs it uninitialized.
|
||||||
|
pub fn run(test: impl AsyncFnOnce(&TestDb)) {
|
||||||
|
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
|
||||||
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
|
||||||
|
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
|
||||||
|
.env("DATABASE_URL", "sqlite://:memory:")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
|
||||||
|
"{}\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// DbConn::run uses block_in_place, requiring a multi-threaded runtime.
|
||||||
|
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
|
||||||
|
test(&TestDb::new()).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
||||||
@@ -604,4 +692,70 @@ pub mod test_db {
|
|||||||
assert_eq!(grants(&mut conn), 0);
|
assert_eq!(grants(&mut conn), 0);
|
||||||
assert_eq!(rows(&mut conn), before);
|
assert_eq!(rows(&mut conn), before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use super::models::{Collection, CustomPermission, Membership, MembershipType, Organization, OrganizationId, User};
|
||||||
|
|
||||||
|
pub async fn organization(conn: &DbConn) -> Organization {
|
||||||
|
let org = Organization::new("Org".into(), "billing@example.com", None, None);
|
||||||
|
org.save(conn).await.unwrap();
|
||||||
|
org
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn member(
|
||||||
|
conn: &DbConn,
|
||||||
|
org: &OrganizationId,
|
||||||
|
role: MembershipType,
|
||||||
|
permissions: &[CustomPermission],
|
||||||
|
) -> (User, Membership) {
|
||||||
|
let mut user = User::new(&format!("{}@example.com", crate::util::get_uuid()), None);
|
||||||
|
user.password_hash = b"existing-account".to_vec();
|
||||||
|
user.save(conn).await.unwrap();
|
||||||
|
let mut member = Membership::new(user.uuid.clone(), org.clone(), None);
|
||||||
|
member.set_type(role);
|
||||||
|
member.status = 2;
|
||||||
|
member.set_custom_permissions(&permissions.iter().copied().collect());
|
||||||
|
member.save(conn).await.unwrap();
|
||||||
|
(user, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn custom_member(conn: &DbConn, permission: CustomPermission) -> (Organization, User, Membership) {
|
||||||
|
let org = organization(conn).await;
|
||||||
|
let (user, member) = member(conn, &org.uuid, MembershipType::Custom, &[permission]).await;
|
||||||
|
(org, user, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collection(conn: &DbConn, org: &OrganizationId) -> Collection {
|
||||||
|
let collection = Collection::new(org.clone(), "Collection".into(), None);
|
||||||
|
collection.save(conn).await.unwrap();
|
||||||
|
collection
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transactions_rollback_before_connection_reuse_and_commit_success() {
|
||||||
|
use crate::error::MapResult;
|
||||||
|
use diesel::RunQueryDsl;
|
||||||
|
run(async |db| {
|
||||||
|
let conn = db.conn();
|
||||||
|
conn.run(|c| c.batch_execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)")).await.unwrap();
|
||||||
|
for (sql, expected) in [
|
||||||
|
("INSERT INTO probe VALUES (1); INSERT INTO probe VALUES (1)", 0_i64),
|
||||||
|
("INSERT INTO probe VALUES (1)", 1),
|
||||||
|
] {
|
||||||
|
let result: Result<(), super::Error> = conn
|
||||||
|
.transaction(async || conn.run(move |c| c.batch_execute(sql)).await.map_res("Insert failed"))
|
||||||
|
.await;
|
||||||
|
assert_eq!(result.is_ok(), expected == 1);
|
||||||
|
let count: i64 = conn
|
||||||
|
.run(|c| {
|
||||||
|
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>("(SELECT COUNT(*) FROM probe)"))
|
||||||
|
.get_result(c)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, expected);
|
||||||
|
// The same live connection must accept another transaction, before DbConn::drop can help.
|
||||||
|
conn.run(|c| c.batch_execute("BEGIN; ROLLBACK")).await.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+132
-12
@@ -1,4 +1,4 @@
|
|||||||
use std::borrow::Cow;
|
use std::{borrow::Cow, collections::HashSet};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
@@ -80,6 +80,47 @@ pub enum RepromptType {
|
|||||||
Password = 1,
|
Password = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which access a cipher operation is authorized with.
|
||||||
|
///
|
||||||
|
/// The administrative cipher routes (`/ciphers/<id>/admin` and friends) are served by the same handlers as the
|
||||||
|
/// regular vault routes, so the handlers state which one applies. They only differ for a Custom member with Edit
|
||||||
|
/// any collection, who reaches every cipher of the organization on the administrative routes, but keeps their
|
||||||
|
/// own vault limited to the collections they are assigned to.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CipherAccessScope {
|
||||||
|
User,
|
||||||
|
OrganizationAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CipherAccessScope {
|
||||||
|
/// Whether `member` reaches every cipher of their organization with this access.
|
||||||
|
fn reaches_all_ciphers(self, member: &Membership) -> bool {
|
||||||
|
match self {
|
||||||
|
CipherAccessScope::User => member.has_full_access(),
|
||||||
|
CipherAccessScope::OrganizationAdmin => member.can_edit_any_collection(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The access the v2 attachment upload asks for with `adminRequest`. It only picks which check runs, the
|
||||||
|
/// administrative one still needs a member who may edit any collection.
|
||||||
|
pub fn requested(admin_request: Option<bool>) -> Self {
|
||||||
|
if admin_request == Some(true) {
|
||||||
|
CipherAccessScope::OrganizationAdmin
|
||||||
|
} else {
|
||||||
|
CipherAccessScope::User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The access for a route that does not tell which one it is, from the member's own membership.
|
||||||
|
pub fn for_member(member: Option<&Membership>) -> Self {
|
||||||
|
if member.is_some_and(Membership::can_edit_any_collection) {
|
||||||
|
CipherAccessScope::OrganizationAdmin
|
||||||
|
} else {
|
||||||
|
CipherAccessScope::User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Cipher {
|
impl Cipher {
|
||||||
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
|
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
|
||||||
@@ -174,6 +215,29 @@ impl Cipher {
|
|||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
sync_type: CipherSyncType,
|
sync_type: CipherSyncType,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
|
) -> Result<Value, crate::Error> {
|
||||||
|
self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Cipher::to_json`] for an administrative route, telling what the member may do with the cipher there.
|
||||||
|
pub async fn to_json_org_admin(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Result<Value, crate::Error> {
|
||||||
|
self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn to_json_scoped(
|
||||||
|
&self,
|
||||||
|
host: &str,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
|
sync_type: CipherSyncType,
|
||||||
|
scope: CipherAccessScope,
|
||||||
|
conn: &DbConn,
|
||||||
) -> Result<Value, crate::Error> {
|
) -> Result<Value, crate::Error> {
|
||||||
use crate::util::{format_date, validate_and_format_date};
|
use crate::util::{format_date, validate_and_format_date};
|
||||||
|
|
||||||
@@ -206,7 +270,7 @@ impl Cipher {
|
|||||||
// We don't need these values at all for Organizational syncs
|
// 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.
|
// 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 {
|
||||||
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await {
|
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await {
|
||||||
(ro, hp, mn)
|
(ro, hp, mn)
|
||||||
} else {
|
} else {
|
||||||
error!("Cipher ownership assertion failure");
|
error!("Cipher ownership assertion failure");
|
||||||
@@ -609,20 +673,21 @@ impl Cipher {
|
|||||||
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether this cipher is owned by an org in which the user has full access.
|
/// Returns whether this cipher is owned by an org in which the user reaches every cipher with `scope`.
|
||||||
async fn is_in_full_access_org(
|
async fn is_in_full_access_org(
|
||||||
&self,
|
&self,
|
||||||
user_uuid: &UserId,
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if let Some(ref org_uuid) = self.organization_uuid {
|
if let Some(ref org_uuid) = self.organization_uuid {
|
||||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
||||||
return cached_member.has_full_access();
|
return scope.reaches_all_ciphers(cached_member);
|
||||||
}
|
}
|
||||||
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||||
return member.has_full_access();
|
return scope.reaches_all_ciphers(&member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@@ -655,6 +720,7 @@ impl Cipher {
|
|||||||
pub async fn get_access_restrictions(
|
pub async fn get_access_restrictions(
|
||||||
&self,
|
&self,
|
||||||
user_uuid: &UserId,
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
cipher_sync_data: Option<&CipherSyncData>,
|
cipher_sync_data: Option<&CipherSyncData>,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> Option<(bool, bool, bool)> {
|
) -> Option<(bool, bool, bool)> {
|
||||||
@@ -662,7 +728,7 @@ impl Cipher {
|
|||||||
// a collection that the user has full access to. If so, there are no
|
// a collection that the user has full access to. If so, there are no
|
||||||
// access restrictions.
|
// access restrictions.
|
||||||
if self.is_owned_by_user(user_uuid)
|
if self.is_owned_by_user(user_uuid)
|
||||||
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await
|
|| self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await
|
||||||
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
|
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
|
||||||
{
|
{
|
||||||
return Some((false, false, true));
|
return Some((false, false, true));
|
||||||
@@ -785,8 +851,13 @@ impl Cipher {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_write_accessible_to_user(
|
||||||
match self.get_access_restrictions(user_uuid, None, conn).await {
|
&self,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> bool {
|
||||||
|
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
|
||||||
Some((read_only, _hide_passwords, manage)) => !read_only || manage,
|
Some((read_only, _hide_passwords, manage)) => !read_only || manage,
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
@@ -794,15 +865,20 @@ impl Cipher {
|
|||||||
|
|
||||||
// used for checking if collection can be edited (only if user has access to a collection they
|
// used for checking if collection can be edited (only if user has access to a collection they
|
||||||
// can write to and also passwords are not hidden to prevent privilege escalation)
|
// can write to and also passwords are not hidden to prevent privilege escalation)
|
||||||
pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_in_editable_collection_by_user(
|
||||||
match self.get_access_restrictions(user_uuid, None, conn).await {
|
&self,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
scope: CipherAccessScope,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> bool {
|
||||||
|
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
|
||||||
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
|
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool {
|
||||||
self.get_access_restrictions(user_uuid, None, conn).await.is_some()
|
self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns whether this cipher is a favorite of the specified user.
|
// Returns whether this cipher is a favorite of the specified user.
|
||||||
@@ -1240,6 +1316,50 @@ impl Cipher {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every collection of every cipher of the organization, for the members who may see all of them.
|
||||||
|
pub async fn get_collections_with_cipher_by_organization(
|
||||||
|
org_uuid: &OrganizationId,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Vec<(CipherId, CollectionId)> {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
ciphers_collections::table
|
||||||
|
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
|
||||||
|
.filter(collections::org_uuid.eq(org_uuid))
|
||||||
|
.select(ciphers_collections::all_columns)
|
||||||
|
.load::<(CipherId, CollectionId)>(conn)
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ciphers of the organization of `member` the Admin Console lists when they may not edit every cipher, like
|
||||||
|
/// Bitwarden does: the ones in the member's own vault, then, for a member who may edit any collection, the ones in
|
||||||
|
/// no collection at all.
|
||||||
|
pub async fn find_assigned_to_member(member: &Membership, conn: &DbConn) -> (Vec<Self>, Vec<Self>) {
|
||||||
|
let assigned: Vec<Self> = Self::find_by_user_visible(&member.user_uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter(|cipher| cipher.organization_uuid.as_ref() == Some(&member.org_uuid))
|
||||||
|
.collect();
|
||||||
|
if !member.can_edit_any_collection() {
|
||||||
|
return (assigned, Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let listed: HashSet<CipherId> = assigned.iter().map(|cipher| cipher.uuid.clone()).collect();
|
||||||
|
let in_collections: HashSet<CipherId> =
|
||||||
|
Self::get_collections_with_cipher_by_organization(&member.org_uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|(cipher, _)| cipher)
|
||||||
|
.collect();
|
||||||
|
let unassigned = Self::find_by_org(&member.org_uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter(|cipher| !in_collections.contains(&cipher.uuid) && !listed.contains(&cipher.uuid))
|
||||||
|
.collect();
|
||||||
|
(assigned, unassigned)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ mod user_signature_key_pair;
|
|||||||
pub use self::archive::Archive;
|
pub use self::archive::Archive;
|
||||||
pub use self::attachment::{Attachment, AttachmentId};
|
pub use self::attachment::{Attachment, AttachmentId};
|
||||||
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
||||||
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
|
pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType, is_data_blob_encrypted};
|
||||||
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
||||||
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
||||||
pub use self::emergency_access::{
|
pub use self::emergency_access::{
|
||||||
|
|||||||
@@ -488,6 +488,35 @@ impl Membership {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn custom_permission(&self, permission: CustomPermission) -> bool {
|
||||||
|
match permission {
|
||||||
|
CustomPermission::ManageUsers => self.manage_users,
|
||||||
|
CustomPermission::ManageGroups => self.manage_groups,
|
||||||
|
CustomPermission::ManagePolicies => self.manage_policies,
|
||||||
|
CustomPermission::CreateNewCollections => self.create_new_collections,
|
||||||
|
CustomPermission::EditAnyCollection => self.edit_any_collection,
|
||||||
|
CustomPermission::DeleteAnyCollection => self.delete_any_collection,
|
||||||
|
CustomPermission::AccessEventLogs => self.access_event_logs,
|
||||||
|
CustomPermission::AccessImportExport => self.access_import_export,
|
||||||
|
CustomPermission::AccessReports => self.access_reports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the role of this member grants `permission`. Only a Custom role grants any, only while Custom roles
|
||||||
|
/// are enabled, and only the permissions this version enforces.
|
||||||
|
fn grants_custom_permission(&self, permission: CustomPermission) -> bool {
|
||||||
|
custom_roles_enabled()
|
||||||
|
&& permission.is_supported()
|
||||||
|
&& self.has_type(MembershipType::Custom)
|
||||||
|
&& self.custom_permission(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this member may act with `permission`, granted by their Custom role. Like the authority of any
|
||||||
|
/// other role, it needs a confirmed membership.
|
||||||
|
pub fn has_custom_permission(&self, permission: CustomPermission) -> bool {
|
||||||
|
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn restore(&mut self) -> bool {
|
pub fn restore(&mut self) -> bool {
|
||||||
if self.status < MembershipStatus::Invited as i32 {
|
if self.status < MembershipStatus::Invited as i32 {
|
||||||
self.status += ACTIVATE_REVOKE_DIFF;
|
self.status += ACTIVATE_REVOKE_DIFF;
|
||||||
@@ -1006,6 +1035,13 @@ impl Membership {
|
|||||||
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
|
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this member may edit every collection and the items in them in the Admin Console: every member with
|
||||||
|
/// full access, and a Custom member with Edit any collection. Unlike full access, the permission does not add
|
||||||
|
/// anything to the member's own vault.
|
||||||
|
pub fn can_edit_any_collection(&self) -> bool {
|
||||||
|
self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
||||||
|
|||||||
@@ -767,6 +767,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_has_v2_encryption() {
|
fn test_has_v2_encryption() {
|
||||||
|
// User::new loads CONFIG; isolate it from the DNS bootstrap test and filesystem configuration.
|
||||||
|
if std::env::var_os("VAULTWARDEN_ISOLATED_CONFIG_TEST").is_none() {
|
||||||
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args(["--exact", std::thread::current().name().unwrap(), "--nocapture"])
|
||||||
|
.env("VAULTWARDEN_ISOLATED_CONFIG_TEST", "1")
|
||||||
|
.env("DATABASE_URL", "sqlite://:memory:")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"),
|
||||||
|
"{}\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let user = |private_key: Option<&str>, security_version: Option<i32>| {
|
let user = |private_key: Option<&str>, security_version: Option<i32>| {
|
||||||
let mut user = User::new("user@example.com", None);
|
let mut user = User::new("user@example.com", None);
|
||||||
user.private_key = private_key.map(String::from);
|
user.private_key = private_key.map(String::from);
|
||||||
|
|||||||
Reference in new issue
Block a user