Browse Source

Merge 5fda3e4dac into 660faee68e

pull/7397/merge
Tom 21 hours ago
committed by GitHub
parent
commit
3e8c866133
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql
  2. 9
      migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql
  3. 9
      migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql
  4. 27
      migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql
  5. 6
      migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql
  6. 9
      migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql
  7. 9
      migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql
  8. 27
      migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql
  9. 6
      migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql
  10. 9
      migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql
  11. 9
      migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql
  12. 27
      migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql
  13. 107
      src/api/admin.rs
  14. 1148
      src/api/core/organizations.rs
  15. 400
      src/auth.rs
  16. 59
      src/db/models/cipher.rs
  17. 11
      src/db/models/collection.rs
  18. 35
      src/db/models/group.rs
  19. 382
      src/db/models/organization.rs
  20. 6
      src/db/schema.rs
  21. 6
      src/static/scripts/admin_users.js
  22. 3
      src/static/templates/admin/users.hbs
  23. 7
      src/static/templates/scss/vaultwarden.scss.hbs

6
migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,6 @@
-- Convert Custom members back to Manager, the representation older server versions
-- expect (they masquerade Manager as Custom in API responses and cannot load type 4).
UPDATE users_organizations SET atype = 3 WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;

9
migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,9 @@
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE;
-- Previously the server stored members created with the Custom role as Manager (3) and
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted
-- type, convert those members so clients (which no longer know the Manager role) keep
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE,
-- which matches the capabilities these members had.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;

9
migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql

@ -0,0 +1,9 @@
-- The previous schema exposes access_all as the three collection permissions together. Avoid
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back.
UPDATE users_organizations
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection
WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;

27
migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql

@ -0,0 +1,27 @@
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
-- Before these permissions were persisted independently, access_all represented the legacy
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all
WHERE atype = 4;
-- A legacy Manager also managed every collection when one of their groups had access_all,
-- even if the membership itself did not. Preserve that existing edit/delete capability without
-- granting collection creation, which historically still required membership access_all.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND EXISTS (
SELECT 1
FROM groups_users
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid
WHERE groups_users.users_organizations_uuid = users_organizations.uuid
AND groups.organizations_uuid = users_organizations.org_uuid
AND groups.access_all = TRUE
);

6
migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,6 @@
-- Convert Custom members back to Manager, the representation older server versions
-- expect (they masquerade Manager as Custom in API responses and cannot load type 4).
UPDATE users_organizations SET atype = 3 WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;

9
migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,9 @@
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE;
-- Previously the server stored members created with the Custom role as Manager (3) and
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted
-- type, convert those members so clients (which no longer know the Manager role) keep
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE,
-- which matches the capabilities these members had.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;

9
migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql

@ -0,0 +1,9 @@
-- The previous schema exposes access_all as the three collection permissions together. Avoid
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back.
UPDATE users_organizations
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection
WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;

27
migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql

@ -0,0 +1,27 @@
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
-- Before these permissions were persisted independently, access_all represented the legacy
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all
WHERE atype = 4;
-- A legacy Manager also managed every collection when one of their groups had access_all,
-- even if the membership itself did not. Preserve that existing edit/delete capability without
-- granting collection creation, which historically still required membership access_all.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND EXISTS (
SELECT 1
FROM groups_users
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid
WHERE groups_users.users_organizations_uuid = users_organizations.uuid
AND groups.organizations_uuid = users_organizations.org_uuid
AND groups.access_all = TRUE
);

6
migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,6 @@
-- Convert Custom members back to Manager, the representation older server versions
-- expect (they masquerade Manager as Custom in API responses and cannot load type 4).
UPDATE users_organizations SET atype = 3 WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;

9
migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,9 @@
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE;
-- Previously the server stored members created with the Custom role as Manager (3) and
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted
-- type, convert those members so clients (which no longer know the Manager role) keep
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE,
-- which matches the capabilities these members had.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;

9
migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql

@ -0,0 +1,9 @@
-- The previous schema exposes access_all as the three collection permissions together. Avoid
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back.
UPDATE users_organizations
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection
WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;

27
migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql

@ -0,0 +1,27 @@
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
-- Before these permissions were persisted independently, access_all represented the legacy
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all
WHERE atype = 4;
-- A legacy Manager also managed every collection when one of their groups had access_all,
-- even if the membership itself did not. Preserve that existing edit/delete capability without
-- granting collection creation, which historically still required membership access_all.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND EXISTS (
SELECT 1
FROM groups_users
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid
WHERE groups_users.users_organizations_uuid = users_organizations.uuid
AND groups.organizations_uuid = users_organizations.org_uuid
AND groups.access_all = TRUE
);

107
src/api/admin.rs

@ -544,6 +544,41 @@ struct MembershipTypeData {
org_uuid: OrganizationId,
}
fn apply_membership_type_change(membership: &mut Membership, new_type: MembershipType) {
let was_custom = membership.atype == MembershipType::Custom;
// Leaving Custom for the legacy Manager role: `access_all` is the internal mirror of
// Edit any collection, but a Manager's `access_all` means the broad "manage all collections"
// grant (Create + Edit + Delete). Carrying an Edit-only mirror over would silently escalate the
// member to Create/Edit/Delete. Only preserve `access_all` when the member actually held the
// full manage-all grant, mirroring the collection-permissions down-migration. Must be evaluated
// before the flags are cleared below (and while the type is still Custom).
if was_custom && new_type == MembershipType::Manager {
membership.access_all = membership.has_manage_all_collections();
}
// Entering Custom through the Vaultwarden admin panel is deliberately fail-closed because
// that UI cannot select granular permissions; they can be granted later through the regular
// organization member dialog.
if new_type == MembershipType::Custom && !was_custom {
membership.clear_custom_permissions();
membership.access_all = false;
}
if new_type != MembershipType::Custom {
membership.clear_custom_permissions();
}
// Prevent stale access_all from surviving a demotion to User. Admins/Owners have implicit
// full access, while legacy Manager access_all is intentionally preserved for compatibility.
match new_type {
MembershipType::Owner | MembershipType::Admin => membership.access_all = true,
MembershipType::User => membership.access_all = false,
MembershipType::Manager | MembershipType::Custom => {}
}
membership.atype = new_type as i32;
}
#[post("/users/org_type", format = "application/json", data = "<data>")]
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, conn: DbConn) -> EmptyResult {
let data: MembershipTypeData = data.into_inner();
@ -553,9 +588,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization")
};
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32
} else {
let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) else {
err!("Invalid type")
};
@ -566,7 +599,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
}
}
member_to_edit.atype = new_type;
apply_membership_type_change(&mut member_to_edit, new_type);
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
@ -869,6 +902,14 @@ impl<'r> FromRequest<'r> for AdminToken {
#[cfg(test)]
mod tests {
use super::*;
use crate::db::models::MembershipStatus;
fn membership(member_type: MembershipType) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = MembershipStatus::Confirmed as i32;
membership
}
#[test]
fn validate_web_vault_compare() {
@ -893,4 +934,62 @@ mod tests {
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
}
#[test]
fn admin_type_changes_clear_custom_permissions_and_stale_access() {
let mut custom = membership(MembershipType::Custom);
custom.access_all = true;
custom.manage_users = true;
custom.create_new_collections = true;
custom.edit_any_collection = true;
custom.delete_any_collection = true;
apply_membership_type_change(&mut custom, MembershipType::User);
assert_eq!(custom.atype, MembershipType::User as i32);
assert!(!custom.access_all);
assert!(!custom.manage_users);
assert!(!custom.create_new_collections);
assert!(!custom.edit_any_collection);
assert!(!custom.delete_any_collection);
let mut admin = membership(MembershipType::Admin);
admin.access_all = true;
apply_membership_type_change(&mut admin, MembershipType::Custom);
assert_eq!(admin.atype, MembershipType::Custom as i32);
assert!(!admin.access_all, "entering Custom through this UI must be fail-closed");
assert!(!admin.has_manage_all_collections());
}
#[test]
fn admin_and_legacy_manager_access_all_behavior_is_preserved() {
let mut user = membership(MembershipType::User);
apply_membership_type_change(&mut user, MembershipType::Admin);
assert!(user.access_all);
// REGRESSION (privilege escalation, PR #7397 / finding F2): a Custom member whose
// `access_all` is only the Edit-any-collection mirror must NOT be turned into a legacy
// Manager with the broad "manage all collections" `access_all` grant. That would escalate
// an Edit-only member into Create + Edit + Delete. Mirrors the collection down-migration.
let mut edit_only = membership(MembershipType::Custom);
edit_only.access_all = true;
edit_only.edit_any_collection = true;
apply_membership_type_change(&mut edit_only, MembershipType::Manager);
assert_eq!(edit_only.atype, MembershipType::Manager as i32);
assert!(!edit_only.access_all, "Edit-only Custom must not become an access_all Manager");
assert!(!edit_only.edit_any_collection);
// A Custom member who genuinely held the full manage-all grant (all three collection
// flags) keeps the equivalent legacy Manager `access_all`.
let mut manage_all = membership(MembershipType::Custom);
manage_all.access_all = true;
manage_all.create_new_collections = true;
manage_all.edit_any_collection = true;
manage_all.delete_any_collection = true;
apply_membership_type_change(&mut manage_all, MembershipType::Manager);
assert_eq!(manage_all.atype, MembershipType::Manager as i32);
assert!(manage_all.access_all, "full manage-all Custom keeps legacy Manager access_all");
assert!(!manage_all.create_new_collections);
assert!(!manage_all.edit_any_collection);
assert!(!manage_all.delete_any_collection);
}
}

1148
src/api/core/organizations.rs

File diff suppressed because it is too large

400
src/auth.rs

@ -708,6 +708,7 @@ pub struct OrgHeaders {
pub host: String,
pub device: Device,
pub user: User,
#[allow(dead_code)]
pub membership_type: MembershipType,
pub membership_status: MembershipStatus,
pub membership: Membership,
@ -729,6 +730,31 @@ impl OrgHeaders {
fn is_confirmed_and_owner(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner
}
fn is_confirmed(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed
}
// Custom-role permission checks. Admins and Owners implicitly hold every
// permission; a Custom member holds a permission only if the matching flag
// is set on their Membership. The has_* helpers gate the flags on the
// Custom type, so stale flags on other types can never grant anything.
fn can_manage_users(&self) -> bool {
self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_users())
}
fn can_manage_groups(&self) -> bool {
self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_groups())
}
fn can_manage_policies(&self) -> bool {
self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_policies())
}
// Reading the full member/group *details* (PII, 2FA status, permission flags, access mappings)
// requires the ability to manage users or groups, matching Bitwarden's `ReadAll`/`ReadAllWithAccess`
// authorization. Basic member mini-details and the plain group list remain member-readable.
fn can_manage_users_or_groups(&self) -> bool {
self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin
|| self.membership.has_manage_users()
|| self.membership.has_manage_groups())
}
}
// org_id is usually the second path param ("/organizations/<org_id>"),
@ -842,6 +868,76 @@ impl<'r> FromRequest<'r> for AdminHeaders {
}
}
// Macro to generate a request guard that permits a confirmed Admin/Owner, or a
// confirmed Custom member holding the given permission. The generated struct
// mirrors AdminHeaders so it can be used as a drop-in replacement on endpoints.
macro_rules! generate_manage_headers {
($name:ident, $check:ident, $err:literal) => {
#[allow(dead_code)]
pub struct $name {
pub host: String,
pub device: Device,
pub user: User,
pub membership_type: MembershipType,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for $name {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.$check() {
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
membership_type: headers.membership_type,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!($err)
}
}
}
impl From<$name> for Headers {
fn from(h: $name) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
};
}
generate_manage_headers!(
ManageUsersHeaders,
can_manage_users,
"You need the 'Manage Users' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManageGroupsHeaders,
can_manage_groups,
"You need the 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManagePoliciesHeaders,
can_manage_policies,
"You need the 'Manage Policies' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManageUsersOrGroupsHeaders,
can_manage_users_or_groups,
"You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
);
// 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.
// First check the path, if this is not a valid uuid, try the query values.
@ -861,9 +957,69 @@ fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
None
}
/// The ManagerHeaders are used to check if you are at least a Manager
/// and have access to the specific collection provided via the <col_id>/collections/collectionId.
/// This does strict checking on the collection_id, ManagerHeadersLoose does not.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CollectionManageAccess {
Any,
LegacyManager,
ExplicitManage,
Denied,
}
fn collection_access_by_role(membership: &Membership, custom_has_any_access: bool) -> CollectionManageAccess {
if !membership.has_status(MembershipStatus::Confirmed) {
return CollectionManageAccess::Denied;
}
match MembershipType::from_i32(membership.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any,
// Keep the pre-Custom role's broad behavior isolated to an exact legacy Manager. Its
// existing helper intentionally accepts membership/group access_all.
Some(MembershipType::Manager) => CollectionManageAccess::LegacyManager,
Some(MembershipType::Custom) if custom_has_any_access => CollectionManageAccess::Any,
// A Custom member must prove an actual users_collections.manage or
// collections_groups.manage assignment. In particular, groups.access_all is not Manage.
Some(MembershipType::Custom) => CollectionManageAccess::ExplicitManage,
Some(MembershipType::User) | None => CollectionManageAccess::Denied,
}
}
fn collection_edit_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(membership, membership.has_edit_any_collection())
}
fn collection_read_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(
membership,
membership.has_edit_any_collection() || membership.has_delete_any_collection(),
)
}
fn collection_delete_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(membership, membership.has_delete_any_collection())
}
async fn can_manage_collection(
access: CollectionManageAccess,
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
match access {
CollectionManageAccess::Any => true,
CollectionManageAccess::LegacyManager => {
Collection::is_coll_manageable_by_user(collection_uuid, &membership.user_uuid, conn).await
}
CollectionManageAccess::ExplicitManage => {
membership.has_explicit_collection_manage_access(collection_uuid, conn).await
}
CollectionManageAccess::Denied => false,
}
}
/// ManagerHeaders authorizes collection updates. A Custom member with Edit any collection can
/// update every collection; otherwise the caller must be at least a legacy Manager and have the
/// per-collection Manage permission. Read and delete use separate guards so Edit cannot
/// accidentally imply Delete.
pub struct ManagerHeaders {
pub host: String,
pub device: Device,
@ -880,12 +1036,15 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.is_confirmed_and_manager() {
if let Some(col_id) = get_col_id(request) {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
err_handler!("The current user isn't a manager for this collection")
let access = collection_edit_access(&headers.membership);
if access != CollectionManageAccess::Any {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !can_manage_collection(access, &headers.membership, &col_id, &conn).await {
err_handler!("The current user isn't a manager for this collection")
}
}
} else {
err_handler!("Error getting the collection id")
@ -904,6 +1063,132 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
}
}
/// Read access to collection metadata and assignment details. Delete any collection needs this
/// visibility to render the standard collection view, but it does not grant edit or cipher access.
pub struct CollectionReadHeaders {
pub host: String,
pub device: Device,
pub user: User,
pub membership: Membership,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for CollectionReadHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if !headers.is_confirmed_and_manager() {
err_handler!("You need collection read permission to call this endpoint")
}
let Some(col_id) = get_col_id(request) else {
err_handler!("Error getting the collection id")
};
let access = collection_read_access(&headers.membership);
if access != CollectionManageAccess::Any {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !can_manage_collection(access, &headers.membership, &col_id, &conn).await {
err_handler!("The current user isn't a manager for this collection")
}
}
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid.clone(),
membership: headers.membership,
})
}
}
impl From<CollectionReadHeaders> for Headers {
fn from(h: CollectionReadHeaders) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
/// Delete is intentionally independent from Edit any collection. Vaultwarden advertises
/// limitCollectionDeletion=true, so deleting *any* collection requires the explicit Delete any
/// collection permission (or Admin/Owner). Deleting an individual collection is additionally
/// allowed for members holding the per-collection Manage grant on it. Custom members use the
/// explicit assignment only; unlike the exact legacy Manager path, membership/group access_all
/// never counts as their per-collection Manage grant.
pub struct CollectionDeleteHeaders {
pub host: String,
pub device: Device,
pub user: User,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for CollectionDeleteHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if !headers.is_confirmed_and_manager() {
err_handler!("You need collection delete permission to call this endpoint")
}
let Some(col_id) = get_col_id(request) else {
err_handler!("Error getting the collection id")
};
match collection_delete_access(&headers.membership) {
CollectionManageAccess::Any => {}
CollectionManageAccess::Denied => {
// Custom is a distinct, fail-closed role. Edit any collection and access_all alone
// must not satisfy a Delete request without either Delete any or explicit Manage.
err_handler!("You need the 'Delete any collection' permission to call this endpoint")
}
access @ (CollectionManageAccess::LegacyManager | CollectionManageAccess::ExplicitManage) => {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !can_manage_collection(access, &headers.membership, &col_id, &conn).await {
err_handler!("The current user isn't a manager for this collection")
}
}
}
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
}
}
impl From<CollectionDeleteHeaders> for Headers {
fn from(h: CollectionDeleteHeaders) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
impl From<ManagerHeaders> for Headers {
fn from(h: ManagerHeaders) -> Headers {
Headers {
@ -956,22 +1241,32 @@ impl From<ManagerHeadersLoose> for Headers {
}
}
impl ManagerHeaders {
impl CollectionDeleteHeaders {
pub async fn from_loose(
h: ManagerHeadersLoose,
collections: &Vec<CollectionId>,
conn: &DbConn,
) -> Result<ManagerHeaders, Error> {
) -> Result<CollectionDeleteHeaders, Error> {
let delete_access = collection_delete_access(&h.membership);
if delete_access == CollectionManageAccess::Denied {
err!("You need the 'Delete any collection' permission to call this endpoint")
}
for col_id in collections {
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
err!("Collection Id is malformed!");
}
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await {
if Collection::find_by_uuid_and_org(col_id, &h.membership.org_uuid, conn).await.is_none() {
err!("Collection not found", "Collection does not exist or does not belong to this organization")
}
if delete_access != CollectionManageAccess::Any
&& !can_manage_collection(delete_access, &h.membership, col_id, conn).await
{
err!("Collection not found", "The current user isn't a manager for this collection")
}
}
Ok(ManagerHeaders {
Ok(CollectionDeleteHeaders {
host: h.host,
device: h.device,
user: h.user,
@ -1313,3 +1608,82 @@ pub async fn refresh_tokens(
Ok((device, auth_tokens))
}
#[cfg(test)]
mod tests {
use super::{CollectionManageAccess, collection_delete_access, collection_edit_access, collection_read_access};
use crate::db::models::{Membership, MembershipStatus, MembershipType};
fn membership(member_type: MembershipType) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = MembershipStatus::Confirmed as i32;
membership
}
#[test]
fn flagless_custom_requires_explicit_manage_for_edit_read_and_delete() {
let custom = membership(MembershipType::Custom);
assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&custom), CollectionManageAccess::ExplicitManage);
// Neither a stale membership access_all value nor an external groups.access_all grant may
// switch a Custom member to the legacy broad helper. ExplicitManage invokes the database
// helper that only accepts users_collections.manage / collections_groups.manage.
let mut access_all = membership(MembershipType::Custom);
access_all.access_all = true;
assert_eq!(collection_edit_access(&access_all), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&access_all), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&access_all), CollectionManageAccess::ExplicitManage);
}
#[test]
fn custom_any_permissions_remain_independent() {
let mut edit_any = membership(MembershipType::Custom);
edit_any.edit_any_collection = true;
edit_any.access_all = true;
assert_eq!(collection_edit_access(&edit_any), CollectionManageAccess::Any);
assert_eq!(collection_read_access(&edit_any), CollectionManageAccess::Any);
// Edit-any alone is not blanket Delete. It still permits deletion of an explicitly managed
// collection, which is why the result is ExplicitManage rather than Denied.
assert_eq!(collection_delete_access(&edit_any), CollectionManageAccess::ExplicitManage);
let mut delete_any = membership(MembershipType::Custom);
delete_any.delete_any_collection = true;
assert_eq!(collection_edit_access(&delete_any), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&delete_any), CollectionManageAccess::Any);
assert_eq!(collection_delete_access(&delete_any), CollectionManageAccess::Any);
}
#[test]
fn exact_legacy_manager_keeps_broad_helper() {
let manager = membership(MembershipType::Manager);
assert_eq!(collection_edit_access(&manager), CollectionManageAccess::LegacyManager);
assert_eq!(collection_read_access(&manager), CollectionManageAccess::LegacyManager);
assert_eq!(collection_delete_access(&manager), CollectionManageAccess::LegacyManager);
let admin = membership(MembershipType::Admin);
assert_eq!(collection_edit_access(&admin), CollectionManageAccess::Any);
assert_eq!(collection_delete_access(&admin), CollectionManageAccess::Any);
let user = membership(MembershipType::User);
assert_eq!(collection_edit_access(&user), CollectionManageAccess::Denied);
assert_eq!(collection_delete_access(&user), CollectionManageAccess::Denied);
}
#[test]
fn migrated_legacy_manager_retains_explicit_collection_manage() {
// The role migration converts legacy Managers to flagless Custom members. They retain
// edit/delete only for collections with a persisted per-collection Manage assignment;
// the restrictive helper deliberately excludes group and membership access_all.
let migrated_manager = membership(MembershipType::Custom);
assert_eq!(collection_edit_access(&migrated_manager), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&migrated_manager), CollectionManageAccess::ExplicitManage);
let mut unconfirmed = membership(MembershipType::Custom);
unconfirmed.status = MembershipStatus::Accepted as i32;
assert_eq!(collection_edit_access(&unconfirmed), CollectionManageAccess::Denied);
assert_eq!(collection_delete_access(&unconfirmed), CollectionManageAccess::Denied);
}
}

59
src/db/models/cipher.rs

@ -592,6 +592,24 @@ impl Cipher {
cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn,
) -> Option<(bool, bool, bool)> {
// Security: central fail-closed check binding cipher -> organization -> confirmed membership.
//
// In the direct (non-sync) authorization path an organization cipher is only accessible to a
// user who has a *confirmed* membership in that same organization. This denies access to
// members whose collection/group assignment rows still exist after they were revoked (or are
// still only invited/accepted), and to cross-organization collection/group assignments that
// another code path might have persisted. Without it, the queries below would keep granting
// access from those stale or cross-tenant rows (security audit findings H-1, H-2, H-3).
//
// The sync path (cipher_sync_data is Some) is intentionally left to the caller: it is built
// only from confirmed memberships and evaluated below against that cached data.
if cipher_sync_data.is_none()
&& let Some(ref org_uuid) = self.organization_uuid
&& Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await.is_none()
{
return None;
}
// Check whether this cipher is directly owned by the user, or is in
// a collection that the user has full access to. If so, there are no
// access restrictions.
@ -657,16 +675,34 @@ impl Cipher {
}
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
let cipher_uuid = self.uuid.clone();
let user_uuid = user_uuid.clone();
conn.run(move |conn| {
// Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection.
//
// Security: bind the assignment to a *confirmed* membership in the same organization as
// both the cipher and the collection. Without this, a `users_collections` row left behind
// after a revoke, or an assignment pointing at a collection in a different organization,
// would keep granting access (defense in depth for audit findings H-1 and H-3).
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(
collections::table.on(collections::uuid
.eq(ciphers_collections::collection_uuid)
.and(collections::org_uuid.nullable().eq(ciphers::organization_uuid))),
)
.inner_join(
users_collections::table.on(ciphers_collections::collection_uuid
.eq(users_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid))),
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.inner_join(
users_organizations::table.on(users_organizations::user_uuid
.eq(user_uuid)
.and(users_organizations::org_uuid.eq(collections::org_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn)
@ -679,9 +715,16 @@ impl Cipher {
if !CONFIG.org_groups_enabled() {
return Vec::new();
}
let cipher_uuid = self.uuid.clone();
let user_uuid = user_uuid.clone();
conn.run(move |conn| {
// Security: bind the group assignment to a *confirmed* membership and require that the
// cipher, the collection, the group and the membership all belong to the same
// organization. The `collections` join in particular prevents a cross-organization
// collection<->group assignment from granting access to a foreign organization's ciphers
// (defense in depth for audit findings H-1, H-2 and H-3).
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(
collections_groups::table
@ -689,13 +732,21 @@ impl Cipher {
)
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(collections_groups::groups_uuid)))
.inner_join(
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)),
users_organizations::table.on(users_organizations::uuid
.eq(groups_users::users_organizations_uuid)
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.inner_join(
groups::table.on(groups::uuid
.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.inner_join(
collections::table.on(collections::uuid
.eq(ciphers_collections::collection_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
.and(collections::org_uuid.nullable().eq(ciphers::organization_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn)

11
src/db/models/collection.rs

@ -102,8 +102,9 @@ impl Collection {
// Owners and Admins always have true. Users are not able to have full access
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) => {
// Only let a manager manage collections when the have full read/write access
let is_manager = m.atype == MembershipType::Manager;
// Only let a manager-level member (Manager or Custom) manage collections
// when they have full read/write access
let is_manager = m.atype >= MembershipType::Manager;
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
(
cu.read_only,
@ -125,11 +126,11 @@ impl Collection {
} else {
match Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await {
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => {
Some(m) if m.atype >= MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => {
(false, false, true)
}
Some(m) => {
let is_manager = m.atype == MembershipType::Manager;
let is_manager = m.atype >= MembershipType::Manager;
let read_only = !self.is_writable_by_user(user_uuid, conn).await;
let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await;
(read_only, hide_passwords, is_manager && !read_only && !hide_passwords)
@ -945,7 +946,7 @@ impl CollectionMembership {
"hidePasswords": self.hide_passwords,
"manage": membership_type >= MembershipType::Admin
|| self.manage
|| (membership_type == MembershipType::Manager
|| (membership_type >= MembershipType::Manager
&& !self.read_only
&& !self.hide_passwords),
})

35
src/db/models/group.rs

@ -13,7 +13,7 @@ use crate::{
};
use macros::UuidFromParam;
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
use super::{Collection, CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = groups)]
@ -257,6 +257,7 @@ impl Group {
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::access_all.eq(true))
.select(groups::organizations_uuid)
.distinct()
@ -268,12 +269,19 @@ impl Group {
pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool {
conn.run(move |conn| {
// Security: the membership linked through `groups_users` must itself belong to the same
// organization as the group and must be confirmed. Otherwise a cross-organization
// `groups_users` row (a member of org A linked to an access-all group of org B) would let
// that member pass as having full access to org B (audit finding H-2).
groups::table
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
.inner_join(
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)),
users_organizations::table.on(users_organizations::uuid
.eq(groups_users::users_organizations_uuid)
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::organizations_uuid.eq(org_uuid))
.filter(groups::access_all.eq(true))
.select(groups::access_all)
@ -319,6 +327,17 @@ impl Group {
impl CollectionGroup {
pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
// Security (audit H-3): never persist a cross-organization link between a collection and a
// group. Both must belong to the organization this assignment is scoped to; otherwise a
// caller could attach a foreign-tenant group to this organization's collection and thereby
// grant that group's members access to it. This is a defense-in-depth guard so no route can
// create such a link even if it fails to validate its inputs.
if Collection::find_by_uuid_and_org(&self.collections_uuid, org_uuid, conn).await.is_none()
|| Group::find_by_uuid_and_org(&self.groups_uuid, org_uuid, conn).await.is_none()
{
err!("Collection and group must belong to the same organization")
}
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
@ -493,6 +512,18 @@ impl CollectionGroup {
impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
// Security (audit H-2): never persist a cross-organization link between a group and a
// membership. The group must belong to the same organization as the membership; otherwise a
// caller could grant a member of one organization full access to another organization's
// collections through an access-all group. This is a defense-in-depth guard so no route can
// create such a link even if it fails to validate its inputs.
let Some(member) = Membership::find_by_uuid(&self.users_organizations_uuid, conn).await else {
err!("Member not found while assigning to group")
};
if Group::find_by_uuid_and_org(&self.groups_uuid, &member.org_uuid, conn).await.is_none() {
err!("Group and member must belong to the same organization")
}
self.update_user_revision(conn).await;
db_run! { conn:

382
src/db/models/organization.rs

@ -15,8 +15,8 @@ use crate::{
db::{
DbConn,
schema::{
ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key,
organizations, users, users_collections, users_organizations,
ciphers, ciphers_collections, collections, collections_groups, groups, groups_users, org_policies,
organization_api_key, organizations, users, users_collections, users_organizations,
},
},
error::MapResult,
@ -44,6 +44,7 @@ pub struct Organization {
#[diesel(table_name = users_organizations)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
#[allow(clippy::struct_excessive_bools)]
pub struct Membership {
pub uuid: MembershipId,
pub user_uuid: UserId,
@ -57,6 +58,12 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
pub manage_users: bool,
pub manage_groups: bool,
pub manage_policies: bool,
pub create_new_collections: bool,
pub edit_any_collection: bool,
pub delete_any_collection: bool,
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -98,36 +105,39 @@ pub enum MembershipType {
Admin = 1,
User = 2,
Manager = 3,
Custom = 4,
}
impl MembershipType {
pub fn from_str(s: &str) -> Option<Self> {
#[expect(
clippy::match_same_arms,
reason = "Specifically define `4|Custom` since this is a hack, not a default"
)]
match s {
"0" | "Owner" => Some(MembershipType::Owner),
"1" | "Admin" => Some(MembershipType::Admin),
"2" | "User" => Some(MembershipType::User),
"3" | "Manager" => Some(MembershipType::Manager),
// HACK: We convert the custom role to a manager role
"4" | "Custom" => Some(MembershipType::Manager),
"4" | "Custom" => Some(MembershipType::Custom),
_ => None,
}
}
const fn access_rank(self) -> u8 {
match self {
Self::User => 0,
Self::Manager | Self::Custom => 1,
Self::Admin => 2,
Self::Owner => 3,
}
}
}
impl Ord for MembershipType {
fn cmp(&self, other: &MembershipType) -> Ordering {
// For easy comparison, map each variant to an access level (where 0 is lowest).
const ACCESS_LEVEL: [i32; 4] = [
3, // Owner
2, // Admin
0, // User
1, // Manager && Custom
];
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
// Manager and Custom intentionally share the same authorization rank. A total ordering
// still has to distinguish unequal enum variants, otherwise `Ord` would disagree with
// `Eq` and ordered maps/sets could collapse one role into the other. The discriminant is a
// stable tie-breaker and places Custom after Manager, preserving `Custom >= Manager` while
// keeping both roles below Admin.
self.access_rank().cmp(&other.access_rank()).then_with(|| (*self as i32).cmp(&(*other as i32)))
}
}
@ -267,6 +277,12 @@ impl Membership {
atype: MembershipType::User as i32,
reset_password_key: None,
external_id: None,
manage_users: false,
manage_groups: false,
manage_policies: false,
create_new_collections: false,
edit_any_collection: false,
delete_any_collection: false,
}
}
@ -306,15 +322,6 @@ impl Membership {
}
false
}
/// HACK: Convert the manager type to a custom type
/// It will be converted back on other locations
pub fn type_manager_as_custom(&self) -> i32 {
match self.atype {
3 => 4,
_ => self.atype,
}
}
}
impl OrganizationApiKey {
@ -443,29 +450,29 @@ impl Membership {
pub async fn to_json(&self, conn: &DbConn) -> Value {
let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap();
// HACK: Convert the manager type to a custom type
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
let membership_type = self.atype;
let permissions = json!({
// TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": membership_type == 4 && self.access_all,
"editAnyCollection": membership_type == 4 && self.access_all,
"deleteAnyCollection": membership_type == 4 && self.access_all,
"manageGroups": false,
"managePolicies": false,
"createNewCollections": membership_type == MembershipType::Custom as i32 && self.create_new_collections,
"editAnyCollection": membership_type == MembershipType::Custom as i32 && self.edit_any_collection,
"deleteAnyCollection": membership_type == MembershipType::Custom as i32 && self.delete_any_collection,
"manageGroups": membership_type == MembershipType::Custom as i32 && self.manage_groups,
"managePolicies": membership_type == MembershipType::Custom as i32 && self.manage_policies,
"manageSso": false, // Not supported
"manageUsers": false,
"manageUsers": membership_type == MembershipType::Custom as i32 && self.manage_users,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
});
// edit_any_collection is internally mirrored to access_all to provide Bitwarden-compatible
// cipher access, but it must not accidentally grant collection creation. The client treats
// limitCollectionCreation=false as an independent create grant, so compute it from the
// actual role/permission rather than access_all for Custom members.
let limit_collection_creation = self.limit_collection_creation();
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({
"id": self.org_uuid,
@ -509,8 +516,7 @@ impl Membership {
"familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null,
"accessSecretsManager": false,
// limit collection creation to managers with access_all permission to prevent issues
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all,
"limitCollectionCreation": limit_collection_creation,
"limitCollectionDeletion": true,
"limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true,
@ -585,7 +591,7 @@ impl Membership {
(
cu.read_only,
cu.hide_passwords,
cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords),
cu.manage || (self.atype >= MembershipType::Manager && !cu.read_only && !cu.hide_passwords),
)
// If previous checks failed it might be that this user has access via a group, but we should not return those elements here
// Those are returned via a special group endpoint
@ -607,28 +613,22 @@ impl Membership {
Vec::new()
};
// HACK: Convert the manager type to a custom type
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
let membership_type = self.atype;
// HACK: Only return permissions if the user is of type custom and has access_all
// Else Bitwarden will assume the defaults of all false
let permissions = if membership_type == 4 && self.access_all {
// Only return a permissions object for custom-type members. Otherwise Bitwarden assumes
// all-false defaults and the role itself supplies any elevated capabilities.
let permissions = if membership_type == MembershipType::Custom as i32 {
json!({
// TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": true,
"editAnyCollection": true,
"deleteAnyCollection": true,
"manageGroups": false,
"managePolicies": false,
"createNewCollections": self.create_new_collections,
"editAnyCollection": self.edit_any_collection,
"deleteAnyCollection": self.delete_any_collection,
"manageGroups": self.manage_groups,
"managePolicies": self.manage_policies,
"manageSso": false, // Not supported
"manageUsers": false,
"manageUsers": self.manage_users,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
@ -728,7 +728,7 @@ impl Membership {
json!({
"id": self.uuid,
"userId": self.user_uuid,
"type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type
"type": self.atype,
"status": status,
"name": user.name,
"email": user.email,
@ -816,7 +816,143 @@ impl Membership {
}
pub fn has_full_access(&self) -> bool {
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
(self.access_all || self.has_edit_any_collection() || self.atype >= MembershipType::Admin)
&& self.has_status(MembershipStatus::Confirmed)
}
// The granular custom permission flags are only meaningful while the membership is of
// the Custom type. Gating them on the type here ensures that a stale flag left over from
// a type change (e.g. via the admin panel) can never grant anything.
pub fn has_manage_users(&self) -> bool {
self.has_type(MembershipType::Custom) && self.manage_users
}
pub fn has_manage_groups(&self) -> bool {
self.has_type(MembershipType::Custom) && self.manage_groups
}
pub fn has_manage_policies(&self) -> bool {
self.has_type(MembershipType::Custom) && self.manage_policies
}
pub fn has_create_new_collections(&self) -> bool {
self.has_type(MembershipType::Custom) && self.create_new_collections
}
pub fn has_edit_any_collection(&self) -> bool {
self.has_type(MembershipType::Custom) && self.edit_any_collection
}
pub fn has_delete_any_collection(&self) -> bool {
self.has_type(MembershipType::Custom) && self.delete_any_collection
}
/// Check for an explicit per-collection Manage grant without treating any `access_all` value
/// as such a grant. Custom-role collection guards use this instead of the legacy broad helper,
/// because membership/group `access_all` must not manufacture a per-collection Manage grant.
pub async fn has_explicit_collection_manage_access(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool {
let membership_uuid = self.uuid.clone();
let user_uuid = self.user_uuid.clone();
let org_uuid = self.org_uuid.clone();
let collection_uuid = collection_uuid.clone();
conn.run(move |conn| {
let has_direct_manage = users_organizations::table
.inner_join(
users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)),
)
.inner_join(
collections::table.on(collections::uuid
.eq(users_collections::collection_uuid)
.and(collections::org_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::uuid.eq(membership_uuid.clone()))
.filter(users_organizations::user_uuid.eq(user_uuid.clone()))
.filter(users_organizations::org_uuid.eq(org_uuid.clone()))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(collections::uuid.eq(collection_uuid.clone()))
.filter(users_collections::manage.eq(true))
.count()
.first::<i64>(conn)
.unwrap_or(0)
!= 0;
if has_direct_manage {
return true;
}
users_organizations::table
.inner_join(
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
)
.inner_join(
groups::table.on(groups::uuid
.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.inner_join(collections_groups::table.on(collections_groups::groups_uuid.eq(groups_users::groups_uuid)))
.inner_join(
collections::table.on(collections::uuid
.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::uuid.eq(membership_uuid))
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(collections::uuid.eq(collection_uuid))
.filter(collections_groups::manage.eq(true))
.count()
.first::<i64>(conn)
.unwrap_or(0)
!= 0
})
.await
}
/// `manageAllCollections` is a client-side aggregate checkbox, not a separately persisted
/// Bitwarden permission. It is selected exactly when all three child permissions are selected.
pub fn has_manage_all_collections(&self) -> bool {
self.has_create_new_collections() && self.has_edit_any_collection() && self.has_delete_any_collection()
}
/// Match Vaultwarden's existing collection-creation policy while keeping the new Custom
/// permission independent from edit/delete. Legacy Manager memberships retain their former
/// access_all-based behavior.
pub fn can_create_new_collections(&self) -> bool {
if !self.has_status(MembershipStatus::Confirmed) {
return false;
}
match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => true,
Some(MembershipType::Manager) => self.access_all,
Some(MembershipType::Custom) => self.create_new_collections,
Some(MembershipType::User) | None => false,
}
}
pub fn limit_collection_creation(&self) -> bool {
match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => false,
Some(MembershipType::Manager) => !self.access_all,
Some(MembershipType::Custom) => !self.create_new_collections,
Some(MembershipType::User) | None => true,
}
}
pub fn can_delete_any_collection(&self) -> bool {
self.has_status(MembershipStatus::Confirmed)
&& (self.atype >= MembershipType::Admin || self.has_delete_any_collection())
}
pub fn clear_custom_permissions(&mut self) {
self.manage_users = false;
self.manage_groups = false;
self.manage_policies = false;
self.create_new_collections = false;
self.edit_any_collection = false;
self.delete_any_collection = false;
}
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
@ -925,7 +1061,7 @@ impl Membership {
.await
}
// Get all users which are either owner or admin, or a manager which can manage/access all
// Get all users which are either owner or admin, or a manager/custom member which can manage/access all
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
@ -935,7 +1071,7 @@ impl Membership {
users_organizations::atype
.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32])
.or(users_organizations::atype
.eq(MembershipType::Manager as i32)
.eq_any(vec![MembershipType::Manager as i32, MembershipType::Custom as i32])
.and(users_organizations::access_all.eq(true))),
)
.load::<Self>(conn)
@ -1264,12 +1400,128 @@ pub struct OrgApiKeyId(String);
mod tests {
use super::*;
fn membership(member_type: MembershipType) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = MembershipStatus::Confirmed as i32;
membership
}
#[test]
#[allow(non_snake_case)]
fn partial_cmp_MembershipType() {
fn membership_type_order_preserves_access_rank_and_ord_contract() {
assert!(MembershipType::Owner > MembershipType::Admin);
assert!(MembershipType::Admin > MembershipType::Manager);
assert!(MembershipType::Admin > MembershipType::Custom);
assert!(MembershipType::Custom > MembershipType::Manager);
assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
assert!(MembershipType::Custom == MembershipType::from_str("4").unwrap());
// Permission comparisons continue to treat Custom as manager-level and below Admin.
assert!(MembershipType::Custom >= MembershipType::Manager);
let custom = MembershipType::Custom as i32;
assert!(custom >= MembershipType::Manager);
assert!(custom < MembershipType::Admin);
let types = [
MembershipType::Owner,
MembershipType::Admin,
MembershipType::User,
MembershipType::Manager,
MembershipType::Custom,
];
for lhs in types {
for rhs in types {
assert_eq!(lhs.cmp(&rhs) == Ordering::Equal, lhs == rhs);
assert_eq!(lhs.cmp(&rhs), rhs.cmp(&lhs).reverse());
}
}
}
#[test]
fn custom_collection_permissions_are_independent_and_type_gated() {
let mut member = membership(MembershipType::Custom);
member.create_new_collections = true;
assert!(member.has_create_new_collections());
assert!(member.can_create_new_collections());
assert!(!member.limit_collection_creation());
assert!(!member.has_full_access());
assert!(!member.can_delete_any_collection());
assert!(!member.has_manage_all_collections());
member.delete_any_collection = true;
assert!(member.has_delete_any_collection());
assert!(member.can_delete_any_collection());
assert!(!member.has_full_access());
assert!(!member.has_manage_all_collections());
member.edit_any_collection = true;
assert!(member.has_edit_any_collection());
assert!(member.has_full_access());
assert!(member.has_manage_all_collections());
// Stale flags on a non-Custom role are inert.
member.atype = MembershipType::User as i32;
assert!(!member.has_create_new_collections());
assert!(!member.has_edit_any_collection());
assert!(!member.has_delete_any_collection());
assert!(!member.can_create_new_collections());
assert!(!member.can_delete_any_collection());
assert!(!member.has_full_access());
}
#[test]
fn edit_any_collection_does_not_imply_create_or_delete() {
let mut custom = membership(MembershipType::Custom);
custom.edit_any_collection = true;
// The persisted access_all mirror is intentionally tested too: client-facing create and
// delete decisions must still use their dedicated permissions.
custom.access_all = true;
assert!(custom.has_full_access());
assert!(!custom.can_create_new_collections());
assert!(custom.limit_collection_creation());
assert!(!custom.can_delete_any_collection());
let mut manager = membership(MembershipType::Manager);
manager.access_all = true;
assert!(manager.can_create_new_collections());
let admin = membership(MembershipType::Admin);
assert!(admin.can_create_new_collections());
assert!(!admin.limit_collection_creation());
assert!(admin.can_delete_any_collection());
}
#[test]
fn custom_collection_permissions_require_confirmed_membership() {
let mut member = membership(MembershipType::Custom);
member.create_new_collections = true;
member.edit_any_collection = true;
member.delete_any_collection = true;
member.status = MembershipStatus::Accepted as i32;
assert!(!member.can_create_new_collections());
assert!(!member.can_delete_any_collection());
assert!(!member.has_full_access());
}
#[test]
fn clearing_custom_permissions_clears_every_flag() {
let mut member = membership(MembershipType::Custom);
member.manage_users = true;
member.manage_groups = true;
member.manage_policies = true;
member.create_new_collections = true;
member.edit_any_collection = true;
member.delete_any_collection = true;
member.clear_custom_permissions();
assert!(!member.manage_users);
assert!(!member.manage_groups);
assert!(!member.manage_policies);
assert!(!member.create_new_collections);
assert!(!member.edit_any_collection);
assert!(!member.delete_any_collection);
}
}

6
src/db/schema.rs

@ -242,6 +242,12 @@ table! {
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
manage_users -> Bool,
manage_groups -> Bool,
manage_policies -> Bool,
create_new_collections -> Bool,
edit_any_collection -> Bool,
delete_any_collection -> Bool,
}
}

6
src/static/scripts/admin_users.js

@ -174,10 +174,14 @@ const ORG_TYPES = {
"name": "User",
"bg": "blue"
},
"4": {
"3": {
"name": "Manager",
"bg": "green"
},
"4": {
"name": "Custom",
"bg": "teal"
},
};
// Special sort function to sort dates in ISO format

3
src/static/templates/admin/users.hbs

@ -135,6 +135,9 @@
<div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label>
</div>
<div class="radio">
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom">&nbsp;Custom</label>
</div>
<div class="radio">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>
</div>

7
src/static/templates/scss/vaultwarden.scss.hbs

@ -116,8 +116,11 @@ app-security > app-two-factor-setup > form {
}
/* Hide unsupported Custom Role options */
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
/* The collection permission group plus manageUsers, manageGroups, managePolicies are supported
by Vaultwarden and are intentionally not hidden here. */
: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="manageResetPassword"]) {
@extend %vw-hide;
}

Loading…
Cancel
Save