[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:
Tom authored and GitHub committed 2026-11-13 14:31:57 +01:00
1 parent 7c82fbed0c
commit 1430a64547
8 files changed
+995 -261

No files matched your search

+132 -12
View File
@@ -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(