Browse Source

Separate administrative cipher access

pull/7938/head
tom27052006 3 weeks ago
parent
commit
b50df443df
  1. 3
      src/api/core/accounts.rs
  2. 788
      src/api/core/ciphers.rs
  3. 6
      src/api/core/events.rs
  4. 97
      src/api/core/organizations.rs
  5. 162
      src/db/mod.rs
  6. 144
      src/db/models/cipher.rs
  7. 2
      src/db/models/mod.rs
  8. 36
      src/db/models/organization.rs

3
src/api/core/accounts.rs

@ -1037,7 +1037,8 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None // Prevent triggering cipher updates via WebSockets by settings UpdateType::None
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
// We force the users to logout after the user has been saved to try and prevent these issues. // We force the users to logout after the user has been saved to try and prevent these issues.
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true)
.await?;
} }
} }

788
src/api/core/ciphers.rs

File diff suppressed because it is too large

6
src/api/core/events.rs

@ -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(

97
src/api/core/organizations.rs

@ -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,43 @@ 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 {
// Only the Custom role needs this, every other role that opens the Admin Console may edit every cipher
if !custom_roles_enabled() {
err_code!("Resource not found.", "Custom roles are disabled", Status::NotFound.code);
}
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 +1919,7 @@ async fn post_org_import(
&conn, &conn,
&nt, &nt,
UpdateType::None, UpdateType::None,
true,
) )
.await .await
.ok(); .ok();
@ -1908,29 +1953,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 +1992,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.

162
src/db/mod.rs

@ -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();
}
});
}
} }

144
src/db/models/cipher.rs

@ -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};
@ -68,6 +68,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 {
pub fn new(atype: i32, name: String) -> Self { pub fn new(atype: i32, name: String) -> Self {
@ -151,6 +192,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};
@ -179,7 +243,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");
@ -562,20 +626,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
@ -608,6 +673,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)> {
@ -615,7 +681,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));
@ -738,8 +804,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,
} }
@ -747,15 +818,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.
@ -1193,6 +1269,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(

2
src/db/models/mod.rs

@ -21,7 +21,7 @@ mod user;
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}; pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType};
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::{

36
src/db/models/organization.rs

@ -483,6 +483,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;
@ -1001,6 +1030,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()

Loading…
Cancel
Save