From 3c5b846d038ca66f49b1e684dc0ab7712ae21d63 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:40:02 +0200 Subject: [PATCH] Implement accessReports custom permission --- src/api/core/organizations.rs | 20 ++++++++------------ src/auth.rs | 9 +++++++++ src/db/models/organization.rs | 10 ++++++---- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 7d05787c..ae7429ef 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1237,9 +1237,7 @@ impl CustomRolePermissions { delete_any_collection: enabled("deleteAnyCollection"), access_event_logs: enabled("accessEventLogs"), access_import_export: enabled("accessImportExport"), - // Vaultwarden has no report endpoints yet. Keep the compatibility field in the - // database/DTO, but never accept a permission that cannot be enforced server-side. - access_reports: false, + access_reports: enabled("accessReports"), } } @@ -1271,8 +1269,7 @@ impl CustomRolePermissions { delete_any_collection: membership.delete_any_collection, access_event_logs: membership.access_event_logs, access_import_export: membership.access_import_export, - // Reports are unsupported and therefore never preserved as an active grant. - access_reports: false, + access_reports: membership.access_reports, } } None => Self::default(), @@ -1288,6 +1285,7 @@ impl CustomRolePermissions { || 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) { @@ -4214,7 +4212,7 @@ mod tests { assert!(custom.delete_any_collection); assert!(custom.access_event_logs); assert!(custom.access_import_export); - assert!(!custom.access_reports, "unsupported report access must remain fail-closed"); + assert!(custom.access_reports); let user = CustomRolePermissions::from_request(MembershipType::User, &permissions); assert_eq!(user, CustomRolePermissions::default()); @@ -4237,6 +4235,7 @@ mod tests { delete_any_collection: true, access_event_logs: true, access_import_export: true, + access_reports: true, ..CustomRolePermissions::default() }; @@ -4248,7 +4247,7 @@ mod tests { assert!(membership.delete_any_collection); assert!(membership.access_event_logs); assert!(membership.access_import_export); - assert!(!membership.access_reports); + assert!(membership.access_reports); } #[test] @@ -4273,11 +4272,8 @@ mod tests { assert!(preserved.delete_any_collection); assert!(preserved.access_event_logs); assert!(preserved.access_import_export); - assert!(!preserved.access_reports); - assert!( - !preserved.differs_from(&membership), - "a stale unsupported reports bit must not block an otherwise unchanged legacy-client update" - ); + assert!(preserved.access_reports); + assert!(!preserved.differs_from(&membership)); let explicit_reset = HashMap::new(); assert_eq!( diff --git a/src/auth.rs b/src/auth.rs index 49b72495..d008543d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -766,6 +766,10 @@ impl OrgHeaders { self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_access_import_export()) } + + fn can_access_reports(&self) -> bool { + self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_access_reports()) + } } // org_id is usually the second path param ("/organizations/"), @@ -966,6 +970,11 @@ generate_manage_headers!( can_access_import_export, "You need the 'Access Import/Export' permission, or to be an Admin or Owner, to call this endpoint" ); +generate_manage_headers!( + AccessReportsHeaders, + can_access_reports, + "You need the 'Access Reports' 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 410ba7d0..6922c56e 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -469,9 +469,7 @@ impl Membership { let permissions = json!({ "accessEventLogs": membership_type == MembershipType::Custom as i32 && self.access_event_logs, "accessImportExport": membership_type == MembershipType::Custom as i32 && self.access_import_export, - // Reports are not implemented server-side. Advertising a stored bit as usable - // would make the permission contract misleading, so this stays fail-closed. - "accessReports": false, + "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, @@ -639,7 +637,7 @@ impl Membership { json!({ "accessEventLogs": self.access_event_logs, "accessImportExport": self.access_import_export, - "accessReports": false, + "accessReports": self.access_reports, "createNewCollections": self.create_new_collections, "editAnyCollection": self.edit_any_collection, "deleteAnyCollection": self.delete_any_collection, @@ -885,6 +883,10 @@ impl Membership { 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.