mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-15 13:55:56 +02:00
[PR 6] Add organization data access permissions for Custom Roles (#7964)
* Add Custom Roles organization data access * Let direct collection assignments override groups on import
This commit is contained in:
7 files changed
+208
-60
No files matched your search
+27
-8
@@ -22,9 +22,9 @@ use crate::{
|
|||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher,
|
Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher,
|
||||||
CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group,
|
CollectionGroup, CollectionId, CollectionUser, CustomPermission, EventType, Favorite, Folder, FolderCipher,
|
||||||
KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User,
|
FolderId, Group, KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType,
|
||||||
UserId, is_data_blob_encrypted,
|
Send, User, UserId, is_data_blob_encrypted,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
||||||
@@ -539,7 +539,8 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
|
|||||||
data.last_known_revision_date = None;
|
data.last_known_revision_date = None;
|
||||||
|
|
||||||
let mut cipher = Cipher::new(data.r#type);
|
let mut cipher = Cipher::new(data.r#type);
|
||||||
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate, true).await?;
|
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate, true, false)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
||||||
}
|
}
|
||||||
@@ -613,9 +614,21 @@ pub async fn update_cipher_from_data(
|
|||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
ut: UpdateType,
|
ut: UpdateType,
|
||||||
emit_side_effects: bool,
|
emit_side_effects: bool,
|
||||||
|
allow_unassigned_org_import: bool,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
||||||
save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut, emit_side_effects).await
|
save_cipher_data(
|
||||||
|
cipher,
|
||||||
|
data,
|
||||||
|
headers,
|
||||||
|
shared_to_collections,
|
||||||
|
conn,
|
||||||
|
nt,
|
||||||
|
ut,
|
||||||
|
emit_side_effects,
|
||||||
|
allow_unassigned_org_import,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply.
|
/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply.
|
||||||
@@ -626,7 +639,7 @@ pub async fn rotate_cipher_data(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None, false).await
|
save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None, false, false).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved
|
/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved
|
||||||
@@ -652,6 +665,7 @@ async fn save_cipher_data(
|
|||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
ut: UpdateType,
|
ut: UpdateType,
|
||||||
emit_side_effects: bool,
|
emit_side_effects: bool,
|
||||||
|
allow_unassigned_org_import: bool,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let is_blob = data.is_blob();
|
let is_blob = data.is_blob();
|
||||||
data.validate_content(is_blob)?;
|
data.validate_content(is_blob)?;
|
||||||
@@ -674,6 +688,8 @@ async fn save_cipher_data(
|
|||||||
// Write access to the cipher itself only counts when it already belongs to this organization.
|
// Write access to the cipher itself only counts when it already belongs to this organization.
|
||||||
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|
||||||
|| member.can_edit_any_collection()
|
|| member.can_edit_any_collection()
|
||||||
|
|| (allow_unassigned_org_import
|
||||||
|
&& member.has_custom_permission(CustomPermission::AccessImportExport))
|
||||||
|| (!transfer_cipher
|
|| (!transfer_cipher
|
||||||
&& cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await)
|
&& cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await)
|
||||||
{
|
{
|
||||||
@@ -945,7 +961,8 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
cipher_data.organization_id = None;
|
cipher_data.organization_id = None;
|
||||||
|
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type);
|
let mut cipher = Cipher::new(cipher_data.r#type);
|
||||||
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true).await?;
|
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true, false)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
@@ -1036,7 +1053,8 @@ async fn put_cipher_impl(
|
|||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
}
|
}
|
||||||
|
|
||||||
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate, true).await?;
|
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate, true, false)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?))
|
Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?))
|
||||||
}
|
}
|
||||||
@@ -1501,6 +1519,7 @@ async fn share_cipher_by_uuid(
|
|||||||
nt,
|
nt,
|
||||||
ut,
|
ut,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
for col_id in &shared_to_collections {
|
for col_id in &shared_to_collections {
|
||||||
|
|||||||
+21
-6
@@ -7,11 +7,12 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, JsonResult},
|
api::{ApiResult, EmptyResult, JsonResult},
|
||||||
auth::{AdminHeaders, Headers},
|
auth::{AccessEventLogsHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
|
Cipher, CipherAccessScope, CipherId, CustomPermission, Event, EventType, Membership, MembershipId,
|
||||||
|
MembershipType, OrganizationId, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
@@ -42,7 +43,12 @@ impl EventRange {
|
|||||||
|
|
||||||
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
|
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
|
||||||
#[get("/organizations/<org_id>/events?<data..>")]
|
#[get("/organizations/<org_id>/events?<data..>")]
|
||||||
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
async fn get_org_events(
|
||||||
|
org_id: OrganizationId,
|
||||||
|
data: EventRange,
|
||||||
|
headers: AccessEventLogsHeaders,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
@@ -72,12 +78,21 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
|||||||
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
// Return an empty vec when org events are disabled.
|
// Return an empty vec when org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
|
// Only an Owner or Admin of the organization of the cipher, or a Custom member there with Access event logs, gets
|
||||||
|
// its events, and only the ones of that organization
|
||||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
||||||
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
|
&& let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await
|
||||||
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
|
&& let Some(member) = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, &conn).await
|
||||||
|
&& (member.atype >= MembershipType::Admin || member.has_custom_permission(CustomPermission::AccessEventLogs))
|
||||||
{
|
{
|
||||||
let (start_date, end_date) = data.date_range()?;
|
let (start_date, end_date) = data.date_range()?;
|
||||||
|
|
||||||
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
|
Event::find_by_cipher_uuid(&cipher_id, &org_id, &start_date, &end_date, &conn)
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.map(Event::to_json)
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
@@ -94,7 +109,7 @@ async fn get_user_events(
|
|||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
data: EventRange,
|
data: EventRange,
|
||||||
headers: AdminHeaders,
|
headers: AccessEventLogsHeaders,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ use crate::{
|
|||||||
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
||||||
},
|
},
|
||||||
auth::{
|
auth::{
|
||||||
AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, CollectionUsersReadHeaders,
|
AccessImportExportHeaders, AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders,
|
||||||
Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose,
|
CollectionUsersReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders,
|
||||||
OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite,
|
ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite,
|
||||||
},
|
},
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
@@ -372,10 +372,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
|
|||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage groups may list organization collections for group access administration without editing them.
|
let collections = if headers.membership.can_list_all_collections() {
|
||||||
let collections = if headers.membership.can_view_all_collections()
|
|
||||||
|| headers.membership.has_custom_permission(CustomPermission::ManageGroups)
|
|
||||||
{
|
|
||||||
get_org_collections_impl(&org_id, &conn).await
|
get_org_collections_impl(&org_id, &conn).await
|
||||||
} else if headers.membership.has_type(MembershipType::Custom) {
|
} else if headers.membership.has_type(MembershipType::Custom) {
|
||||||
// A Custom member gets the collections they may manage
|
// A Custom member gets the collections they may manage
|
||||||
@@ -908,7 +905,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.can_edit_any_collection() {
|
if !headers.membership.can_list_all_ciphers() {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1618,7 +1615,10 @@ async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeade
|
|||||||
if org_id != headers.membership.org_uuid {
|
if org_id != headers.membership.org_uuid {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
|
// A Custom member may also need basic member details for event logs or reports.
|
||||||
if headers.membership.has_type(MembershipType::Custom)
|
if headers.membership.has_type(MembershipType::Custom)
|
||||||
|
&& !headers.membership.has_custom_permission(CustomPermission::AccessEventLogs)
|
||||||
|
&& !headers.membership.has_custom_permission(CustomPermission::AccessReports)
|
||||||
&& !may_assign_collection_access(&headers.membership, &conn).await
|
&& !may_assign_collection_access(&headers.membership, &conn).await
|
||||||
{
|
{
|
||||||
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
|
||||||
@@ -1998,21 +1998,40 @@ async fn validate_org_import_collections(
|
|||||||
imported: &[ImportCollectionData],
|
imported: &[ImportCollectionData],
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
membership: &Membership,
|
membership: &Membership,
|
||||||
|
import_export: bool,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> ApiResult<HashMap<CollectionId, Collection>> {
|
) -> ApiResult<HashMap<CollectionId, Collection>> {
|
||||||
let existing: HashMap<CollectionId, Collection> =
|
let existing: HashMap<CollectionId, Collection> =
|
||||||
Collection::find_by_organization(org_id, conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
|
Collection::find_by_organization(org_id, conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
|
||||||
|
let manageable = if membership.atype < MembershipType::Admin
|
||||||
|
&& !membership.can_edit_any_collection()
|
||||||
|
&& !membership.has_type(MembershipType::Manager)
|
||||||
|
{
|
||||||
|
Some(
|
||||||
|
Collection::find_explicitly_manageable_by_user(org_id, &membership.user_uuid, conn)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<HashSet<_>>(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
for col in imported {
|
for col in imported {
|
||||||
if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) {
|
if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) {
|
||||||
if membership.atype < MembershipType::Admin
|
if membership.atype < MembershipType::Admin && !membership.can_edit_any_collection() {
|
||||||
&& !membership.can_edit_any_collection()
|
let can_import = if let Some(manageable) = &manageable {
|
||||||
&& !collection.is_writable_by_user(&membership.user_uuid, conn).await
|
manageable.contains(&collection.uuid)
|
||||||
{
|
} else {
|
||||||
|
collection.is_writable_by_user(&membership.user_uuid, conn).await
|
||||||
|
};
|
||||||
|
if !can_import {
|
||||||
err!(Compact, "The current user isn't allowed to manage this collection")
|
err!(Compact, "The current user isn't allowed to manage this collection")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if membership.atype <= MembershipType::Manager
|
} else if membership.atype <= MembershipType::Manager
|
||||||
&& !membership.has_full_access()
|
&& !membership.has_full_access()
|
||||||
&& !membership.has_custom_permission(CustomPermission::CreateNewCollections)
|
&& !membership.has_custom_permission(CustomPermission::CreateNewCollections)
|
||||||
|
&& !import_export
|
||||||
{
|
{
|
||||||
err!(Compact, "The current user isn't allowed to create new collections")
|
err!(Compact, "The current user isn't allowed to create new collections")
|
||||||
}
|
}
|
||||||
@@ -2045,6 +2064,8 @@ async fn post_org_import(
|
|||||||
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
||||||
Cipher::validate_cipher_data(&data.ciphers)?;
|
Cipher::validate_cipher_data(&data.ciphers)?;
|
||||||
|
|
||||||
|
// Access import/export includes unassigned organization items. It is effective only for confirmed Custom members.
|
||||||
|
let import_export = headers.membership.has_custom_permission(CustomPermission::AccessImportExport);
|
||||||
let relations = validate_org_import_relationships(&data)?;
|
let relations = validate_org_import_relationships(&data)?;
|
||||||
let ImportData {
|
let ImportData {
|
||||||
ciphers: imported_ciphers,
|
ciphers: imported_ciphers,
|
||||||
@@ -2070,7 +2091,8 @@ async fn post_org_import(
|
|||||||
// Keep authorization and every write on the same connection. A failure at any later cipher or
|
// Keep authorization and every write on the same connection. A failure at any later cipher or
|
||||||
// relationship must also roll back newly created collections and their Manage assignments.
|
// relationship must also roll back newly created collections and their Manage assignments.
|
||||||
conn.transaction(async || {
|
conn.transaction(async || {
|
||||||
let existing = validate_org_import_collections(&imported_collections, &org_id, &membership, &conn).await?;
|
let existing =
|
||||||
|
validate_org_import_collections(&imported_collections, &org_id, &membership, import_export, &conn).await?;
|
||||||
let mut collections: Vec<CollectionId> = Vec::with_capacity(imported_collections.len());
|
let mut collections: Vec<CollectionId> = Vec::with_capacity(imported_collections.len());
|
||||||
for col in imported_collections {
|
for col in imported_collections {
|
||||||
let collection_uuid = if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) {
|
let collection_uuid = if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) {
|
||||||
@@ -2104,6 +2126,7 @@ async fn post_org_import(
|
|||||||
&nt,
|
&nt,
|
||||||
UpdateType::None,
|
UpdateType::None,
|
||||||
false,
|
false,
|
||||||
|
import_export,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
for collection_id in collection_ids {
|
for collection_id in collection_ids {
|
||||||
@@ -2142,6 +2165,7 @@ mod org_import_tests {
|
|||||||
("index", MembershipType::Custom, CustomPermission::CreateNewCollections),
|
("index", MembershipType::Custom, CustomPermission::CreateNewCollections),
|
||||||
("disabled", MembershipType::Custom, CustomPermission::CreateNewCollections),
|
("disabled", MembershipType::Custom, CustomPermission::CreateNewCollections),
|
||||||
("export", MembershipType::Owner, CustomPermission::CreateNewCollections),
|
("export", MembershipType::Owner, CustomPermission::CreateNewCollections),
|
||||||
|
("export", MembershipType::Custom, CustomPermission::AccessImportExport),
|
||||||
] {
|
] {
|
||||||
test_flag::CUSTOM_ROLES_ENABLED.set(Some(case != "disabled"));
|
test_flag::CUSTOM_ROLES_ENABLED.set(Some(case != "disabled"));
|
||||||
let org = test_db::organization(&conn).await;
|
let org = test_db::organization(&conn).await;
|
||||||
@@ -2859,7 +2883,10 @@ async fn get_groups_data(
|
|||||||
|| headers.membership.has_custom_permission(CustomPermission::ManageUsers)
|
|| headers.membership.has_custom_permission(CustomPermission::ManageUsers)
|
||||||
|| headers.membership.has_custom_permission(CustomPermission::ManageGroups)
|
|| headers.membership.has_custom_permission(CustomPermission::ManageGroups)
|
||||||
} else {
|
} else {
|
||||||
has_full_access || may_assign_collection_access(&headers.membership, &conn).await
|
// Reports need the basic group list, not group details or management rights.
|
||||||
|
has_full_access
|
||||||
|
|| headers.membership.has_custom_permission(CustomPermission::AccessReports)
|
||||||
|
|| may_assign_collection_access(&headers.membership, &conn).await
|
||||||
};
|
};
|
||||||
if !allowed {
|
if !allowed {
|
||||||
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
|
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
|
||||||
@@ -3700,11 +3727,11 @@ async fn put_reset_password_enrollment(
|
|||||||
// NOTE: It seems clients can't handle uppercase-first keys!!
|
// NOTE: It seems clients can't handle uppercase-first keys!!
|
||||||
// We need to convert all keys so they have the first character to be a lowercase.
|
// We need to convert all keys so they have the first character to be a lowercase.
|
||||||
// Else the export will be just an empty JSON file.
|
// Else the export will be just an empty JSON file.
|
||||||
// We currently only support exports by members of the Admin or Owner status.
|
// Exports the whole vault of the organization, for an Owner, an Admin or a Custom member with Access import/export.
|
||||||
// Vaultwarden does not yet support exporting only managed collections!
|
// Vaultwarden does not yet support exporting only managed collections!
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
|
||||||
#[get("/organizations/<org_id>/export")]
|
#[get("/organizations/<org_id>/export")]
|
||||||
async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
async fn get_org_export(org_id: OrganizationId, headers: AccessImportExportHeaders, conn: DbConn) -> JsonResult {
|
||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
@@ -3834,6 +3861,7 @@ mod tests {
|
|||||||
#[cfg(sqlite)]
|
#[cfg(sqlite)]
|
||||||
async fn users_headers(member: &Membership, conn: &DbConn) -> ManageUsersHeaders {
|
async fn users_headers(member: &Membership, conn: &DbConn) -> ManageUsersHeaders {
|
||||||
ManageUsersHeaders {
|
ManageUsersHeaders {
|
||||||
|
host: "http://localhost".into(),
|
||||||
device: Device::new("test".to_owned().into(), member.user_uuid.clone(), "test".into(), 1),
|
device: Device::new("test".to_owned().into(), member.user_uuid.clone(), "test".into(), 1),
|
||||||
user: User::find_by_uuid(&member.user_uuid, conn).await.unwrap(),
|
user: User::find_by_uuid(&member.user_uuid, conn).await.unwrap(),
|
||||||
membership: Membership::find_by_uuid(&member.uuid, conn).await.unwrap(),
|
membership: Membership::find_by_uuid(&member.uuid, conn).await.unwrap(),
|
||||||
@@ -3916,6 +3944,7 @@ mod tests {
|
|||||||
for first_enable in [true, false] {
|
for first_enable in [true, false] {
|
||||||
let headers = users_headers(&actor, &conn).await;
|
let headers = users_headers(&actor, &conn).await;
|
||||||
let headers = ManagePoliciesHeaders {
|
let headers = ManagePoliciesHeaders {
|
||||||
|
host: headers.host,
|
||||||
device: headers.device,
|
device: headers.device,
|
||||||
user: headers.user,
|
user: headers.user,
|
||||||
membership: headers.membership,
|
membership: headers.membership,
|
||||||
@@ -3952,4 +3981,58 @@ mod tests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
#[cfg(sqlite)]
|
||||||
|
async fn directory_headers(member: &Membership, conn: &DbConn) -> ManagerHeadersLoose {
|
||||||
|
let headers = users_headers(member, conn).await;
|
||||||
|
ManagerHeadersLoose {
|
||||||
|
host: headers.host,
|
||||||
|
device: headers.device,
|
||||||
|
user: headers.user,
|
||||||
|
membership: headers.membership,
|
||||||
|
ip: headers.ip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(sqlite)]
|
||||||
|
#[test]
|
||||||
|
fn access_reports_directory_authorization() {
|
||||||
|
test_db::run(async |db| {
|
||||||
|
let conn = db.conn();
|
||||||
|
let (org, _, mut member) = test_db::custom_member(&conn, CustomPermission::AccessReports).await;
|
||||||
|
let foreign = test_db::organization(&conn).await;
|
||||||
|
for (enabled, status, permission, org_id, allowed) in [
|
||||||
|
(true, 2, true, &org.uuid, true),
|
||||||
|
(true, 2, true, &foreign.uuid, false),
|
||||||
|
(true, 2, false, &org.uuid, false),
|
||||||
|
(true, 1, true, &org.uuid, false),
|
||||||
|
(true, -126, true, &org.uuid, false),
|
||||||
|
(false, 2, true, &org.uuid, false),
|
||||||
|
] {
|
||||||
|
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
|
||||||
|
member.status = status;
|
||||||
|
member.set_custom_permissions(&if permission {
|
||||||
|
HashSet::from([CustomPermission::AccessReports])
|
||||||
|
} else {
|
||||||
|
HashSet::new()
|
||||||
|
});
|
||||||
|
member.save(&conn).await.unwrap();
|
||||||
|
assert!(!may_assign_collection_access(&member, &conn).await);
|
||||||
|
assert_eq!(
|
||||||
|
get_groups(org_id.clone(), directory_headers(&member, &conn).await, db.conn()).await.is_ok(),
|
||||||
|
allowed
|
||||||
|
);
|
||||||
|
let users =
|
||||||
|
get_org_user_mini_details(org_id.clone(), directory_headers(&member, &conn).await, db.conn()).await;
|
||||||
|
assert_eq!(users.is_ok(), allowed);
|
||||||
|
if allowed {
|
||||||
|
assert_eq!(users.unwrap().0["data"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
get_groups_details(org_id.clone(), directory_headers(&member, &conn).await, db.conn())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+12
-2
@@ -847,7 +847,6 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AdminHeaders {
|
pub struct AdminHeaders {
|
||||||
pub host: String,
|
|
||||||
pub device: Device,
|
pub device: Device,
|
||||||
pub user: User,
|
pub user: User,
|
||||||
pub membership_type: MembershipType,
|
pub membership_type: MembershipType,
|
||||||
@@ -869,7 +868,6 @@ impl<'r> FromRequest<'r> for AdminHeaders {
|
|||||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||||
if headers.is_confirmed_and_admin() {
|
if headers.is_confirmed_and_admin() {
|
||||||
Outcome::Success(Self {
|
Outcome::Success(Self {
|
||||||
host: headers.host,
|
|
||||||
device: headers.device,
|
device: headers.device,
|
||||||
user: headers.user,
|
user: headers.user,
|
||||||
membership_type: headers.membership_type,
|
membership_type: headers.membership_type,
|
||||||
@@ -889,6 +887,7 @@ macro_rules! permission_headers {
|
|||||||
$(#[$doc])*
|
$(#[$doc])*
|
||||||
#[allow(dead_code, reason = "Not every route reads every field")]
|
#[allow(dead_code, reason = "Not every route reads every field")]
|
||||||
pub struct $name {
|
pub struct $name {
|
||||||
|
pub host: String,
|
||||||
pub device: Device,
|
pub device: Device,
|
||||||
pub user: User,
|
pub user: User,
|
||||||
pub membership_type: MembershipType,
|
pub membership_type: MembershipType,
|
||||||
@@ -905,6 +904,7 @@ macro_rules! permission_headers {
|
|||||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||||
if headers.is_confirmed_and_admin() || headers.membership.has_custom_permission($permission) {
|
if headers.is_confirmed_and_admin() || headers.membership.has_custom_permission($permission) {
|
||||||
Outcome::Success(Self {
|
Outcome::Success(Self {
|
||||||
|
host: headers.host,
|
||||||
device: headers.device,
|
device: headers.device,
|
||||||
user: headers.user,
|
user: headers.user,
|
||||||
membership_type: headers.membership_type,
|
membership_type: headers.membership_type,
|
||||||
@@ -937,6 +937,16 @@ permission_headers!(
|
|||||||
ManageGroupsHeaders,
|
ManageGroupsHeaders,
|
||||||
CustomPermission::ManageGroups
|
CustomPermission::ManageGroups
|
||||||
);
|
);
|
||||||
|
permission_headers!(
|
||||||
|
/// For the routes that read the event logs of an organization.
|
||||||
|
AccessEventLogsHeaders,
|
||||||
|
CustomPermission::AccessEventLogs
|
||||||
|
);
|
||||||
|
permission_headers!(
|
||||||
|
/// For the routes that export the vault of an organization.
|
||||||
|
AccessImportExportHeaders,
|
||||||
|
CustomPermission::AccessImportExport
|
||||||
|
);
|
||||||
|
|
||||||
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
|
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
|
||||||
// but there could be cases where it is a query value.
|
// but there could be cases where it is a query value.
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ impl Event {
|
|||||||
|
|
||||||
pub async fn find_by_cipher_uuid(
|
pub async fn find_by_cipher_uuid(
|
||||||
cipher_uuid: &CipherId,
|
cipher_uuid: &CipherId,
|
||||||
|
org_uuid: &OrganizationId,
|
||||||
start: &NaiveDateTime,
|
start: &NaiveDateTime,
|
||||||
end: &NaiveDateTime,
|
end: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
@@ -338,6 +339,7 @@ impl Event {
|
|||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
event::table
|
event::table
|
||||||
.filter(event::cipher_uuid.eq(cipher_uuid))
|
.filter(event::cipher_uuid.eq(cipher_uuid))
|
||||||
|
.filter(event::org_uuid.eq(org_uuid))
|
||||||
.filter(event::event_date.between(start, end))
|
.filter(event::event_date.between(start, end))
|
||||||
.order_by(event::event_date.desc())
|
.order_by(event::event_date.desc())
|
||||||
.limit(Self::PAGE_SIZE)
|
.limit(Self::PAGE_SIZE)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
schema::{
|
schema::{
|
||||||
ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key,
|
ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key,
|
||||||
organizations, users, users_collections, users_organizations,
|
organizations, users, users_collections, users_organizations,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -257,7 +257,8 @@ pub enum CustomPermission {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
|
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
|
||||||
/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them.
|
/// grant of any other one grants nothing. Every stored permission is enforced now, a permission added later only
|
||||||
|
/// belongs here once every endpoint it opens enforces it.
|
||||||
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
|
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
|
||||||
CustomPermission::ManageUsers,
|
CustomPermission::ManageUsers,
|
||||||
CustomPermission::ManageGroups,
|
CustomPermission::ManageGroups,
|
||||||
@@ -265,6 +266,9 @@ const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
|
|||||||
CustomPermission::CreateNewCollections,
|
CustomPermission::CreateNewCollections,
|
||||||
CustomPermission::EditAnyCollection,
|
CustomPermission::EditAnyCollection,
|
||||||
CustomPermission::DeleteAnyCollection,
|
CustomPermission::DeleteAnyCollection,
|
||||||
|
CustomPermission::AccessEventLogs,
|
||||||
|
CustomPermission::AccessImportExport,
|
||||||
|
CustomPermission::AccessReports,
|
||||||
];
|
];
|
||||||
|
|
||||||
impl CustomPermission {
|
impl CustomPermission {
|
||||||
@@ -1105,6 +1109,25 @@ impl Membership {
|
|||||||
self.can_view_all_collections() || self.has_custom_permission(CustomPermission::ManageUsers)
|
self.can_view_all_collections() || self.has_custom_permission(CustomPermission::ManageUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the member may list all organization collections in the Admin Console. This endpoint returns
|
||||||
|
/// collection metadata without user or group access details. Group management and import/export need the
|
||||||
|
/// complete collection list, but neither permission grants individual collection editing or cipher access.
|
||||||
|
pub fn can_list_all_collections(&self) -> bool {
|
||||||
|
self.can_view_all_collections()
|
||||||
|
|| self.has_custom_permission(CustomPermission::ManageGroups)
|
||||||
|
|| self.has_custom_permission(CustomPermission::AccessImportExport)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this member may list every cipher of the organization in the Admin Console, like Bitwarden's
|
||||||
|
/// CanAccessAllCiphers: every member who may edit any collection, and a Custom member with Access import/export
|
||||||
|
/// (the export holds every cipher anyway) or Access reports (the reports run on every cipher). The list adds
|
||||||
|
/// nothing to the member's own vault.
|
||||||
|
pub fn can_list_all_ciphers(&self) -> bool {
|
||||||
|
self.can_edit_any_collection()
|
||||||
|
|| self.has_custom_permission(CustomPermission::AccessImportExport)
|
||||||
|
|| self.has_custom_permission(CustomPermission::AccessReports)
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -1484,28 +1507,6 @@ impl Membership {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool {
|
|
||||||
conn.run(move |conn| {
|
|
||||||
users_organizations::table
|
|
||||||
.inner_join(
|
|
||||||
ciphers::table.on(ciphers::uuid
|
|
||||||
.eq(cipher_uuid)
|
|
||||||
.and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))),
|
|
||||||
)
|
|
||||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
|
||||||
.filter(
|
|
||||||
users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]),
|
|
||||||
)
|
|
||||||
.count()
|
|
||||||
.first::<i64>(conn)
|
|
||||||
.ok()
|
|
||||||
.unwrap_or(0)
|
|
||||||
!= 0
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find_by_collection_and_org(
|
pub async fn find_by_collection_and_org(
|
||||||
collection_uuid: &CollectionId,
|
collection_uuid: &CollectionId,
|
||||||
org_uuid: &OrganizationId,
|
org_uuid: &OrganizationId,
|
||||||
@@ -1710,7 +1711,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SUPPORTED_CUSTOM_PERMISSIONS,
|
SUPPORTED_CUSTOM_PERMISSIONS,
|
||||||
[ManageUsers, ManageGroups, ManagePolicies, CreateNewCollections, EditAnyCollection, DeleteAnyCollection]
|
[
|
||||||
|
ManageUsers,
|
||||||
|
ManageGroups,
|
||||||
|
ManagePolicies,
|
||||||
|
CreateNewCollections,
|
||||||
|
EditAnyCollection,
|
||||||
|
DeleteAnyCollection,
|
||||||
|
AccessEventLogs,
|
||||||
|
AccessImportExport,
|
||||||
|
AccessReports
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1773,6 +1784,14 @@ mod tests {
|
|||||||
(allowed, edit, view),
|
(allowed, edit, view),
|
||||||
"{role:?} {all} {enabled} {status} {permission:?}"
|
"{role:?} {all} {enabled} {status} {permission:?}"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(member.can_list_all_collections(), member.can_list_all_ciphers()),
|
||||||
|
(
|
||||||
|
view || (allowed && matches!(permission, ManageGroups | AccessImportExport)),
|
||||||
|
edit || (allowed && matches!(permission, AccessImportExport | AccessReports))
|
||||||
|
),
|
||||||
|
"{role:?} {enabled} {status} {permission:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1787,12 +1806,13 @@ mod tests {
|
|||||||
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
|
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
|
||||||
assert!(CustomPermission::ALL.into_iter().all(|p| !member.has_custom_permission(p)));
|
assert!(CustomPermission::ALL.into_iter().all(|p| !member.has_custom_permission(p)));
|
||||||
assert!(!member.can_edit_any_collection() && !member.can_view_all_collections());
|
assert!(!member.can_edit_any_collection() && !member.can_view_all_collections());
|
||||||
|
assert!(!member.can_list_all_collections() && !member.can_list_all_ciphers());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let empty = member(Custom);
|
let empty = member(Custom);
|
||||||
assert!(!empty.can_edit_any_collection() && !empty.can_view_all_collections());
|
assert!(!empty.can_edit_any_collection() && !empty.can_view_all_collections());
|
||||||
assert!(empty.shown_custom_permissions().is_empty());
|
assert!(empty.shown_custom_permissions().is_empty());
|
||||||
|
assert!(!empty.can_list_all_collections() && !empty.can_list_all_ciphers());
|
||||||
test_flag::CUSTOM_ROLES_ENABLED.set(None);
|
test_flag::CUSTOM_ROLES_ENABLED.set(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,7 @@ app-security > app-two-factor-setup > form {
|
|||||||
|
|
||||||
/* Hide unsupported Custom Role options */
|
/* Hide unsupported Custom Role options */
|
||||||
{{#if custom_roles_enabled}}
|
{{#if custom_roles_enabled}}
|
||||||
/* Only the collection permissions, Manage users, Manage groups and Manage policies can be granted yet */
|
/* Not supported: Manage SSO and Manage account recovery */
|
||||||
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"]),
|
|
||||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]),
|
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]),
|
||||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageResetPassword"]) {
|
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageResetPassword"]) {
|
||||||
@extend %vw-hide;
|
@extend %vw-hide;
|
||||||
|
|||||||
Reference in new issue
Block a user