|
|
|
@ -14,7 +14,7 @@ use crate::{ |
|
|
|
auth::{ |
|
|
|
AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, |
|
|
|
ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, |
|
|
|
ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite, |
|
|
|
ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, can_read_collection_access, decode_invite, |
|
|
|
}, |
|
|
|
db::{ |
|
|
|
DbConn, |
|
|
|
@ -397,19 +397,25 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos |
|
|
|
// Custom users with a user/group manage permission need to read the collection list
|
|
|
|
// (metadata only) to be able to assign collections to groups/members. This does NOT
|
|
|
|
// expose cipher contents. manage_policies does not need the collection list.
|
|
|
|
let can_read_collection_list = headers.membership.has_full_access() |
|
|
|
|| headers.membership.has_manage_users() |
|
|
|
|| headers.membership.has_manage_groups() |
|
|
|
|| headers.membership.has_delete_any_collection() |
|
|
|
// Create new collections needs the list too: the client resolves the parent of a nested
|
|
|
|
// collection against it and refreshes it after a create.
|
|
|
|
|| headers.membership.has_create_new_collections(); |
|
|
|
if !can_read_collection_list { |
|
|
|
let can_read_collection_list = may_read_complete_collection_list(&headers.membership); |
|
|
|
let all_collections = Collection::find_by_organization(&org_id, &conn).await; |
|
|
|
let collections = if can_read_collection_list { |
|
|
|
all_collections |
|
|
|
} else { |
|
|
|
let mut explicitly_managed = Vec::new(); |
|
|
|
for collection in all_collections { |
|
|
|
if headers.membership.has_explicit_collection_manage_access(&collection.uuid, &conn).await { |
|
|
|
explicitly_managed.push(collection); |
|
|
|
} |
|
|
|
} |
|
|
|
explicitly_managed |
|
|
|
}; |
|
|
|
if !can_read_collection_list && collections.is_empty() { |
|
|
|
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); |
|
|
|
} |
|
|
|
|
|
|
|
Ok(Json(json!({ |
|
|
|
"data": get_org_collections_impl(&org_id, &conn).await, |
|
|
|
"data": collections.iter().map(Collection::to_json).collect::<Value>(), |
|
|
|
"object": "list", |
|
|
|
"continuationToken": null, |
|
|
|
}))) |
|
|
|
@ -444,11 +450,6 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea |
|
|
|
|| member.has_manage_groups() |
|
|
|
|| member.has_delete_any_collection() |
|
|
|
|| member.has_create_new_collections(); |
|
|
|
// Delete any collection can reveal collection access metadata, matching Bitwarden's
|
|
|
|
// ReadAllWithAccess behavior, but still does not grant cipher access. Manage Users/Groups
|
|
|
|
// retain the narrower metadata-only view introduced by the base PR.
|
|
|
|
let can_read_all_collection_access = member.has_edit_any_collection() || member.has_delete_any_collection(); |
|
|
|
|
|
|
|
// Get all admins, owners and managers who can manage/access all
|
|
|
|
// Those are currently not listed in the col_users but need to be listed too.
|
|
|
|
let manage_all_members: Vec<Value> = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn) |
|
|
|
@ -472,30 +473,33 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea |
|
|
|
|| (CONFIG.org_groups_enabled() |
|
|
|
&& GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); |
|
|
|
|
|
|
|
// If the user is a manager and is not assigned to this collection, normally skip it.
|
|
|
|
// Exception: custom users with a manage permission get a metadata-only entry (no user
|
|
|
|
// or group access details) so the web client can resolve assignment references without
|
|
|
|
// crashing. This never exposes cipher contents.
|
|
|
|
if !assigned && !can_read_all_collection_access { |
|
|
|
if can_read_collection_list { |
|
|
|
// ACL mappings require the same authority as the single-collection details endpoint.
|
|
|
|
// Mere read access (`assigned`, including group `access_all`) is not Manage authority.
|
|
|
|
match collection_details_response_scope( |
|
|
|
can_read_collection_access(&member, &col.uuid, &conn).await, |
|
|
|
assigned, |
|
|
|
can_read_collection_list, |
|
|
|
) { |
|
|
|
CollectionDetailsResponseScope::MetadataOnly => { |
|
|
|
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; |
|
|
|
json_object["assigned"] = json!(false); |
|
|
|
json_object["assigned"] = json!(assigned); |
|
|
|
json_object["users"] = json!(Vec::<Value>::new()); |
|
|
|
json_object["groups"] = json!(Vec::<Value>::new()); |
|
|
|
json_object["object"] = json!("collectionAccessDetails"); |
|
|
|
json_object["unmanaged"] = json!(false); |
|
|
|
data.push(json_object); |
|
|
|
} |
|
|
|
continue; |
|
|
|
} |
|
|
|
|
|
|
|
CollectionDetailsResponseScope::Hidden => {} |
|
|
|
CollectionDetailsResponseScope::AccessDetails => { |
|
|
|
// get the users assigned directly to the given collection
|
|
|
|
let mut users: Vec<Value> = col_users |
|
|
|
.iter() |
|
|
|
.filter(|collection_member| collection_member.collection_uuid == col.uuid) |
|
|
|
.map(|collection_member| { |
|
|
|
collection_member.to_json_details_for_member( |
|
|
|
*membership_type.get(&collection_member.membership_uuid).unwrap_or(&(MembershipType::User as i32)), |
|
|
|
*membership_type |
|
|
|
.get(&collection_member.membership_uuid) |
|
|
|
.unwrap_or(&(MembershipType::User as i32)), |
|
|
|
) |
|
|
|
}) |
|
|
|
.collect(); |
|
|
|
@ -520,6 +524,8 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea |
|
|
|
json_object["unmanaged"] = json!(false); |
|
|
|
data.push(json_object); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
Ok(Json(json!({ |
|
|
|
"data": data, |
|
|
|
@ -528,8 +534,35 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea |
|
|
|
}))) |
|
|
|
} |
|
|
|
|
|
|
|
async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value { |
|
|
|
Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::<Value>() |
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)] |
|
|
|
enum CollectionDetailsResponseScope { |
|
|
|
AccessDetails, |
|
|
|
MetadataOnly, |
|
|
|
Hidden, |
|
|
|
} |
|
|
|
|
|
|
|
fn may_read_complete_collection_list(member: &Membership) -> bool { |
|
|
|
member.has_full_access() |
|
|
|
|| member.has_manage_users() |
|
|
|
|| member.has_manage_groups() |
|
|
|
|| member.has_delete_any_collection() |
|
|
|
// Create new collections needs the list too: the client resolves the parent of a nested
|
|
|
|
// collection against it and refreshes it after a create.
|
|
|
|
|| member.has_create_new_collections() |
|
|
|
} |
|
|
|
|
|
|
|
fn collection_details_response_scope( |
|
|
|
can_read_access_details: bool, |
|
|
|
has_collection_read_access: bool, |
|
|
|
can_read_collection_list: bool, |
|
|
|
) -> CollectionDetailsResponseScope { |
|
|
|
if can_read_access_details { |
|
|
|
CollectionDetailsResponseScope::AccessDetails |
|
|
|
} else if has_collection_read_access || can_read_collection_list { |
|
|
|
CollectionDetailsResponseScope::MetadataOnly |
|
|
|
} else { |
|
|
|
CollectionDetailsResponseScope::Hidden |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
#[post("/organizations/<org_id>/collections", data = "<data>")] |
|
|
|
@ -569,15 +602,14 @@ async fn post_organization_collections( |
|
|
|
let collection = Collection::new(org_id.clone(), data.name, data.external_id); |
|
|
|
collection.save(&conn).await?; |
|
|
|
|
|
|
|
// Security (F-3): a `manage` grant carries collection *delete*/administer authority
|
|
|
|
// (`has_explicit_collection_manage_access` -> CollectionDeleteHeaders/ManagerHeaders), so only a
|
|
|
|
// caller who could delete this collection may confer it — the same rule the collection-update and
|
|
|
|
// bulk-access endpoints apply. Create is deliberately independent from Edit/Delete, so a Custom
|
|
|
|
// member holding only `create_new_collections` must not be able to hand a manage row to another
|
|
|
|
// member or to a group while creating the collection. For such callers the requested `manage`
|
|
|
|
// is forced to false; Admin/Owner and Custom-with-`delete_any_collection` keep it. The creator's
|
|
|
|
// own object-scoped ownership is added separately below. Evaluated after the collection exists
|
|
|
|
// so the per-collection lookup sees it.
|
|
|
|
// Security (F-3): a `manage` grant carries collection administration authority
|
|
|
|
// (`has_explicit_collection_manage_access` -> ManagerHeaders), so only a caller who may already
|
|
|
|
// administer this collection may confer it — the same rule the collection-update and bulk-access
|
|
|
|
// endpoints apply. Create is deliberately independent from Edit/Delete, so a Custom member
|
|
|
|
// holding only `create_new_collections` must not be able to hand a manage row to another member
|
|
|
|
// or to a group while creating the collection. For such callers the requested `manage` is forced
|
|
|
|
// to false. The creator's own object-scoped ownership is added separately below. Evaluated after
|
|
|
|
// the collection exists so the per-collection lookup sees it.
|
|
|
|
let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await; |
|
|
|
let creator_needs_assignment = !headers.membership.has_full_access(); |
|
|
|
|
|
|
|
@ -1088,32 +1120,17 @@ async fn assigned_org_ciphers_json( |
|
|
|
// report (Exposed/Reused/Weak Passwords, Unsecured Websites, Inactive 2FA, ...) locally — Vaultwarden
|
|
|
|
// has no server-side reports.
|
|
|
|
//
|
|
|
|
// Two different answers, depending on how much the caller may actually read:
|
|
|
|
//
|
|
|
|
// * Members who already reach every collection (Admin/Owner, or Custom + `editAnyCollection`) get
|
|
|
|
// the whole organization, serialized with `CipherSyncType::Organization` which deliberately skips
|
|
|
|
// the per-cipher access restrictions. This is unchanged behavior.
|
|
|
|
//
|
|
|
|
// * `accessReports` opens the endpoint *without* widening what may be read: the response is built
|
|
|
|
// from the caller's own assignments with `CipherSyncType::User`, so `readOnly`/`hidePasswords`
|
|
|
|
// still apply and collections the member is not assigned to never appear. Their reports therefore
|
|
|
|
// cover exactly their own collections.
|
|
|
|
//
|
|
|
|
// This mirrors `accessImportExport`/`get_org_export`: a permission decides *whether* a member may use
|
|
|
|
// a feature, never *what* they may read. Bitwarden upstream is more permissive here (its
|
|
|
|
// `CanAccessAllCiphersAsync` grants the full organization to AccessReports as well); we deliberately
|
|
|
|
// deviate so that ticking "Access reports" cannot hand out read access to every password in the
|
|
|
|
// organization.
|
|
|
|
// Bitwarden computes organization reports locally from this list. `accessReports` therefore grants
|
|
|
|
// the full organization cipher list, just like Admin/Owner or `editAnyCollection`; limiting it to the
|
|
|
|
// caller's assignments makes organization-wide reports silently incomplete.
|
|
|
|
#[get("/ciphers/organization-details?<data..>")] |
|
|
|
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { |
|
|
|
if data.organization_id != headers.membership.org_uuid { |
|
|
|
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); |
|
|
|
} |
|
|
|
|
|
|
|
let ciphers_json = if headers.membership.has_full_access() { |
|
|
|
let ciphers_json = if may_read_all_organization_ciphers(&headers.membership) { |
|
|
|
get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? |
|
|
|
} else if headers.membership.has_access_reports() { |
|
|
|
assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? |
|
|
|
} else { |
|
|
|
err_code!( |
|
|
|
"Resource not found.", |
|
|
|
@ -1320,15 +1337,26 @@ impl CustomRolePermissions { |
|
|
|
} |
|
|
|
|
|
|
|
fn differs_from(self, membership: &Membership) -> bool { |
|
|
|
self.manage_users != membership.manage_users |
|
|
|
|| self.manage_groups != membership.manage_groups |
|
|
|
|| self.manage_policies != membership.manage_policies |
|
|
|
|| self.create_new_collections != membership.create_new_collections |
|
|
|
|| self.edit_any_collection != membership.edit_any_collection |
|
|
|
|| self.delete_any_collection != membership.delete_any_collection |
|
|
|
|| self.access_event_logs != membership.access_event_logs |
|
|
|
|| self.access_import_export != membership.access_import_export |
|
|
|
|| self.access_reports != membership.access_reports |
|
|
|
let stored = if membership.atype == MembershipType::Custom as i32 { |
|
|
|
Self { |
|
|
|
manage_users: membership.manage_users, |
|
|
|
manage_groups: membership.manage_groups, |
|
|
|
manage_policies: membership.manage_policies, |
|
|
|
create_new_collections: membership.create_new_collections, |
|
|
|
edit_any_collection: membership.edit_any_collection, |
|
|
|
delete_any_collection: membership.delete_any_collection, |
|
|
|
access_event_logs: membership.access_event_logs, |
|
|
|
access_import_export: membership.access_import_export, |
|
|
|
access_reports: membership.access_reports, |
|
|
|
} |
|
|
|
} else { |
|
|
|
// Permission bits outside the Custom role are stale, inert data. Clearing them while an
|
|
|
|
// ordinary member is edited is not an authority change and must not make a
|
|
|
|
// ManageUsers-only caller fail the "may not change custom permissions" check.
|
|
|
|
Self::default() |
|
|
|
}; |
|
|
|
|
|
|
|
self != stored |
|
|
|
} |
|
|
|
|
|
|
|
fn apply_to(self, membership: &mut Membership) { |
|
|
|
@ -1428,9 +1456,14 @@ async fn send_invite( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
let mut user_created: bool = false; |
|
|
|
for email in &data.emails { |
|
|
|
let mut member_status = MembershipStatus::Invited as i32; |
|
|
|
// Scoped to this iteration on purpose. A single flag hoisted out of the loop stays `true`
|
|
|
|
// for every later recipient once any account has been created, so a failing invite mail to
|
|
|
|
// an address that already had an account would delete that *existing* global user -- their
|
|
|
|
// personal ciphers, devices, 2FA, emergency access and memberships in unrelated
|
|
|
|
// organizations -- instead of only the membership this request just made.
|
|
|
|
let mut user_created: bool = false; |
|
|
|
let user = match User::find_by_mail(email, &conn).await { |
|
|
|
None => { |
|
|
|
if !CONFIG.invitations_allowed() { |
|
|
|
@ -1958,6 +1991,16 @@ async fn edit_member( |
|
|
|
// confirm, revoke, restore, delete). Without it `edit_member` was the only path on which a
|
|
|
|
// Custom member holding manage_users could aim at an Admin or at a fellow Custom membership, as
|
|
|
|
// long as the request left the role unchanged.
|
|
|
|
//
|
|
|
|
// NOTE: this is a deliberate, documented narrowing of upstream. Bitwarden lets Custom+ManageUsers
|
|
|
|
// administer *peer Custom* members too, and delegate a subset of the permissions the actor holds
|
|
|
|
// itself (`OrganizationUserValidationService`). Implementing that would put permission delegation
|
|
|
|
// -- the one operation that can raise another member's authority -- into the hands of a
|
|
|
|
// non-Admin, and correctness would then rest on a subset comparison being right on every path.
|
|
|
|
// Vaultwarden keeps role and permission changes with Admins/Owners instead: strictly less
|
|
|
|
// authority than upstream grants, and the failure mode is a refused request rather than an
|
|
|
|
// escalation. Change this only together with tests for every actor/target/permission-subset
|
|
|
|
// combination.
|
|
|
|
if !may_manage_stored_member_type(headers.membership_type, member_to_edit.atype) { |
|
|
|
err!("You don't have permission to edit this member") |
|
|
|
} |
|
|
|
@ -1985,6 +2028,15 @@ async fn edit_member( |
|
|
|
// with full access) may change a member's collection assignments. A custom user with only
|
|
|
|
// manage_users must not be able to add/remove collection access, so we leave the existing
|
|
|
|
// assignments untouched for them.
|
|
|
|
//
|
|
|
|
// NOTE: another deliberate narrowing of upstream, which resolves ModifyUserAccess per collection
|
|
|
|
// and accepts a per-collection Manage grant on every affected collection. Requiring blanket
|
|
|
|
// authority here is coarser -- a ManageUsers member holding Manage on exactly the collections in
|
|
|
|
// the request is refused -- but it keeps a *stored* grant from being reachable as a lever for
|
|
|
|
// handing out access, which is the same boundary `caller_may_grant_collection_manage` draws. The
|
|
|
|
// group paths below (`post_groups`, `put_group_members`, `delete_group`) are narrowed for the same
|
|
|
|
// reason. Widening this needs the per-collection check to cover the members' *current* assignments
|
|
|
|
// as well as the requested ones, or removal becomes the hole.
|
|
|
|
let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin |
|
|
|
|| match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { |
|
|
|
Some(m) => m.has_full_access(), |
|
|
|
@ -2020,40 +2072,38 @@ async fn edit_member( |
|
|
|
// We need to perform the check after changing the type since `admin` is exempt.
|
|
|
|
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; |
|
|
|
|
|
|
|
if caller_can_manage_collections { |
|
|
|
// Delete all the odd collections
|
|
|
|
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { |
|
|
|
c.delete(&conn).await?; |
|
|
|
} |
|
|
|
|
|
|
|
// Security (F-1): a per-collection `manage` grant carries delete authority, so the caller
|
|
|
|
// may only confer it on collections they could delete themselves. A caller acting via
|
|
|
|
// Edit-any-collection thus cannot hand another member a manage/delete grant it lacks.
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
// Validation phase. Nothing below this point may be written until every id, tenant binding and
|
|
|
|
// caller right in the request has been checked.
|
|
|
|
//
|
|
|
|
// This endpoint replaces a member's collection assignments and their group memberships, and
|
|
|
|
// Vaultwarden has no database transactions, so an error raised *between* those two replaces used
|
|
|
|
// to leave the request half-applied: the member's collection access already changed, their groups
|
|
|
|
// still the old ones, no `OrganizationUserUpdated` event written, and a 4xx on the wire telling
|
|
|
|
// the client that nothing happened. A foreign group id -- exactly the case the tenant check below
|
|
|
|
// exists for -- was enough to trigger it. Resolving everything first cannot make the two replaces
|
|
|
|
// atomic against a database error, but it does mean a *rejected* request changes nothing.
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Security (F-1): a per-collection `manage` grant is durable administration authority, so the
|
|
|
|
// caller may only confer it where they already hold it themselves. A caller acting via
|
|
|
|
// Edit-any-collection thus cannot hand another member a manage grant it lacks.
|
|
|
|
let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; |
|
|
|
|
|
|
|
// If the member does not already reach every collection, add the collections received
|
|
|
|
if !grants_full_access { |
|
|
|
// Resolve the requested assignments: every collection has to exist in *this* organization, and
|
|
|
|
// the effective `manage` bit is decided here rather than while writing.
|
|
|
|
let mut collection_assignments: Vec<(CollectionId, bool, bool, bool)> = Vec::new(); |
|
|
|
if caller_can_manage_collections && !grants_full_access { |
|
|
|
for col in data.collections.iter().flatten() { |
|
|
|
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { |
|
|
|
None => err!("Collection not found in Organization"), |
|
|
|
Some(collection) => { |
|
|
|
let Some(collection) = Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await else { |
|
|
|
err!("Collection not found in Organization") |
|
|
|
}; |
|
|
|
let manage = col.manage |
|
|
|
&& match &caller { |
|
|
|
Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await, |
|
|
|
None => false, |
|
|
|
}; |
|
|
|
CollectionUser::save( |
|
|
|
&member_to_edit.user_uuid, |
|
|
|
&collection.uuid, |
|
|
|
col.read_only, |
|
|
|
col.hide_passwords, |
|
|
|
manage, |
|
|
|
&conn, |
|
|
|
) |
|
|
|
.await?; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
collection_assignments.push((collection.uuid, col.read_only, col.hide_passwords, manage)); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -2061,7 +2111,7 @@ async fn edit_member( |
|
|
|
// (via the groups' collections). Only callers who may manage groups (Admins/Owners or users
|
|
|
|
// with manage_groups) are allowed to change it. For others we leave group membership untouched.
|
|
|
|
let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin |
|
|
|
|| match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { |
|
|
|
|| match &caller { |
|
|
|
Some(m) => m.has_manage_groups(), |
|
|
|
None => false, |
|
|
|
}; |
|
|
|
@ -2086,54 +2136,66 @@ async fn edit_member( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Security (audit H-2): every requested group has to belong to this organization. Otherwise a
|
|
|
|
// caller could link the member to a group of a foreign tenant (e.g. an access-all group), which
|
|
|
|
// the direct cipher-access checks would then honor. Fail closed on the whole request.
|
|
|
|
if caller_can_manage_groups { |
|
|
|
// Security (audit H-2): validate that every requested group belongs to this organization
|
|
|
|
// *before* mutating any group membership. Otherwise a caller could link the member to a
|
|
|
|
// group of a foreign tenant (e.g. an access-all group), which the direct cipher-access
|
|
|
|
// checks would then honor. Fail closed on the whole request if any group is foreign.
|
|
|
|
for group_id in data.groups.iter().flatten() { |
|
|
|
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { |
|
|
|
err!("Group not found in this organization") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if caller_can_manage_collections { |
|
|
|
// Caller may grant/revoke collection access via groups: full replace.
|
|
|
|
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; |
|
|
|
|
|
|
|
for group_id in data.groups.iter().flatten() { |
|
|
|
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); |
|
|
|
group_entry.save(&conn).await?; |
|
|
|
} |
|
|
|
} else { |
|
|
|
// Security: the caller may manage groups but NOT collections. They may only change the
|
|
|
|
// member's membership in groups that confer no collection access; collection-bearing
|
|
|
|
// memberships are preserved untouched (neither granted nor revoked), mirroring the
|
|
|
|
// restriction enforced in put_group_members and add_update_group.
|
|
|
|
|
|
|
|
// Remove the member only from non-collection-bearing groups; keep collection-bearing
|
|
|
|
// memberships so this caller cannot revoke collection access either.
|
|
|
|
for gu in GroupUser::find_by_member(&member_to_edit.uuid, &conn).await { |
|
|
|
if may_change_group_membership( |
|
|
|
|
|
|
|
// Decide the group changes while still not writing. A caller who may manage groups but *not*
|
|
|
|
// collections may only touch memberships in groups that confer no collection access; the others
|
|
|
|
// are preserved untouched (neither granted nor revoked), mirroring put_group_members and
|
|
|
|
// add_update_group.
|
|
|
|
let mut groups_to_remove: Vec<GroupId> = Vec::new(); |
|
|
|
let mut groups_to_add: Vec<GroupId> = Vec::new(); |
|
|
|
if caller_can_manage_groups { |
|
|
|
for group_id in ¤t_groups { |
|
|
|
if caller_can_manage_collections |
|
|
|
|| may_change_group_membership( |
|
|
|
caller_can_manage_collections, |
|
|
|
group_confers_collection_access(&gu.groups_uuid, &org_id, &conn).await, |
|
|
|
) { |
|
|
|
GroupUser::delete_by_group_and_member(&gu.groups_uuid, &member_to_edit.uuid, &conn).await?; |
|
|
|
group_confers_collection_access(group_id, &org_id, &conn).await, |
|
|
|
) |
|
|
|
{ |
|
|
|
groups_to_remove.push(group_id.clone()); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Add the requested groups, skipping any that would grant collection access.
|
|
|
|
for group_id in data.groups.iter().flatten() { |
|
|
|
if !may_change_group_membership( |
|
|
|
if caller_can_manage_collections |
|
|
|
|| may_change_group_membership( |
|
|
|
caller_can_manage_collections, |
|
|
|
group_confers_collection_access(group_id, &org_id, &conn).await, |
|
|
|
) { |
|
|
|
continue; |
|
|
|
) |
|
|
|
{ |
|
|
|
groups_to_add.push(group_id.clone()); |
|
|
|
} |
|
|
|
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); |
|
|
|
group_entry.save(&conn).await?; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
// Write phase.
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
if caller_can_manage_collections { |
|
|
|
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { |
|
|
|
c.delete(&conn).await?; |
|
|
|
} |
|
|
|
for (collection_uuid, read_only, hide_passwords, manage) in collection_assignments { |
|
|
|
CollectionUser::save(&member_to_edit.user_uuid, &collection_uuid, read_only, hide_passwords, manage, &conn) |
|
|
|
.await?; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
for group_id in groups_to_remove { |
|
|
|
GroupUser::delete_by_group_and_member(&group_id, &member_to_edit.uuid, &conn).await?; |
|
|
|
} |
|
|
|
for group_id in groups_to_add { |
|
|
|
let mut group_entry = GroupUser::new(group_id, member_to_edit.uuid.clone()); |
|
|
|
group_entry.save(&conn).await?; |
|
|
|
} |
|
|
|
|
|
|
|
log_event( |
|
|
|
@ -2291,7 +2353,7 @@ async fn bulk_public_keys( |
|
|
|
} |
|
|
|
|
|
|
|
use super::ciphers::CipherData; |
|
|
|
use super::ciphers::update_cipher_from_data; |
|
|
|
use super::ciphers::update_cipher_from_data_with_authority; |
|
|
|
|
|
|
|
#[derive(Deserialize)] |
|
|
|
#[serde(rename_all = "camelCase")] |
|
|
|
@ -2324,15 +2386,9 @@ async fn post_org_import( |
|
|
|
err!("Organization not found", "Organization id's do not match"); |
|
|
|
} |
|
|
|
|
|
|
|
// NOTE: no `accessImportExport` gate here on purpose. Bitwarden does not require the permission
|
|
|
|
// either — `ImportCiphersController.CheckOrgImportPermissionAsync` authorizes an organization
|
|
|
|
// import on `AccessImportExport` *or* per-collection Create/ImportCiphers authority. Vaultwarden
|
|
|
|
// has always authorized this endpoint per target collection, so an up-front role check would take
|
|
|
|
// a capability away from ordinary members that they have today. The real boundary is enforced
|
|
|
|
// below and is unchanged: an existing collection must be writable for the caller
|
|
|
|
// (`Collection::is_writable_by_user`), and creating a new one requires the independent
|
|
|
|
// `createNewCollections` permission. The one deliberate difference from Bitwarden is that
|
|
|
|
// `accessImportExport` alone does not open the endpoint here; it governs the export side only.
|
|
|
|
// Bitwarden authorizes an organization import on `AccessImportExport` *or* the regular
|
|
|
|
// per-collection Create/ImportCiphers authority. Keep the latter path for ordinary members while
|
|
|
|
// treating the named Custom permission as the organization-wide import shortcut it represents.
|
|
|
|
//
|
|
|
|
// A confirmed membership is required though: both checks below are confirmed-gated, so an
|
|
|
|
// invited/accepted member could otherwise only import ciphers without any collection — which lands
|
|
|
|
@ -2340,6 +2396,7 @@ async fn post_org_import( |
|
|
|
if !headers.membership.has_status(MembershipStatus::Confirmed) { |
|
|
|
err!("You need to be a confirmed member of this organization to import into it") |
|
|
|
} |
|
|
|
let has_org_wide_import_access = may_import_without_collection_access(&headers.membership); |
|
|
|
|
|
|
|
let data: ImportData = data.into_inner(); |
|
|
|
|
|
|
|
@ -2370,24 +2427,33 @@ async fn post_org_import( |
|
|
|
// assignment must not let an importer plant ciphers into a shared collection.
|
|
|
|
let existing_collections: HashMap<CollectionId, Collection> = |
|
|
|
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); |
|
|
|
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); |
|
|
|
for col in data.collections { |
|
|
|
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); |
|
|
|
let collection_uuid = if let Some(collection) = existing { |
|
|
|
// When not an Owner or Admin, check if the member is allowed to write to the collection.
|
|
|
|
if headers.membership.atype < MembershipType::Admin |
|
|
|
|
|
|
|
// Finish every request-controlled collection authorization check before the first new collection
|
|
|
|
// is written. This matters for the PR's create-only Custom role: a payload may name a new
|
|
|
|
// collection first and an existing, non-writable collection later. Rejecting the latter only in
|
|
|
|
// the write loop left the former behind even though the request failed.
|
|
|
|
for col in &data.collections { |
|
|
|
if let Some(collection) = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)) { |
|
|
|
if !has_org_wide_import_access |
|
|
|
&& headers.membership.atype < MembershipType::Admin |
|
|
|
&& !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await |
|
|
|
{ |
|
|
|
err!(Compact, "The current user isn't allowed to manage this collection") |
|
|
|
} |
|
|
|
} else if !has_org_wide_import_access && !headers.membership.can_create_new_collections() { |
|
|
|
err!(Compact, "The current user isn't allowed to create new collections") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); |
|
|
|
for col in data.collections { |
|
|
|
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); |
|
|
|
let collection_uuid = if let Some(collection) = existing { |
|
|
|
collection.uuid.clone() |
|
|
|
} else { |
|
|
|
// Collection creation through an organization import is governed by the same
|
|
|
|
// independent permission as the regular create endpoint. In particular,
|
|
|
|
// Edit any collection (full access to every collection) must not satisfy this check.
|
|
|
|
if !headers.membership.can_create_new_collections() { |
|
|
|
err!(Compact, "The current user isn't allowed to create new collections") |
|
|
|
} |
|
|
|
let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); |
|
|
|
new_collection.save(&conn).await?; |
|
|
|
// Import-created collections do not carry the regular create endpoint's user access
|
|
|
|
@ -2419,19 +2485,18 @@ async fn post_org_import( |
|
|
|
// Replace the client-provided, unvalidated organizationId with the real target org
|
|
|
|
cipher_data.organization_id = Some(org_id.clone()); |
|
|
|
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); |
|
|
|
// Propagate cipher-save failures instead of silently discarding them (audit M-3): a
|
|
|
|
// discarded error would still push the cipher id and let a relationship reference a cipher
|
|
|
|
// that was never persisted. This matches Bitwarden's all-or-nothing import semantics.
|
|
|
|
update_cipher_from_data( |
|
|
|
update_cipher_from_data_with_authority( |
|
|
|
&mut cipher, |
|
|
|
cipher_data, |
|
|
|
&headers, |
|
|
|
Some(collections.clone()), |
|
|
|
has_org_wide_import_access, |
|
|
|
&conn, |
|
|
|
&nt, |
|
|
|
UpdateType::None, |
|
|
|
) |
|
|
|
.await?; |
|
|
|
.await |
|
|
|
.ok(); |
|
|
|
ciphers.push(cipher.uuid); |
|
|
|
} |
|
|
|
|
|
|
|
@ -3403,29 +3468,27 @@ async fn group_confers_collection_access(group_id: &GroupId, org_id: &Organizati |
|
|
|
/// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` /
|
|
|
|
/// `collections_groups.manage`) on `col_id`.
|
|
|
|
///
|
|
|
|
/// Security (F-1, edit-any -> delete-any escalation): a `manage` grant carries collection *delete*
|
|
|
|
/// authority — `CollectionDeleteHeaders` accepts it via `has_explicit_collection_manage_access`.
|
|
|
|
/// Without this gate a Custom member holding only `edit_any_collection` (which grants full access to
|
|
|
|
/// every collection) could, through the collection-access / group endpoints, hand a `manage` row to
|
|
|
|
/// a group they belong to (or to a manager-level member) and thereby gain deletion — a capability
|
|
|
|
/// `edit_any_collection` must never imply.
|
|
|
|
/// Security (F-1): a `manage` grant is per-collection administration authority — `ManagerHeaders`
|
|
|
|
/// accepts it via `has_explicit_collection_manage_access`, and it survives every later change to the
|
|
|
|
/// grantee's role. Without this gate a Custom member holding only `edit_any_collection` (which grants
|
|
|
|
/// full access to every collection, but is meant to be revocable by clearing one flag) could, through
|
|
|
|
/// the collection-access / group endpoints, hand a permanent `manage` row to a group they belong to
|
|
|
|
/// and keep that authority after the flag is gone.
|
|
|
|
///
|
|
|
|
/// We therefore allow granting `manage` on a collection only to a caller who could delete that same
|
|
|
|
/// collection themselves, mirroring `collection_delete_access` exactly so it can never hand out a
|
|
|
|
/// right the caller lacks: Admin/Owner and Custom-with-`delete_any_collection` always qualify; any
|
|
|
|
/// other Custom member must hold a real explicit manage grant. This is strictly subtractive — it can
|
|
|
|
/// only ever downgrade a requested `manage` to `false`, never grant it — so it opens no new access,
|
|
|
|
/// and delete-capable members (including all Admins/Owners) are unaffected.
|
|
|
|
/// We therefore allow granting `manage` on a collection only to a caller who already holds blanket
|
|
|
|
/// collection authority or a real stored manage grant on that same collection: Admin/Owner and
|
|
|
|
/// Custom-with-`delete_any_collection` always qualify; any other Custom member must hold an explicit
|
|
|
|
/// manage grant. This is strictly subtractive — it can only ever downgrade a requested `manage` to
|
|
|
|
/// `false`, never grant it — so it opens no new access, and Admins/Owners are unaffected.
|
|
|
|
async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { |
|
|
|
match caller_manage_grant_role_check(caller) { |
|
|
|
// Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no).
|
|
|
|
Some(decision) => decision, |
|
|
|
// Custom without delete_any: the answer is per-collection and must reflect a *real* stored
|
|
|
|
// manage grant. Edit any collection deliberately does not count here, and neither does the
|
|
|
|
// legacy `access_all`-group authority: that one is derived from a group membership that can
|
|
|
|
// be taken away again, while a `manage` row written here outlives it. Accepting it would let
|
|
|
|
// temporary authority be laundered into a permanent grant — and with it collection deletion
|
|
|
|
// — which is exactly the escalation this clamp exists to prevent.
|
|
|
|
// manage grant. Edit any collection deliberately does not count here — it is revocable by
|
|
|
|
// clearing a flag, while a `manage` row written here outlives it. Accepting it would let
|
|
|
|
// temporary authority be laundered into a permanent grant, which is exactly the escalation
|
|
|
|
// this clamp exists to prevent.
|
|
|
|
None => match MembershipType::from_i32(caller.atype) { |
|
|
|
Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await, |
|
|
|
_ => false, |
|
|
|
@ -3433,6 +3496,20 @@ async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &Collec |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// Whether a caller may import throughout the organization without proving Create/Write authority
|
|
|
|
/// for every target collection. This is the server-side meaning of Bitwarden's
|
|
|
|
/// `accessImportExport` Custom permission; Admins and Owners already have equivalent authority.
|
|
|
|
fn may_import_without_collection_access(caller: &Membership) -> bool { |
|
|
|
caller.has_status(MembershipStatus::Confirmed) |
|
|
|
&& (caller.atype >= MembershipType::Admin || caller.has_access_import_export()) |
|
|
|
} |
|
|
|
|
|
|
|
/// Organization reports are computed client-side and require every organization cipher. Match
|
|
|
|
/// Bitwarden's `AccessReports` semantics instead of silently producing assignment-scoped reports.
|
|
|
|
fn may_read_all_organization_ciphers(caller: &Membership) -> bool { |
|
|
|
caller.has_full_access() || (caller.has_status(MembershipStatus::Confirmed) && caller.has_access_reports()) |
|
|
|
} |
|
|
|
|
|
|
|
/// Whether `caller` may export the *entire* organization instead of only their own assignments.
|
|
|
|
///
|
|
|
|
/// Security (audit F1): the `AccessImportExportHeaders` guard on `get_org_export` decides whether a
|
|
|
|
@ -4188,10 +4265,12 @@ mod tests { |
|
|
|
use serde_json::{Value, json}; |
|
|
|
|
|
|
|
use super::{ |
|
|
|
CustomRolePermissions, caller_manage_grant_role_check, collection_bearing_membership_unchanged, |
|
|
|
filter_ciphers_for_organization, may_change_group_membership, may_change_member_type, |
|
|
|
may_export_entire_organization, may_manage_member_type, may_manage_stored_member_type, |
|
|
|
may_provision_member_type, may_provision_stored_member_type, |
|
|
|
CollectionDetailsResponseScope, CustomRolePermissions, caller_manage_grant_role_check, |
|
|
|
collection_bearing_membership_unchanged, collection_details_response_scope, filter_ciphers_for_organization, |
|
|
|
may_change_group_membership, may_change_member_type, may_export_entire_organization, |
|
|
|
may_import_without_collection_access, may_manage_member_type, may_manage_stored_member_type, |
|
|
|
may_provision_member_type, may_provision_stored_member_type, may_read_all_organization_ciphers, |
|
|
|
may_read_complete_collection_list, |
|
|
|
}; |
|
|
|
use crate::db::models::{Cipher, GroupId, Membership, MembershipStatus, MembershipType, OrganizationId}; |
|
|
|
|
|
|
|
@ -4202,6 +4281,41 @@ mod tests { |
|
|
|
m |
|
|
|
} |
|
|
|
|
|
|
|
#[test] |
|
|
|
fn bulk_collection_details_only_include_acls_for_manage_authority() { |
|
|
|
// Ordinary collection assignment, including group access_all, keeps the collection metadata
|
|
|
|
// visible but must never reveal user/group ACL mappings.
|
|
|
|
assert_eq!(collection_details_response_scope(false, false, false), CollectionDetailsResponseScope::Hidden); |
|
|
|
assert_eq!(collection_details_response_scope(false, true, false), CollectionDetailsResponseScope::MetadataOnly); |
|
|
|
assert_eq!(collection_details_response_scope(false, false, true), CollectionDetailsResponseScope::MetadataOnly); |
|
|
|
|
|
|
|
// Admin/Owner, Edit-any/Delete-any, and explicit per-collection Manage all arrive here as
|
|
|
|
// `can_read_access_details = true`, matching CollectionReadHeaders on the single endpoint.
|
|
|
|
assert_eq!( |
|
|
|
collection_details_response_scope(true, false, false), |
|
|
|
CollectionDetailsResponseScope::AccessDetails |
|
|
|
); |
|
|
|
assert_eq!(collection_details_response_scope(true, true, true), CollectionDetailsResponseScope::AccessDetails); |
|
|
|
} |
|
|
|
|
|
|
|
#[test] |
|
|
|
fn flagless_custom_uses_only_its_explicit_manage_collections_in_the_list() { |
|
|
|
// `false` selects the route's per-collection explicit-Manage filtering path. Permissions that
|
|
|
|
// need metadata for every collection select the complete list instead.
|
|
|
|
assert!(!may_read_complete_collection_list(&confirmed_member(MembershipType::Custom))); |
|
|
|
|
|
|
|
let mut manage_users = confirmed_member(MembershipType::Custom); |
|
|
|
manage_users.manage_users = true; |
|
|
|
assert!(may_read_complete_collection_list(&manage_users)); |
|
|
|
|
|
|
|
let mut create = confirmed_member(MembershipType::Custom); |
|
|
|
create.create_new_collections = true; |
|
|
|
assert!(may_read_complete_collection_list(&create)); |
|
|
|
|
|
|
|
assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Admin))); |
|
|
|
assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Owner))); |
|
|
|
} |
|
|
|
|
|
|
|
#[test] |
|
|
|
fn only_delete_capable_callers_may_grant_collection_manage() { |
|
|
|
// Admin/Owner may always confer a per-collection `manage` (delete) grant.
|
|
|
|
@ -4261,6 +4375,39 @@ mod tests { |
|
|
|
assert!(!may_export_entire_organization(&unconfirmed)); |
|
|
|
} |
|
|
|
|
|
|
|
#[test] |
|
|
|
fn access_import_export_opens_the_organization_import() { |
|
|
|
let mut import_export = confirmed_member(MembershipType::Custom); |
|
|
|
import_export.access_import_export = true; |
|
|
|
assert!(may_import_without_collection_access(&import_export)); |
|
|
|
|
|
|
|
assert!(!may_import_without_collection_access(&confirmed_member(MembershipType::Custom))); |
|
|
|
assert!(!may_import_without_collection_access(&confirmed_member(MembershipType::User))); |
|
|
|
assert!(may_import_without_collection_access(&confirmed_member(MembershipType::Admin))); |
|
|
|
assert!(may_import_without_collection_access(&confirmed_member(MembershipType::Owner))); |
|
|
|
|
|
|
|
import_export.status = MembershipStatus::Accepted as i32; |
|
|
|
assert!(!may_import_without_collection_access(&import_export)); |
|
|
|
} |
|
|
|
|
|
|
|
#[test] |
|
|
|
fn access_reports_grants_the_complete_report_input() { |
|
|
|
let mut reports = confirmed_member(MembershipType::Custom); |
|
|
|
reports.access_reports = true; |
|
|
|
assert!(may_read_all_organization_ciphers(&reports)); |
|
|
|
|
|
|
|
assert!(!may_read_all_organization_ciphers(&confirmed_member(MembershipType::Custom))); |
|
|
|
assert!(may_read_all_organization_ciphers(&confirmed_member(MembershipType::Admin))); |
|
|
|
assert!(may_read_all_organization_ciphers(&confirmed_member(MembershipType::Owner))); |
|
|
|
|
|
|
|
reports.status = MembershipStatus::Accepted as i32; |
|
|
|
assert!(!may_read_all_organization_ciphers(&reports)); |
|
|
|
|
|
|
|
let mut stale_user = confirmed_member(MembershipType::User); |
|
|
|
stale_user.access_reports = true; |
|
|
|
assert!(!may_read_all_organization_ciphers(&stale_user)); |
|
|
|
} |
|
|
|
|
|
|
|
#[test] |
|
|
|
fn assigned_cipher_response_is_scoped_to_requested_organization() { |
|
|
|
let requested_org: OrganizationId = "requested-org".to_owned().into(); |
|
|
|
@ -4533,4 +4680,34 @@ mod tests { |
|
|
|
CustomRolePermissions::default() |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
#[test] |
|
|
|
fn stale_permission_bits_on_non_custom_members_are_not_authority_changes() { |
|
|
|
let mut membership = confirmed_member(MembershipType::User); |
|
|
|
membership.manage_users = true; |
|
|
|
membership.manage_groups = true; |
|
|
|
membership.manage_policies = true; |
|
|
|
membership.create_new_collections = true; |
|
|
|
membership.edit_any_collection = true; |
|
|
|
membership.delete_any_collection = true; |
|
|
|
membership.access_event_logs = true; |
|
|
|
membership.access_import_export = true; |
|
|
|
membership.access_reports = true; |
|
|
|
|
|
|
|
let requested = CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership); |
|
|
|
assert_eq!(requested, CustomRolePermissions::default()); |
|
|
|
assert!(!requested.differs_from(&membership)); |
|
|
|
|
|
|
|
// Applying the effective request opportunistically clears the inert historical data.
|
|
|
|
requested.apply_to(&mut membership); |
|
|
|
assert!(!membership.manage_users); |
|
|
|
assert!(!membership.manage_groups); |
|
|
|
assert!(!membership.manage_policies); |
|
|
|
assert!(!membership.create_new_collections); |
|
|
|
assert!(!membership.edit_any_collection); |
|
|
|
assert!(!membership.delete_any_collection); |
|
|
|
assert!(!membership.access_event_logs); |
|
|
|
assert!(!membership.access_import_export); |
|
|
|
assert!(!membership.access_reports); |
|
|
|
} |
|
|
|
} |
|
|
|
|