mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-13 15: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},
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
||||
models::{
|
||||
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
|
||||
},
|
||||
},
|
||||
error::MapResult,
|
||||
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.
|
||||
if let Some(cipher_uuid) = &event.cipher_id
|
||||
&& 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
|
||||
{
|
||||
log_event_impl(
|
||||
|
||||
@@ -15,10 +15,11 @@ use crate::{
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
|
||||
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
|
||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
|
||||
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
|
||||
Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
|
||||
CollectionUser, CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser,
|
||||
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType,
|
||||
Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
||||
custom_roles_enabled,
|
||||
},
|
||||
},
|
||||
mail,
|
||||
@@ -47,6 +48,7 @@ pub fn routes() -> Vec<Route> {
|
||||
bulk_delete_organization_collections,
|
||||
post_bulk_collections,
|
||||
get_org_details,
|
||||
get_assigned_org_details,
|
||||
get_org_domain_sso_verified,
|
||||
get_members,
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -859,7 +861,12 @@ async fn get_org_details_impl(
|
||||
conn: &DbConn,
|
||||
) -> Result<Value, crate::Error> {
|
||||
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());
|
||||
for c in ciphers {
|
||||
@@ -868,6 +875,39 @@ async fn get_org_details_impl(
|
||||
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
|
||||
// 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`
|
||||
@@ -1875,6 +1915,7 @@ async fn post_org_import(
|
||||
&conn,
|
||||
&nt,
|
||||
UpdateType::None,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
@@ -1908,29 +1949,37 @@ struct BulkCollectionsData {
|
||||
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
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")
|
||||
}
|
||||
};
|
||||
// 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
|
||||
// Also filter based upon the provided collections
|
||||
let user_collections: HashMap<CollectionId, Collection> =
|
||||
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
if data.collection_ids.contains(&c.uuid) {
|
||||
Some((c.uuid.clone(), c))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let collections = if can_edit_any_collection {
|
||||
Collection::find_by_organization(&data.organization_id, &conn).await
|
||||
} else {
|
||||
Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn).await
|
||||
};
|
||||
let user_collections: HashMap<CollectionId, Collection> = collections
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
if data.collection_ids.contains(&c.uuid) {
|
||||
Some((c.uuid.clone(), c))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Verify if all the collections requested exists and are writable for the user, else abort
|
||||
for collection_uuid in &data.collection_ids {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1939,7 +1988,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
|
||||
// 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
|
||||
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
|
||||
// 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;
|
||||
|
||||
use std::{
|
||||
sync::{Arc, OnceLock},
|
||||
future::Future,
|
||||
sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
@@ -123,6 +127,7 @@ pub static ACTIVE_DB_TYPE: OnceLock<DbConnType> = OnceLock::new();
|
||||
pub struct DbConn {
|
||||
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,
|
||||
permit: Option<OwnedSemaphorePermit>,
|
||||
transaction_open: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -150,6 +155,7 @@ impl Drop for DbConn {
|
||||
fn drop(&mut self) {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
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
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -248,6 +260,7 @@ impl DbPool {
|
||||
Ok(DbConn {
|
||||
conn: Arc::new(Mutex::new(Some(c))),
|
||||
permit: Some(permit),
|
||||
transaction_open: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -320,6 +333,29 @@ impl DbConnType {
|
||||
}
|
||||
|
||||
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
|
||||
where
|
||||
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.
|
||||
#[cfg(all(test, sqlite))]
|
||||
pub mod test_db {
|
||||
use super::sqlite_migrations::MIGRATIONS;
|
||||
use diesel::connection::SimpleConnection;
|
||||
use super::{DbConn, DbConnManager, sqlite_migrations::MIGRATIONS};
|
||||
use diesel::{Connection, connection::SimpleConnection, r2d2::Pool, sqlite::SqliteConnection};
|
||||
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]
|
||||
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!(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 derive_more::{AsRef, Deref, Display, From};
|
||||
@@ -80,6 +80,47 @@ pub enum RepromptType {
|
||||
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
|
||||
impl Cipher {
|
||||
/// 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>,
|
||||
sync_type: CipherSyncType,
|
||||
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> {
|
||||
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
|
||||
// Skip any other database calls if this is the case and just return false.
|
||||
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)
|
||||
} else {
|
||||
error!("Cipher ownership assertion failure");
|
||||
@@ -609,20 +673,21 @@ impl Cipher {
|
||||
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(
|
||||
&self,
|
||||
user_uuid: &UserId,
|
||||
scope: CipherAccessScope,
|
||||
cipher_sync_data: Option<&CipherSyncData>,
|
||||
conn: &DbConn,
|
||||
) -> bool {
|
||||
if let Some(ref org_uuid) = self.organization_uuid {
|
||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
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 {
|
||||
return member.has_full_access();
|
||||
return scope.reaches_all_ciphers(&member);
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -655,6 +720,7 @@ impl Cipher {
|
||||
pub async fn get_access_restrictions(
|
||||
&self,
|
||||
user_uuid: &UserId,
|
||||
scope: CipherAccessScope,
|
||||
cipher_sync_data: Option<&CipherSyncData>,
|
||||
conn: &DbConn,
|
||||
) -> Option<(bool, bool, bool)> {
|
||||
@@ -662,7 +728,7 @@ impl Cipher {
|
||||
// a collection that the user has full access to. If so, there are no
|
||||
// access restrictions.
|
||||
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
|
||||
{
|
||||
return Some((false, false, true));
|
||||
@@ -785,8 +851,13 @@ impl Cipher {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
match self.get_access_restrictions(user_uuid, None, conn).await {
|
||||
pub async fn is_write_accessible_to_user(
|
||||
&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,
|
||||
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
|
||||
// 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 {
|
||||
match self.get_access_restrictions(user_uuid, None, conn).await {
|
||||
pub async fn is_in_editable_collection_by_user(
|
||||
&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,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
|
||||
self.get_access_restrictions(user_uuid, None, conn).await.is_some()
|
||||
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool {
|
||||
self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some()
|
||||
}
|
||||
|
||||
// Returns whether this cipher is a favorite of the specified user.
|
||||
@@ -1240,6 +1316,50 @@ impl Cipher {
|
||||
})
|
||||
.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(
|
||||
|
||||
@@ -22,7 +22,7 @@ mod user_signature_key_pair;
|
||||
pub use self::archive::Archive;
|
||||
pub use self::attachment::{Attachment, AttachmentId};
|
||||
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::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
||||
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 {
|
||||
if self.status < MembershipStatus::Invited as i32 {
|
||||
self.status += ACTIVATE_REVOKE_DIFF;
|
||||
@@ -1006,6 +1035,13 @@ impl Membership {
|
||||
(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> {
|
||||
conn.run(move |conn| {
|
||||
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
|
||||
|
||||
@@ -767,6 +767,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 mut user = User::new("user@example.com", None);
|
||||
user.private_key = private_key.map(String::from);
|
||||
|
||||
Reference in new issue
Block a user