diff --git a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql new file mode 100644 index 00000000..f276ea5b --- /dev/null +++ b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations DROP COLUMN access_event_logs; +ALTER TABLE users_organizations DROP COLUMN access_import_export; +ALTER TABLE users_organizations DROP COLUMN access_reports; diff --git a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql new file mode 100644 index 00000000..9d9c31ff --- /dev/null +++ b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql @@ -0,0 +1,5 @@ +-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members +-- (gated on the role in code); Owners/Admins hold every permission implicitly. +ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql new file mode 100644 index 00000000..f276ea5b --- /dev/null +++ b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations DROP COLUMN access_event_logs; +ALTER TABLE users_organizations DROP COLUMN access_import_export; +ALTER TABLE users_organizations DROP COLUMN access_reports; diff --git a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql new file mode 100644 index 00000000..9d9c31ff --- /dev/null +++ b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql @@ -0,0 +1,5 @@ +-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members +-- (gated on the role in code); Owners/Admins hold every permission implicitly. +ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql new file mode 100644 index 00000000..f276ea5b --- /dev/null +++ b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations DROP COLUMN access_event_logs; +ALTER TABLE users_organizations DROP COLUMN access_import_export; +ALTER TABLE users_organizations DROP COLUMN access_reports; diff --git a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql new file mode 100644 index 00000000..9d9c31ff --- /dev/null +++ b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql @@ -0,0 +1,5 @@ +-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members +-- (gated on the role in code); Owners/Admins hold every permission implicitly. +ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 698a890f..3fea281e 100644 --- a/src/api/core/events.rs +++ b/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, 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//events?")] -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 { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index edc88881..001b546f 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -12,9 +12,9 @@ use crate::{ core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{ - AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManageGroupsHeaders, - ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, ManagerHeadersLoose, - OrgMemberHeaders, OwnerHeaders, decode_invite, + AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, + ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, + ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite, }, db::{ DbConn, @@ -1192,6 +1192,9 @@ struct CustomRolePermissions { create_new_collections: bool, edit_any_collection: bool, delete_any_collection: bool, + access_event_logs: bool, + access_import_export: bool, + access_reports: bool, } impl CustomRolePermissions { @@ -1208,6 +1211,9 @@ impl CustomRolePermissions { create_new_collections: enabled("createNewCollections"), edit_any_collection: enabled("editAnyCollection"), delete_any_collection: enabled("deleteAnyCollection"), + access_event_logs: enabled("accessEventLogs"), + access_import_export: enabled("accessImportExport"), + access_reports: enabled("accessReports"), } } @@ -1225,6 +1231,9 @@ impl CustomRolePermissions { || self.create_new_collections != membership.create_new_collections || self.edit_any_collection != membership.edit_any_collection || self.delete_any_collection != membership.delete_any_collection + || self.access_event_logs != membership.access_event_logs + || self.access_import_export != membership.access_import_export + || self.access_reports != membership.access_reports } fn apply_to(self, membership: &mut Membership) { @@ -1234,6 +1243,9 @@ impl CustomRolePermissions { membership.create_new_collections = self.create_new_collections; membership.edit_any_collection = self.edit_any_collection; membership.delete_any_collection = self.delete_any_collection; + membership.access_event_logs = self.access_event_logs; + membership.access_import_export = self.access_import_export; + membership.access_reports = self.access_reports; } } @@ -2169,6 +2181,20 @@ async fn post_org_import( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + + // accessImportExport: importing into the organization requires the permission (or Admin/Owner), + // mirroring the export endpoint and the Bitwarden permission model. The web-vault only offers org + // import to members holding this permission; enforcing it server-side keeps the two consistent. + // NOTE: this tightens the previous member-level behaviour (any confirmed member could import into + // collections they could write) — see the branch notes. + if !(headers.membership.has_status(MembershipStatus::Confirmed) + && (headers.membership.atype >= MembershipType::Admin || headers.membership.has_access_import_export())) + { + err!( + "You need the 'Access Import/Export' permission, or to be an Admin or Owner, to import into this organization" + ) + } + let data: ImportData = data.into_inner(); // Validate the import before continuing @@ -3806,7 +3832,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//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"); } @@ -4023,6 +4049,9 @@ mod tests { ("createNewCollections".to_owned(), json!(true)), ("editAnyCollection".to_owned(), json!(true)), ("deleteAnyCollection".to_owned(), json!(true)), + ("accessEventLogs".to_owned(), json!(true)), + ("accessImportExport".to_owned(), json!(true)), + ("accessReports".to_owned(), json!(true)), ]); let custom = CustomRolePermissions::from_request(MembershipType::Custom, &permissions); @@ -4032,6 +4061,9 @@ mod tests { assert!(custom.create_new_collections); assert!(custom.edit_any_collection); assert!(custom.delete_any_collection); + assert!(custom.access_event_logs); + assert!(custom.access_import_export); + assert!(custom.access_reports); let user = CustomRolePermissions::from_request(MembershipType::User, &permissions); assert_eq!(user, CustomRolePermissions::default()); @@ -4052,6 +4084,9 @@ mod tests { create_new_collections: true, edit_any_collection: true, delete_any_collection: true, + access_event_logs: true, + access_import_export: true, + access_reports: true, ..CustomRolePermissions::default() }; @@ -4061,5 +4096,8 @@ mod tests { assert!(membership.create_new_collections); assert!(membership.edit_any_collection); assert!(membership.delete_any_collection); + assert!(membership.access_event_logs); + assert!(membership.access_import_export); + assert!(membership.access_reports); } } diff --git a/src/auth.rs b/src/auth.rs index 4716b6e2..9e89a16b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -755,6 +755,14 @@ impl OrgHeaders { || self.membership.has_manage_users() || self.membership.has_manage_groups()) } + fn can_access_event_logs(&self) -> bool { + self.is_confirmed() + && (self.membership_type >= MembershipType::Admin || self.membership.has_access_event_logs()) + } + fn can_access_import_export(&self) -> bool { + self.is_confirmed() + && (self.membership_type >= MembershipType::Admin || self.membership.has_access_import_export()) + } } // org_id is usually the second path param ("/organizations/"), @@ -839,6 +847,9 @@ impl<'r> FromRequest<'r> for OrgHeaders { } pub struct AdminHeaders { + // Kept for parity with the other org header guards (and possible future use); the org export + // endpoint that used to read this now goes through `AccessImportExportHeaders` instead. + #[allow(dead_code)] pub host: String, pub device: Device, pub user: User, @@ -937,6 +948,16 @@ generate_manage_headers!( 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" ); +generate_manage_headers!( + AccessEventLogsHeaders, + can_access_event_logs, + "You need the 'Access Event Logs' permission, or to be an Admin or Owner, to call this endpoint" +); +generate_manage_headers!( + AccessImportExportHeaders, + can_access_import_export, + "You need the 'Access Import/Export' permission, or to be an Admin or Owner, to call this endpoint" +); // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 81ceb15c..0ddfcc80 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -64,6 +64,9 @@ pub struct Membership { pub create_new_collections: bool, pub edit_any_collection: bool, pub delete_any_collection: bool, + pub access_event_logs: bool, + pub access_import_export: bool, + pub access_reports: bool, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -283,6 +286,9 @@ impl Membership { create_new_collections: false, edit_any_collection: false, delete_any_collection: false, + access_event_logs: false, + access_import_export: false, + access_reports: false, } } @@ -453,9 +459,9 @@ impl Membership { let membership_type = self.atype; let permissions = json!({ - "accessEventLogs": false, - "accessImportExport": false, - "accessReports": false, + "accessEventLogs": membership_type == MembershipType::Custom as i32 && self.access_event_logs, + "accessImportExport": membership_type == MembershipType::Custom as i32 && self.access_import_export, + "accessReports": membership_type == MembershipType::Custom as i32 && self.access_reports, "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, @@ -619,9 +625,9 @@ impl Membership { // all-false defaults and the role itself supplies any elevated capabilities. let permissions = if membership_type == MembershipType::Custom as i32 { json!({ - "accessEventLogs": false, - "accessImportExport": false, - "accessReports": false, + "accessEventLogs": self.access_event_logs, + "accessImportExport": self.access_import_export, + "accessReports": self.access_reports, "createNewCollections": self.create_new_collections, "editAnyCollection": self.edit_any_collection, "deleteAnyCollection": self.delete_any_collection, @@ -847,6 +853,18 @@ impl Membership { self.has_type(MembershipType::Custom) && self.delete_any_collection } + pub fn has_access_event_logs(&self) -> bool { + self.has_type(MembershipType::Custom) && self.access_event_logs + } + + pub fn has_access_import_export(&self) -> bool { + self.has_type(MembershipType::Custom) && self.access_import_export + } + + pub fn has_access_reports(&self) -> bool { + self.has_type(MembershipType::Custom) && self.access_reports + } + /// 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. @@ -953,6 +971,9 @@ impl Membership { self.create_new_collections = false; self.edit_any_collection = false; self.delete_any_collection = false; + self.access_event_logs = false; + self.access_import_export = false; + self.access_reports = false; } pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { @@ -1514,6 +1535,9 @@ mod tests { member.create_new_collections = true; member.edit_any_collection = true; member.delete_any_collection = true; + member.access_event_logs = true; + member.access_import_export = true; + member.access_reports = true; member.clear_custom_permissions(); @@ -1523,5 +1547,31 @@ mod tests { assert!(!member.create_new_collections); assert!(!member.edit_any_collection); assert!(!member.delete_any_collection); + assert!(!member.access_event_logs); + assert!(!member.access_import_export); + assert!(!member.access_reports); + } + + #[test] + fn custom_access_permissions_are_independent_and_type_gated() { + let mut member = membership(MembershipType::Custom); + member.access_event_logs = true; + assert!(member.has_access_event_logs()); + assert!(!member.has_access_import_export()); + assert!(!member.has_access_reports()); + + member.access_import_export = true; + member.access_reports = true; + assert!(member.has_access_import_export()); + assert!(member.has_access_reports()); + // None of them imply collection or management capabilities. + assert!(!member.has_full_access()); + assert!(!member.has_manage_users()); + + // Stale flags on a non-Custom role grant nothing. + member.atype = MembershipType::User as i32; + assert!(!member.has_access_event_logs()); + assert!(!member.has_access_import_export()); + assert!(!member.has_access_reports()); } } diff --git a/src/db/schema.rs b/src/db/schema.rs index e8840acd..aee26991 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -248,6 +248,9 @@ table! { create_new_collections -> Bool, edit_any_collection -> Bool, delete_any_collection -> Bool, + access_event_logs -> Bool, + access_import_export -> Bool, + access_reports -> Bool, } }