mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-15 19:25:56 +02:00
Add Custom Roles account recovery permission (#7965)
This commit is contained in:
11 files changed
+167
-37
No files matched your search
@@ -0,0 +1,13 @@
|
||||
-- Dropping the permission would silently take it away from the Custom members holding it. So while there are any,
|
||||
-- this fails on the duplicate key below, before anything is dropped: take it away first, or restore the backup
|
||||
-- taken before the upgrade.
|
||||
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
|
||||
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Manage account recovery: take it away first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Manage account recovery: take it away first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE manage_reset_password LIMIT 1;
|
||||
DROP TEMPORARY TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_reset_password;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- The Manage account recovery permission of the Custom member role. Like the other permissions it only takes
|
||||
-- effect on a Custom membership (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and nobody starts with it.
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_reset_password BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-- Dropping the permission would silently take it away from the Custom members holding it. So while there are any,
|
||||
-- this fails on the duplicate key below: take it away first, or restore the backup taken before the upgrade.
|
||||
-- It needs to run in one transaction, as Diesel does.
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Manage account recovery: take it away first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Manage account recovery: take it away first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE manage_reset_password LIMIT 1;
|
||||
DROP TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_reset_password;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- The Manage account recovery permission of the Custom member role. Like the other permissions it only takes
|
||||
-- effect on a Custom membership (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and nobody starts with it.
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_reset_password BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Dropping the permission would silently take it away from the Custom members holding it. So while there are any,
|
||||
-- this fails on the duplicate key below: take it away first, or restore the backup taken before the upgrade.
|
||||
-- It needs to run in one transaction, as Diesel does.
|
||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
||||
INSERT INTO custom_role_downgrade VALUES ('Manage account recovery: take it away first, or restore the backup taken before the upgrade');
|
||||
INSERT INTO custom_role_downgrade
|
||||
SELECT 'Manage account recovery: take it away first, or restore the backup taken before the upgrade'
|
||||
FROM users_organizations WHERE manage_reset_password LIMIT 1;
|
||||
DROP TABLE custom_role_downgrade;
|
||||
|
||||
ALTER TABLE users_organizations DROP COLUMN manage_reset_password;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- The Manage account recovery permission of the Custom member role. Like the other permissions it only takes
|
||||
-- effect on a Custom membership (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and nobody starts with it.
|
||||
ALTER TABLE users_organizations ADD COLUMN manage_reset_password BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
@@ -13,8 +13,9 @@ use crate::{
|
||||
},
|
||||
auth::{
|
||||
AccessImportExportHeaders, AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders,
|
||||
CollectionUsersReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders,
|
||||
ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite,
|
||||
CollectionUsersReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageResetPasswordHeaders,
|
||||
ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may,
|
||||
decode_invite,
|
||||
},
|
||||
db::{
|
||||
DbConn,
|
||||
@@ -1007,13 +1008,18 @@ async fn get_members(
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
|
||||
if !headers.membership.has_full_access() && !headers.membership.has_custom_permission(CustomPermission::ManageUsers)
|
||||
{
|
||||
// As in Bitwarden, Manage account recovery alone only lists the members whose account it can recover
|
||||
let recovery_only = !headers.membership.has_full_access()
|
||||
&& !headers.membership.has_custom_permission(CustomPermission::ManageUsers);
|
||||
if recovery_only && !headers.membership.has_custom_permission(CustomPermission::ManageResetPassword) {
|
||||
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
||||
}
|
||||
|
||||
let mut users_json = Vec::new();
|
||||
for u in Membership::find_by_org(&org_id, &conn).await {
|
||||
if recovery_only && (!u.has_status(MembershipStatus::Confirmed) || u.reset_password_key.is_none()) {
|
||||
continue;
|
||||
}
|
||||
// The user can be a manager instead of an admin, but we've checked above that they have full access
|
||||
users_json.push(
|
||||
u.to_json_details_for_admin(
|
||||
@@ -1036,7 +1042,7 @@ async fn get_members(
|
||||
async fn post_org_keys(
|
||||
org_id: OrganizationId,
|
||||
data: Json<OrgKeyData>,
|
||||
headers: AdminHeaders,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
@@ -2883,9 +2889,10 @@ async fn get_groups_data(
|
||||
|| headers.membership.has_custom_permission(CustomPermission::ManageUsers)
|
||||
|| headers.membership.has_custom_permission(CustomPermission::ManageGroups)
|
||||
} else {
|
||||
// Reports need the basic group list, not group details or management rights.
|
||||
// Reports and account recovery need the basic group list, not group details or management rights.
|
||||
has_full_access
|
||||
|| headers.membership.has_custom_permission(CustomPermission::AccessReports)
|
||||
|| headers.membership.has_custom_permission(CustomPermission::ManageResetPassword)
|
||||
|| may_assign_collection_access(&headers.membership, &conn).await
|
||||
};
|
||||
if !allowed {
|
||||
@@ -3322,7 +3329,7 @@ async fn get_organization_keys(org_id: OrganizationId, headers: Headers, conn: D
|
||||
async fn put_recover_account(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
headers: AdminHeaders,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
data: Json<OrganizationUserRecoverAccountRequest>,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
@@ -3333,7 +3340,7 @@ async fn put_recover_account(
|
||||
async fn recover_account(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
headers: AdminHeaders,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
req: OrganizationUserRecoverAccountRequest,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
@@ -3353,7 +3360,7 @@ async fn recover_account(
|
||||
err!("User not found")
|
||||
};
|
||||
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?;
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers.membership, &conn).await?;
|
||||
|
||||
if member.reset_password_key.is_none() {
|
||||
err!("Password reset not or not correctly enrolled");
|
||||
@@ -3423,12 +3430,14 @@ async fn recover_account(
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
if req.reset_master_password {
|
||||
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
|
||||
for (reset, event_type) in [
|
||||
(req.reset_master_password, EventType::OrganizationUserAdminResetPassword),
|
||||
(req.reset_two_factor, EventType::OrganizationUserAdminResetTwoFactor),
|
||||
] {
|
||||
if reset {
|
||||
log_event(event_type, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||
.await;
|
||||
}
|
||||
|
||||
if req.reset_two_factor {
|
||||
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -3599,7 +3608,7 @@ async fn get_organization_private_key(org_id: OrganizationId, headers: ManageUse
|
||||
async fn get_reset_password_details(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
headers: AdminHeaders,
|
||||
headers: ManageResetPasswordHeaders,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
@@ -3617,7 +3626,7 @@ async fn get_reset_password_details(
|
||||
err!("User not found")
|
||||
};
|
||||
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?;
|
||||
check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers.membership, &conn).await?;
|
||||
|
||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs#L190
|
||||
Ok(Json(json!({
|
||||
@@ -3636,7 +3645,7 @@ async fn get_reset_password_details(
|
||||
async fn check_reset_password_applicable_and_permissions(
|
||||
org_id: &OrganizationId,
|
||||
member_id: &MembershipId,
|
||||
headers: &AdminHeaders,
|
||||
caller: &Membership,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
check_reset_password_applicable(org_id, conn).await?;
|
||||
@@ -3645,11 +3654,25 @@ async fn check_reset_password_applicable_and_permissions(
|
||||
err!("Reset target user not found")
|
||||
};
|
||||
|
||||
// Resetting user must be higher/equal to user to reset
|
||||
match headers.membership_type {
|
||||
MembershipType::Owner => Ok(()),
|
||||
MembershipType::Admin if target_user.atype <= MembershipType::Admin => Ok(()),
|
||||
_ => err!("No permission to reset this user's password"),
|
||||
if may_recover_account(caller, &target_user) {
|
||||
Ok(())
|
||||
} else {
|
||||
err!("No permission to reset this user's password")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `caller` may recover the account of `target`, who must not have more authority, as in Bitwarden: Owners
|
||||
/// may recover every member, Admins every member but Owners, and Custom members with Manage account recovery Users
|
||||
/// and the Custom members whose permissions they all have.
|
||||
fn may_recover_account(caller: &Membership, target: &Membership) -> bool {
|
||||
if caller.has_type(MembershipType::Owner) {
|
||||
true
|
||||
} else if caller.has_type(MembershipType::Admin) {
|
||||
target.atype <= MembershipType::Admin
|
||||
} else {
|
||||
caller.has_custom_permission(CustomPermission::ManageResetPassword)
|
||||
&& custom_member_manages(target.atype)
|
||||
&& caller.has_custom_permissions_of(target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4035,4 +4058,54 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(sqlite)]
|
||||
#[test]
|
||||
fn account_recovery_authorization() {
|
||||
test_db::run(async |db| {
|
||||
use CustomPermission::{ManageResetPassword, ManageUsers};
|
||||
use MembershipType::*;
|
||||
let conn = db.conn();
|
||||
let org = test_db::organization(&conn).await;
|
||||
let mut members = Vec::new();
|
||||
for (role, permissions) in [
|
||||
(Owner, vec![]),
|
||||
(Admin, vec![]),
|
||||
(Manager, vec![]),
|
||||
(User, vec![]),
|
||||
(Custom, vec![ManageResetPassword]),
|
||||
(Custom, vec![ManageResetPassword, ManageUsers]),
|
||||
] {
|
||||
members.push(test_db::member(&conn, &org.uuid, role, &permissions).await.1);
|
||||
}
|
||||
// Who may recover whom: a row per caller, a column per target, both in the order above
|
||||
for (caller, row) in members.iter().zip(["111111", "011111", "000000", "000000", "000110", "000111"]) {
|
||||
for (target, allowed) in members.iter().zip(row.chars()) {
|
||||
assert_eq!(
|
||||
may_recover_account(caller, target),
|
||||
allowed == '1',
|
||||
"{} {}",
|
||||
caller.atype,
|
||||
target.atype
|
||||
);
|
||||
}
|
||||
}
|
||||
// Manage account recovery alone lists only the confirmed members enrolled in account recovery
|
||||
for (i, status) in [(2, MembershipStatus::Accepted), (3, MembershipStatus::Confirmed)] {
|
||||
members[i].status = status as i32;
|
||||
members[i].reset_password_key = Some("key".into());
|
||||
members[i].save(&conn).await.unwrap();
|
||||
}
|
||||
let data = GetOrgUserData {
|
||||
include_collections: None,
|
||||
include_groups: None,
|
||||
};
|
||||
let users = get_members(data, org.uuid.clone(), directory_headers(&members[4], &conn).await, db.conn());
|
||||
assert_eq!(
|
||||
users.await.unwrap().0["data"],
|
||||
json!([members[3].to_json_details_for_admin(false, false, &conn).await])
|
||||
);
|
||||
assert!(get_groups(org.uuid.clone(), directory_headers(&members[4], &conn).await, db.conn()).await.is_ok());
|
||||
});
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -27,14 +27,14 @@ use rocket::{
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{ApiResult, core::log_event},
|
||||
api::ApiResult,
|
||||
config::PathType,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
AttachmentId, CipherId, Collection, CollectionId, CustomPermission, Device, DeviceId, DeviceType,
|
||||
EmergencyAccessId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId,
|
||||
OrganizationId, SendFileId, SendId, User, UserId, custom_roles_enabled,
|
||||
EmergencyAccessId, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||
SendFileId, SendId, User, UserId, custom_roles_enabled,
|
||||
},
|
||||
},
|
||||
error::Error,
|
||||
@@ -854,12 +854,6 @@ pub struct AdminHeaders {
|
||||
pub org_id: OrganizationId,
|
||||
}
|
||||
|
||||
impl AdminHeaders {
|
||||
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
|
||||
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for AdminHeaders {
|
||||
type Error = &'static str;
|
||||
@@ -947,6 +941,11 @@ permission_headers!(
|
||||
AccessImportExportHeaders,
|
||||
CustomPermission::AccessImportExport
|
||||
);
|
||||
permission_headers!(
|
||||
/// For the routes that recover the accounts of the members of an organization.
|
||||
ManageResetPasswordHeaders,
|
||||
CustomPermission::ManageResetPassword
|
||||
);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -81,6 +81,7 @@ pub struct Membership {
|
||||
access_event_logs: bool,
|
||||
access_import_export: bool,
|
||||
access_reports: bool,
|
||||
manage_reset_password: bool,
|
||||
|
||||
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
|
||||
pub v2_upgrade_token: Option<String>,
|
||||
@@ -254,6 +255,7 @@ pub enum CustomPermission {
|
||||
AccessEventLogs,
|
||||
AccessImportExport,
|
||||
AccessReports,
|
||||
ManageResetPassword,
|
||||
}
|
||||
|
||||
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
|
||||
@@ -269,10 +271,11 @@ const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
|
||||
CustomPermission::AccessEventLogs,
|
||||
CustomPermission::AccessImportExport,
|
||||
CustomPermission::AccessReports,
|
||||
CustomPermission::ManageResetPassword,
|
||||
];
|
||||
|
||||
impl CustomPermission {
|
||||
pub const ALL: [CustomPermission; 9] = [
|
||||
pub const ALL: [CustomPermission; 10] = [
|
||||
CustomPermission::ManageUsers,
|
||||
CustomPermission::ManageGroups,
|
||||
CustomPermission::ManagePolicies,
|
||||
@@ -282,6 +285,7 @@ impl CustomPermission {
|
||||
CustomPermission::AccessEventLogs,
|
||||
CustomPermission::AccessImportExport,
|
||||
CustomPermission::AccessReports,
|
||||
CustomPermission::ManageResetPassword,
|
||||
];
|
||||
|
||||
/// The key of the permission in the `permissions` object of the Bitwarden clients.
|
||||
@@ -296,6 +300,7 @@ impl CustomPermission {
|
||||
CustomPermission::AccessEventLogs => "accessEventLogs",
|
||||
CustomPermission::AccessImportExport => "accessImportExport",
|
||||
CustomPermission::AccessReports => "accessReports",
|
||||
CustomPermission::ManageResetPassword => "manageResetPassword",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +473,7 @@ impl Membership {
|
||||
access_event_logs: false,
|
||||
access_import_export: false,
|
||||
access_reports: false,
|
||||
manage_reset_password: false,
|
||||
|
||||
v2_upgrade_token: None,
|
||||
}
|
||||
@@ -484,6 +490,7 @@ impl Membership {
|
||||
CustomPermission::AccessEventLogs => &mut self.access_event_logs,
|
||||
CustomPermission::AccessImportExport => &mut self.access_import_export,
|
||||
CustomPermission::AccessReports => &mut self.access_reports,
|
||||
CustomPermission::ManageResetPassword => &mut self.manage_reset_password,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +560,7 @@ impl Membership {
|
||||
CustomPermission::AccessEventLogs => self.access_event_logs,
|
||||
CustomPermission::AccessImportExport => self.access_import_export,
|
||||
CustomPermission::AccessReports => self.access_reports,
|
||||
CustomPermission::ManageResetPassword => self.manage_reset_password,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +579,12 @@ impl Membership {
|
||||
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
|
||||
}
|
||||
|
||||
/// Whether this member may act with every Custom role permission stored for `other`, as Bitwarden's
|
||||
/// `Permissions.Includes`.
|
||||
pub fn has_custom_permissions_of(&self, other: &Membership) -> bool {
|
||||
CustomPermission::ALL.into_iter().all(|p| !other.custom_permission(p) || self.has_custom_permission(p))
|
||||
}
|
||||
|
||||
/// The permissions the "Manage all collections" a legacy Manager is shown with stands for: their `access_all`.
|
||||
pub fn legacy_manager_permissions(&self) -> HashSet<CustomPermission> {
|
||||
if self.has_type(MembershipType::Manager) && self.access_all {
|
||||
@@ -597,7 +611,6 @@ impl Membership {
|
||||
let shown = self.shown_custom_permissions();
|
||||
let mut permissions = json!({
|
||||
"manageSso": false, // Not supported
|
||||
"manageResetPassword": false,
|
||||
"manageScim": false, // Not supported (Not AGPLv3 Licensed)
|
||||
});
|
||||
for permission in CustomPermission::ALL {
|
||||
@@ -1720,7 +1733,8 @@ mod tests {
|
||||
DeleteAnyCollection,
|
||||
AccessEventLogs,
|
||||
AccessImportExport,
|
||||
AccessReports
|
||||
AccessReports,
|
||||
ManageResetPassword
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ table! {
|
||||
access_event_logs -> Bool,
|
||||
access_import_export -> Bool,
|
||||
access_reports -> Bool,
|
||||
manage_reset_password -> Bool,
|
||||
v2_upgrade_token -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,8 @@ app-security > app-two-factor-setup > form {
|
||||
|
||||
/* Hide unsupported Custom Role options */
|
||||
{{#if custom_roles_enabled}}
|
||||
/* Not supported: Manage SSO and Manage account recovery */
|
||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]),
|
||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageResetPassword"]) {
|
||||
/* Not supported: Manage SSO */
|
||||
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{else}}
|
||||
|
||||
Reference in new issue
Block a user