Sacha ATHIAS 2 days ago
committed by GitHub
parent
commit
29463af13d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/down.sql
  2. 2
      migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/up.sql
  3. 1
      migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/down.sql
  4. 2
      migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql
  5. 1
      migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/down.sql
  6. 2
      migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql
  7. 11
      src/api/core/events.rs
  8. 279
      src/api/core/organizations.rs
  9. 172
      src/auth.rs
  10. 2
      src/db/models/mod.rs
  11. 283
      src/db/models/organization.rs
  12. 1
      src/db/schema.rs

1
migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN permissions;

2
migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/up.sql

@ -0,0 +1,2 @@
-- Store permission bits as a signed integer to match Rust/Diesel i32.
ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL;

1
migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN permissions;

2
migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql

@ -0,0 +1,2 @@
-- Store permission bits as a signed integer to match Rust/Diesel i32.
ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL;

1
migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN permissions;

2
migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql

@ -0,0 +1,2 @@
-- Store permission bits as a signed integer to match Rust/Diesel i32.
ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL;

11
src/api/core/events.rs

@ -7,7 +7,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{EmptyResult, JsonResult},
auth::{AdminHeaders, Headers},
auth::{AccessEventLogsHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
@ -31,7 +31,12 @@ struct EventRange {
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
async fn get_org_events(
org_id: OrganizationId,
data: EventRange,
headers: AccessEventLogsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
@ -93,7 +98,7 @@ async fn get_user_events(
org_id: OrganizationId,
member_id: MembershipId,
data: EventRange,
headers: AdminHeaders,
headers: AccessEventLogsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {

279
src/api/core/organizations.rs

@ -11,13 +11,18 @@ use crate::{
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
},
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
auth::{
AccessImportExportHeaders, AdminHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders,
ManageResetPasswordHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders,
OwnerHeaders, decode_invite,
},
db::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, OrganizationUserPermissions,
User, UserId,
},
},
mail,
@ -107,6 +112,152 @@ pub fn routes() -> Vec<Route> {
]
}
fn parse_custom_permissions(
raw_type: &str,
permissions: &HashMap<String, Value>,
) -> Result<Option<OrganizationUserPermissions>, crate::Error> {
if raw_type != "4" && raw_type != "Custom" {
return Ok(None);
}
match OrganizationUserPermissions::from_payload_map(permissions) {
Ok(parsed) => Ok(Some(parsed)),
Err(error) => err!(format!("Invalid custom role permissions payload: {error:#}")),
}
}
fn resolve_membership_custom_permissions(
raw_type: &str,
new_type: i32,
permissions: &HashMap<String, Value>,
) -> Result<(bool, Option<i32>), crate::Error> {
let custom_permissions = parse_custom_permissions(raw_type, permissions)?;
let access_all = new_type >= MembershipType::Admin
|| custom_permissions.as_ref().is_some_and(OrganizationUserPermissions::has_manage_all_collections);
let permissions_mask = custom_permissions.map(|custom_permissions| custom_permissions.to_mask());
Ok((access_all, permissions_mask))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::models::OrganizationUserPermission;
fn custom_permissions_payload() -> HashMap<String, Value> {
HashMap::from([
("accessEventLogs".to_owned(), json!(true)),
("accessImportExport".to_owned(), json!(false)),
("accessReports".to_owned(), json!(true)),
("createNewCollections".to_owned(), json!(true)),
("editAnyCollection".to_owned(), json!(true)),
("deleteAnyCollection".to_owned(), json!(true)),
("manageGroups".to_owned(), json!(true)),
("managePolicies".to_owned(), json!(false)),
("manageSso".to_owned(), json!(false)),
("manageUsers".to_owned(), json!(true)),
("manageResetPassword".to_owned(), json!(false)),
("manageScim".to_owned(), json!(false)),
])
}
#[test]
fn invite_edit_store_custom_permissions_and_get_user_round_trip() {
let payload = custom_permissions_payload();
let (access_all, permission_mask) =
resolve_membership_custom_permissions("4", MembershipType::Manager as i32, &payload).unwrap();
assert!(access_all);
let stored_mask = permission_mask.expect("custom role payload should be persisted");
let expected_mask = OrganizationUserPermission::AccessEventLogs.bit()
| OrganizationUserPermission::AccessReports.bit()
| OrganizationUserPermission::CreateNewCollections.bit()
| OrganizationUserPermission::EditAnyCollection.bit()
| OrganizationUserPermission::DeleteAnyCollection.bit()
| OrganizationUserPermission::ManageGroups.bit()
| OrganizationUserPermission::ManageUsers.bit();
assert_eq!(stored_mask, expected_mask);
let parsed = OrganizationUserPermissions::from_mask(stored_mask);
assert_eq!(stored_mask, parsed.to_mask());
assert_eq!(
json!(parsed),
json!({
"accessEventLogs": true,
"accessImportExport": false,
"accessReports": true,
"createNewCollections": true,
"editAnyCollection": true,
"deleteAnyCollection": true,
"manageGroups": true,
"managePolicies": false,
"manageSso": false,
"manageUsers": true,
"manageResetPassword": false,
"manageScim": false
})
);
}
#[test]
fn invite_edit_non_custom_role_keeps_permissions_unset() {
let payload = custom_permissions_payload();
let (access_all, permission_mask) =
resolve_membership_custom_permissions("3", MembershipType::Manager as i32, &payload).unwrap();
assert!(!access_all);
assert!(permission_mask.is_none());
}
#[test]
fn invite_edit_custom_partial_collection_permissions_do_not_grant_access_all() {
let payload = HashMap::from([
("createNewCollections".to_owned(), json!(true)),
("editAnyCollection".to_owned(), json!(true)),
("deleteAnyCollection".to_owned(), json!(false)),
("manageUsers".to_owned(), json!(true)),
]);
let (access_all, permission_mask) =
resolve_membership_custom_permissions("Custom", MembershipType::Manager as i32, &payload).unwrap();
assert!(!access_all);
let stored_mask = permission_mask.expect("custom role payload should be persisted");
let expected_mask = OrganizationUserPermission::CreateNewCollections.bit()
| OrganizationUserPermission::EditAnyCollection.bit()
| OrganizationUserPermission::ManageUsers.bit();
assert_eq!(stored_mask, expected_mask);
let parsed = OrganizationUserPermissions::from_mask(stored_mask);
assert!(parsed.manage_users);
assert!(parsed.create_new_collections);
assert!(parsed.edit_any_collection);
assert!(!parsed.delete_any_collection);
}
#[test]
fn invite_edit_custom_empty_permissions_persist_zero_mask() {
let payload = HashMap::new();
let (access_all, permission_mask) =
resolve_membership_custom_permissions("Custom", MembershipType::Manager as i32, &payload).unwrap();
assert!(!access_all);
assert_eq!(permission_mask, Some(0));
let parsed = OrganizationUserPermissions::from_mask(permission_mask.unwrap());
assert_eq!(parsed, OrganizationUserPermissions::default());
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrgData {
@ -1043,7 +1194,7 @@ impl InviteData {
async fn send_invite(
org_id: OrganizationId,
data: Json<InviteData>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
@ -1066,14 +1217,7 @@ async fn send_invite(
err!("Only Owners can invite Managers, Admins or Owners")
}
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// If the box is not checked, the user will still be a manager, but not with the access_all permission
let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let (access_all, permissions_mask) = resolve_membership_custom_permissions(raw_type, new_type, &data.permissions)?;
let mut user_created: bool = false;
for email in &data.emails {
@ -1118,6 +1262,7 @@ async fn send_invite(
new_member.access_all = access_all;
new_member.atype = new_type;
new_member.status = member_status;
new_member.permissions = permissions_mask;
new_member.save(&conn).await?;
if CONFIG.mail_enabled() {
@ -1194,7 +1339,7 @@ async fn send_invite(
async fn bulk_reinvite_members(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -1229,7 +1374,7 @@ async fn bulk_reinvite_members(
async fn reinvite_member(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
@ -1360,7 +1505,7 @@ struct BulkConfirmData {
async fn bulk_confirm_invite(
org_id: OrganizationId,
data: Json<BulkConfirmData>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
@ -1404,7 +1549,7 @@ async fn confirm_invite(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<ConfirmData>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@ -1417,7 +1562,7 @@ async fn confirm_invite_impl(
org_id: &OrganizationId,
member_id: &MembershipId,
key: &str,
headers: &AdminHeaders,
headers: &ManageUsersHeaders,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
@ -1502,7 +1647,7 @@ async fn get_user(
org_id: OrganizationId,
member_id: MembershipId,
data: GetOrgUserData,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -1533,7 +1678,7 @@ async fn put_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<EditUserData>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
edit_member(org_id, member_id, data, headers, conn).await
@ -1544,7 +1689,7 @@ async fn edit_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<EditUserData>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
@ -1560,14 +1705,8 @@ async fn edit_member(
err!("Invalid type")
};
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// If the box is not checked, the user will still be a manager, but not with the access_all permission
let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let (access_all, permissions_mask) =
resolve_membership_custom_permissions(raw_type, new_type as i32, &data.permissions)?;
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err!("The specified user isn't member of the organization")
@ -1596,6 +1735,7 @@ async fn edit_member(
member_to_edit.access_all = access_all;
member_to_edit.atype = new_type as i32;
member_to_edit.permissions = permissions_mask;
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// We need to perform the check after changing the type since `admin` is exempt.
@ -1654,7 +1794,7 @@ async fn edit_member(
async fn bulk_delete_member(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
@ -1690,7 +1830,7 @@ async fn bulk_delete_member(
async fn delete_member(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@ -1700,7 +1840,7 @@ async fn delete_member(
async fn delete_member_impl(
org_id: &OrganizationId,
member_id: &MembershipId,
headers: &AdminHeaders,
headers: &ManageUsersHeaders,
conn: &DbConn,
nt: &Notify<'_>,
) -> EmptyResult {
@ -1754,7 +1894,7 @@ async fn delete_member_impl(
async fn bulk_public_keys(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -1966,7 +2106,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
}
#[get("/organizations/<org_id>/policies")]
async fn list_policies(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult {
async fn list_policies(org_id: OrganizationId, headers: ManagePoliciesHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
@ -2032,7 +2172,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH
}
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 3)]
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult {
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: ManagePoliciesHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
@ -2069,7 +2209,7 @@ async fn put_policy(
org_id: OrganizationId,
pol_type: i32,
data: Json<PutPolicy>,
headers: AdminHeaders,
headers: ManagePoliciesHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -2189,7 +2329,7 @@ async fn put_policy_vnext(
org_id: OrganizationId,
pol_type: i32,
data: Json<PutPolicy>,
headers: AdminHeaders,
headers: ManagePoliciesHeaders,
conn: DbConn,
) -> JsonResult {
put_policy(org_id, pol_type, data, headers, conn).await
@ -2266,7 +2406,7 @@ struct BulkRevokeMembershipIds {
async fn revoke_member(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
revoke_member_impl(&org_id, &member_id, &headers, &conn).await
@ -2276,7 +2416,7 @@ async fn revoke_member(
async fn bulk_revoke_members(
org_id: OrganizationId,
data: Json<BulkRevokeMembershipIds>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -2315,7 +2455,7 @@ async fn bulk_revoke_members(
async fn revoke_member_impl(
org_id: &OrganizationId,
member_id: &MembershipId,
headers: &AdminHeaders,
headers: &ManageUsersHeaders,
conn: &DbConn,
) -> EmptyResult {
if org_id != &headers.org_id {
@ -2329,6 +2469,9 @@ async fn revoke_member_impl(
if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can revoke other owners")
}
if !headers.is_admin_or_owner && member.atype != MembershipType::User {
err!("Custom roles can only revoke users")
}
if member.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1
{
@ -2359,7 +2502,7 @@ async fn revoke_member_impl(
async fn restore_member_vnext(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
// Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy.
@ -2371,7 +2514,7 @@ async fn restore_member_vnext(
async fn restore_member(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
restore_member_impl(&org_id, &member_id, &headers, &conn).await
@ -2381,7 +2524,7 @@ async fn restore_member(
async fn bulk_restore_members(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -2415,7 +2558,7 @@ async fn bulk_restore_members(
async fn restore_member_impl(
org_id: &OrganizationId,
member_id: &MembershipId,
headers: &AdminHeaders,
headers: &ManageUsersHeaders,
conn: &DbConn,
) -> EmptyResult {
if org_id != &headers.org_id {
@ -2429,6 +2572,9 @@ async fn restore_member_impl(
if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can restore other owners")
}
if !headers.is_admin_or_owner && member.atype != MembershipType::User {
err!("Custom roles can only restore users")
}
member.restore();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
@ -2579,7 +2725,7 @@ async fn post_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> JsonResult {
put_group(org_id, group_id, data, headers, conn).await
@ -2588,7 +2734,7 @@ async fn post_group(
#[post("/organizations/<org_id>/groups", data = "<data>")]
async fn post_groups(
org_id: OrganizationId,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
data: Json<GroupRequest>,
conn: DbConn,
) -> JsonResult {
@ -2623,7 +2769,7 @@ async fn put_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -2664,7 +2810,7 @@ async fn add_update_group(
collections: Vec<CollectionData>,
members: Vec<MembershipId>,
org_id: OrganizationId,
headers: &AdminHeaders,
headers: &ManageGroupsHeaders,
conn: &DbConn,
) -> JsonResult {
group.save(conn).await?;
@ -2704,7 +2850,7 @@ async fn add_update_group(
async fn get_group_details(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -2725,21 +2871,26 @@ async fn get_group_details(
async fn post_delete_group(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
}
#[delete("/organizations/<org_id>/groups/<group_id>")]
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
async fn delete_group(
org_id: OrganizationId,
group_id: GroupId,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
}
async fn delete_group_impl(
org_id: &OrganizationId,
group_id: &GroupId,
headers: &AdminHeaders,
headers: &ManageGroupsHeaders,
conn: &DbConn,
) -> EmptyResult {
if org_id != &headers.org_id {
@ -2771,7 +2922,7 @@ async fn delete_group_impl(
async fn bulk_delete_groups(
org_id: OrganizationId,
data: Json<BulkGroupIds>,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
@ -2790,7 +2941,12 @@ async fn bulk_delete_groups(
}
#[get("/organizations/<org_id>/groups/<group_id>", rank = 2)]
async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> JsonResult {
async fn get_group(
org_id: OrganizationId,
group_id: GroupId,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
@ -2809,7 +2965,7 @@ async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHead
async fn get_group_members(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -2836,7 +2992,7 @@ async fn get_group_members(
async fn put_group_members(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
data: Json<Vec<MembershipId>>,
conn: DbConn,
) -> EmptyResult {
@ -2884,7 +3040,7 @@ async fn post_delete_group_member(
org_id: OrganizationId,
group_id: GroupId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
@ -2967,7 +3123,7 @@ async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders
async fn put_recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageResetPasswordHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
@ -2985,7 +3141,7 @@ async fn put_recover_account(
async fn put_reset_password(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageResetPasswordHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
@ -2996,7 +3152,7 @@ async fn put_reset_password(
async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
headers: ManageResetPasswordHeaders,
reset_request: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
@ -3056,7 +3212,7 @@ async fn recover_account(
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 {
@ -3092,7 +3248,7 @@ async fn get_reset_password_details(
async fn check_reset_password_applicable_and_permissions(
org_id: &OrganizationId,
member_id: &MembershipId,
headers: &AdminHeaders,
headers: &ManageResetPasswordHeaders,
conn: &DbConn,
) -> EmptyResult {
check_reset_password_applicable(org_id, conn).await?;
@ -3105,6 +3261,7 @@ async fn check_reset_password_applicable_and_permissions(
match headers.membership_type {
MembershipType::Owner => Ok(()),
MembershipType::Admin if target_user.atype <= MembershipType::Admin => Ok(()),
MembershipType::Manager if !headers.is_admin_or_owner && target_user.atype == MembershipType::User => Ok(()),
_ => err!("No permission to reset this user's password"),
}
}
@ -3184,7 +3341,7 @@ async fn put_reset_password_enrollment(
// Vaultwarden does not yet support exporting only managed collections!
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
#[get("/organizations/<org_id>/export")]
async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult {
async fn get_org_export(org_id: OrganizationId, headers: AccessImportExportHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}

172
src/auth.rs

@ -29,8 +29,8 @@ use crate::{
DbConn,
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
SendId, User, UserId, UserStampException,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
OrganizationUserPermission, SendFileId, SendId, User, UserId, UserStampException,
},
},
error::Error,
@ -730,6 +730,10 @@ impl OrgHeaders {
fn is_confirmed_and_owner(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner
}
fn has_permission(&self, permission: OrganizationUserPermission) -> bool {
self.membership.has_permission(permission)
}
}
// org_id is usually the second path param ("/organizations/<org_id>"),
@ -814,31 +818,185 @@ impl<'r> FromRequest<'r> for OrgHeaders {
}
pub struct AdminHeaders {
pub host: String,
pub user: User,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders {
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_admin() {
Outcome::Success(Self {
user: headers.user,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin or Owner to call this endpoint")
}
}
}
pub struct ManageUsersHeaders {
pub device: Device,
pub user: User,
pub membership_type: MembershipType,
pub is_admin_or_owner: bool,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders {
impl<'r> FromRequest<'r> for ManageUsersHeaders {
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_admin() {
let is_admin_or_owner = headers.is_confirmed_and_admin();
if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageUsers) {
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
membership_type: headers.membership_type,
is_admin_or_owner,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin or Owner to call this endpoint")
err_handler!("You need to be Admin/Owner or have Manage Users permission to call this endpoint")
}
}
}
pub struct ManageResetPasswordHeaders {
pub device: Device,
pub user: User,
pub membership_type: MembershipType,
pub is_admin_or_owner: bool,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ManageResetPasswordHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
let is_admin_or_owner = headers.is_confirmed_and_admin();
if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageResetPassword) {
Outcome::Success(Self {
device: headers.device,
user: headers.user,
membership_type: headers.membership_type,
is_admin_or_owner,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin/Owner or have Manage Reset Password permission to call this endpoint")
}
}
}
pub struct ManageGroupsHeaders {
pub device: Device,
pub user: User,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ManageGroupsHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
let is_admin_or_owner = headers.is_confirmed_and_admin();
if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageGroups) {
Outcome::Success(Self {
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin/Owner or have Manage Groups permission to call this endpoint")
}
}
}
pub struct ManagePoliciesHeaders {
pub device: Device,
pub user: User,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ManagePoliciesHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
let is_admin_or_owner = headers.is_confirmed_and_admin();
if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManagePolicies) {
Outcome::Success(Self {
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin/Owner or have Manage Policies permission to call this endpoint")
}
}
}
pub struct AccessEventLogsHeaders {
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AccessEventLogsHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
let is_admin_or_owner = headers.is_confirmed_and_admin();
if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::AccessEventLogs) {
Outcome::Success(Self {
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin/Owner or have Access Event Logs permission to call this endpoint")
}
}
}
pub struct AccessImportExportHeaders {
pub host: String,
pub user: User,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AccessImportExportHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
let is_admin_or_owner = headers.is_confirmed_and_admin();
if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::AccessImportExport) {
Outcome::Success(Self {
host: headers.host,
user: headers.user,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin/Owner or have Access Import Export permission to call this endpoint")
}
}
}

2
src/db/models/mod.rs

@ -32,7 +32,7 @@ pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId,
OrganizationId, OrganizationUserPermission, OrganizationUserPermissions,
};
pub use self::send::{Send, SendFileId, SendId, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};

283
src/db/models/organization.rs

@ -7,6 +7,7 @@ use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use num_traits::FromPrimitive;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
@ -57,6 +58,199 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
pub permissions: Option<i32>,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OrganizationUserPermissions {
pub access_event_logs: bool,
pub access_import_export: bool,
pub access_reports: bool,
pub create_new_collections: bool,
pub edit_any_collection: bool,
pub delete_any_collection: bool,
pub manage_groups: bool,
pub manage_policies: bool,
pub manage_sso: bool,
pub manage_users: bool,
pub manage_reset_password: bool,
pub manage_scim: bool,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum OrganizationUserPermission {
AccessEventLogs,
AccessImportExport,
AccessReports,
CreateNewCollections,
EditAnyCollection,
DeleteAnyCollection,
ManageGroups,
ManagePolicies,
ManageSso,
ManageUsers,
ManageResetPassword,
ManageScim,
}
impl OrganizationUserPermission {
// Stable bit layout persisted in users_organizations.permissions.
// Do not reorder or reuse these bit positions for different semantics.
pub const ACCESS_EVENT_LOGS_BIT: i32 = 1 << 0;
pub const ACCESS_IMPORT_EXPORT_BIT: i32 = 1 << 1;
pub const ACCESS_REPORTS_BIT: i32 = 1 << 2;
pub const CREATE_NEW_COLLECTIONS_BIT: i32 = 1 << 3;
pub const EDIT_ANY_COLLECTION_BIT: i32 = 1 << 4;
pub const DELETE_ANY_COLLECTION_BIT: i32 = 1 << 5;
pub const MANAGE_GROUPS_BIT: i32 = 1 << 6;
pub const MANAGE_POLICIES_BIT: i32 = 1 << 7;
pub const MANAGE_SSO_BIT: i32 = 1 << 8;
pub const MANAGE_USERS_BIT: i32 = 1 << 9;
pub const MANAGE_RESET_PASSWORD_BIT: i32 = 1 << 10;
pub const MANAGE_SCIM_BIT: i32 = 1 << 11;
pub const KNOWN_BITS_MASK: i32 = Self::ACCESS_EVENT_LOGS_BIT
| Self::ACCESS_IMPORT_EXPORT_BIT
| Self::ACCESS_REPORTS_BIT
| Self::CREATE_NEW_COLLECTIONS_BIT
| Self::EDIT_ANY_COLLECTION_BIT
| Self::DELETE_ANY_COLLECTION_BIT
| Self::MANAGE_GROUPS_BIT
| Self::MANAGE_POLICIES_BIT
| Self::MANAGE_SSO_BIT
| Self::MANAGE_USERS_BIT
| Self::MANAGE_RESET_PASSWORD_BIT
| Self::MANAGE_SCIM_BIT;
pub fn from_key(key: &str) -> Option<Self> {
match key {
"accessEventLogs" | "access_event_logs" => Some(Self::AccessEventLogs),
"accessImportExport" | "access_import_export" => Some(Self::AccessImportExport),
"accessReports" | "access_reports" => Some(Self::AccessReports),
"createNewCollections" | "create_new_collections" => Some(Self::CreateNewCollections),
"editAnyCollection" | "edit_any_collection" => Some(Self::EditAnyCollection),
"deleteAnyCollection" | "delete_any_collection" => Some(Self::DeleteAnyCollection),
"manageGroups" | "manage_groups" => Some(Self::ManageGroups),
"managePolicies" | "manage_policies" => Some(Self::ManagePolicies),
"manageSso" | "manage_sso" => Some(Self::ManageSso),
"manageUsers" | "manage_users" => Some(Self::ManageUsers),
"manageResetPassword" | "manage_reset_password" => Some(Self::ManageResetPassword),
"manageScim" | "manage_scim" => Some(Self::ManageScim),
_ => None,
}
}
pub const fn bit(self) -> i32 {
match self {
Self::AccessEventLogs => Self::ACCESS_EVENT_LOGS_BIT,
Self::AccessImportExport => Self::ACCESS_IMPORT_EXPORT_BIT,
Self::AccessReports => Self::ACCESS_REPORTS_BIT,
Self::CreateNewCollections => Self::CREATE_NEW_COLLECTIONS_BIT,
Self::EditAnyCollection => Self::EDIT_ANY_COLLECTION_BIT,
Self::DeleteAnyCollection => Self::DELETE_ANY_COLLECTION_BIT,
Self::ManageGroups => Self::MANAGE_GROUPS_BIT,
Self::ManagePolicies => Self::MANAGE_POLICIES_BIT,
Self::ManageSso => Self::MANAGE_SSO_BIT,
Self::ManageUsers => Self::MANAGE_USERS_BIT,
Self::ManageResetPassword => Self::MANAGE_RESET_PASSWORD_BIT,
Self::ManageScim => Self::MANAGE_SCIM_BIT,
}
}
}
impl OrganizationUserPermissions {
pub fn from_payload_map(payload: &HashMap<String, Value>) -> serde_json::Result<Self> {
let value = Value::Object(payload.clone().into_iter().collect());
serde_json::from_value(value)
}
pub fn from_mask(mask: i32) -> Self {
let sanitized_mask = mask & OrganizationUserPermission::KNOWN_BITS_MASK;
if sanitized_mask != mask {
debug!(
"Ignoring unknown organization permission bits in mask {mask:#x} (known bits {:#x})",
OrganizationUserPermission::KNOWN_BITS_MASK
);
}
Self {
access_event_logs: sanitized_mask & OrganizationUserPermission::AccessEventLogs.bit() != 0,
access_import_export: sanitized_mask & OrganizationUserPermission::AccessImportExport.bit() != 0,
access_reports: sanitized_mask & OrganizationUserPermission::AccessReports.bit() != 0,
create_new_collections: sanitized_mask & OrganizationUserPermission::CreateNewCollections.bit() != 0,
edit_any_collection: sanitized_mask & OrganizationUserPermission::EditAnyCollection.bit() != 0,
delete_any_collection: sanitized_mask & OrganizationUserPermission::DeleteAnyCollection.bit() != 0,
manage_groups: sanitized_mask & OrganizationUserPermission::ManageGroups.bit() != 0,
manage_policies: sanitized_mask & OrganizationUserPermission::ManagePolicies.bit() != 0,
manage_sso: sanitized_mask & OrganizationUserPermission::ManageSso.bit() != 0,
manage_users: sanitized_mask & OrganizationUserPermission::ManageUsers.bit() != 0,
manage_reset_password: sanitized_mask & OrganizationUserPermission::ManageResetPassword.bit() != 0,
manage_scim: sanitized_mask & OrganizationUserPermission::ManageScim.bit() != 0,
}
}
pub fn to_mask(&self) -> i32 {
let mut mask = 0;
if self.access_event_logs {
mask |= OrganizationUserPermission::AccessEventLogs.bit();
}
if self.access_import_export {
mask |= OrganizationUserPermission::AccessImportExport.bit();
}
if self.access_reports {
mask |= OrganizationUserPermission::AccessReports.bit();
}
if self.create_new_collections {
mask |= OrganizationUserPermission::CreateNewCollections.bit();
}
if self.edit_any_collection {
mask |= OrganizationUserPermission::EditAnyCollection.bit();
}
if self.delete_any_collection {
mask |= OrganizationUserPermission::DeleteAnyCollection.bit();
}
if self.manage_groups {
mask |= OrganizationUserPermission::ManageGroups.bit();
}
if self.manage_policies {
mask |= OrganizationUserPermission::ManagePolicies.bit();
}
if self.manage_sso {
mask |= OrganizationUserPermission::ManageSso.bit();
}
if self.manage_users {
mask |= OrganizationUserPermission::ManageUsers.bit();
}
if self.manage_reset_password {
mask |= OrganizationUserPermission::ManageResetPassword.bit();
}
if self.manage_scim {
mask |= OrganizationUserPermission::ManageScim.bit();
}
mask
}
pub fn is_enabled(&self, permission: OrganizationUserPermission) -> bool {
self.to_mask() & permission.bit() != 0
}
pub fn has_manage_all_collections(&self) -> bool {
self.edit_any_collection && self.delete_any_collection && self.create_new_collections
}
pub fn from_legacy_access_all(access_all: bool) -> Option<Self> {
if !access_all {
return None;
}
Some(Self {
create_new_collections: true,
edit_any_collection: true,
delete_any_collection: true,
..Self::default()
})
}
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -274,6 +468,7 @@ impl Membership {
atype: MembershipType::User as i32,
reset_password_key: None,
external_id: None,
permissions: None,
}
}
@ -322,6 +517,32 @@ impl Membership {
_ => self.atype,
}
}
pub fn custom_permissions(&self) -> Option<OrganizationUserPermissions> {
let legacy_permissions = OrganizationUserPermissions::from_legacy_access_all(self.access_all);
self.permissions.map(OrganizationUserPermissions::from_mask).or(legacy_permissions)
}
pub fn has_permission(&self, permission: OrganizationUserPermission) -> bool {
if !self.has_status(MembershipStatus::Confirmed) {
return false;
}
if self.atype >= MembershipType::Admin {
return true;
}
if self.atype != MembershipType::Manager {
return false;
}
self.custom_permissions().is_some_and(|permissions| permissions.is_enabled(permission))
}
pub fn has_permission_key(&self, key: &str) -> bool {
OrganizationUserPermission::from_key(key).is_some_and(|permission| self.has_permission(permission))
}
}
impl OrganizationApiKey {
@ -454,24 +675,11 @@ impl Membership {
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
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,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
});
let permissions = if membership_type == 4 {
json!(self.custom_permissions().unwrap_or_default())
} else {
json!(OrganizationUserPermissions::default())
};
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({
@ -624,27 +832,10 @@ impl Membership {
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
// 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 {
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,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
// HACK: Keep the Manager->Custom response conversion. If no stored permissions exist,
// preserve the old access_all-based fallback behavior.
let permissions = if membership_type == 4 {
self.custom_permissions().map_or_else(|| json!(null), |permissions| json!(permissions))
} else {
json!(null)
};
@ -1285,4 +1476,18 @@ mod tests {
assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
}
#[test]
fn organization_user_permissions_round_trip_all_known_bits() {
for mask in 0..=OrganizationUserPermission::KNOWN_BITS_MASK {
let permissions = OrganizationUserPermissions::from_mask(mask);
assert_eq!(permissions.to_mask(), mask);
}
}
#[test]
fn organization_user_permissions_ignore_unknown_bits() {
let permissions = OrganizationUserPermissions::from_mask(-1);
assert_eq!(permissions.to_mask(), OrganizationUserPermission::KNOWN_BITS_MASK);
}
}

1
src/db/schema.rs

@ -242,6 +242,7 @@ table! {
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
permissions -> Nullable<Integer>,
}
}

Loading…
Cancel
Save