Browse Source

Add accessEventLogs, accessImportExport and accessReports Custom permissions

Implements the three remaining Bitwarden Custom-role permissions on top of the
existing set. Each is an independent, persisted flag on the membership, gated on
the Custom role in code (stale flags on other roles grant nothing); Owners/Admins
hold every permission implicitly. They are parsed from and emitted in the
`permissions` object (replacing the previously hard-coded `false`) so the
unmodified web-vault shows and round-trips them.

Server-side enforcement:
- accessEventLogs: the organization event-log endpoints (`GET .../events` and
  `GET .../users/<id>/events`) now use a new `AccessEventLogsHeaders` guard
  (Admin/Owner or the permission) instead of `AdminHeaders`.
- accessImportExport: `GET .../export` uses a new `AccessImportExportHeaders`
  guard, and `post_org_import` gains an explicit permission check.
  NOTE: this tightens org import, which previously accepted any confirmed member
  (with per-collection gating). It now requires Admin/Owner or the permission,
  matching Bitwarden and the web-vault, which only offers org import to permitted
  members. Flagged here for maintainer review.
- accessReports has no server endpoint in Vaultwarden (reports are computed
  client-side from vault data the member already has), so it is stored and
  reported in the permissions object and enforced by the client UI, matching
  Bitwarden's own model. No server route gates it.

New migration adds the three columns (down drops them). Unit tests cover
independence, type-gating, parsing and change-detection; a black-box probe over
HTTP confirms the event-log/export/import gating and the permission round-trip
(16/16), and the existing custom-role suite still passes (30/30).
pull/7397/head
tom27052006 3 weeks ago
parent
commit
bf56c9b169
  1. 3
      migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql
  2. 5
      migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql
  3. 3
      migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql
  4. 5
      migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql
  5. 3
      migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql
  6. 5
      migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql
  7. 11
      src/api/core/events.rs
  8. 46
      src/api/core/organizations.rs
  9. 21
      src/auth.rs
  10. 62
      src/db/models/organization.rs
  11. 3
      src/db/schema.rs

3
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;

5
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;

3
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;

5
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;

3
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;

5
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;

11
src/api/core/events.rs

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

46
src/api/core/organizations.rs

@ -12,9 +12,9 @@ use crate::{
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
}, },
auth::{ auth::{
AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManageGroupsHeaders, AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers,
ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, ManagerHeadersLoose, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders,
OrgMemberHeaders, OwnerHeaders, decode_invite, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite,
}, },
db::{ db::{
DbConn, DbConn,
@ -1192,6 +1192,9 @@ struct CustomRolePermissions {
create_new_collections: bool, create_new_collections: bool,
edit_any_collection: bool, edit_any_collection: bool,
delete_any_collection: bool, delete_any_collection: bool,
access_event_logs: bool,
access_import_export: bool,
access_reports: bool,
} }
impl CustomRolePermissions { impl CustomRolePermissions {
@ -1208,6 +1211,9 @@ impl CustomRolePermissions {
create_new_collections: enabled("createNewCollections"), create_new_collections: enabled("createNewCollections"),
edit_any_collection: enabled("editAnyCollection"), edit_any_collection: enabled("editAnyCollection"),
delete_any_collection: enabled("deleteAnyCollection"), 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.create_new_collections != membership.create_new_collections
|| self.edit_any_collection != membership.edit_any_collection || self.edit_any_collection != membership.edit_any_collection
|| self.delete_any_collection != membership.delete_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) { fn apply_to(self, membership: &mut Membership) {
@ -1234,6 +1243,9 @@ impl CustomRolePermissions {
membership.create_new_collections = self.create_new_collections; membership.create_new_collections = self.create_new_collections;
membership.edit_any_collection = self.edit_any_collection; membership.edit_any_collection = self.edit_any_collection;
membership.delete_any_collection = self.delete_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 { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); 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(); let data: ImportData = data.into_inner();
// Validate the import before continuing // Validate the import before continuing
@ -3806,7 +3832,7 @@ async fn put_reset_password_enrollment(
// Vaultwarden does not yet support exporting only managed collections! // Vaultwarden does not yet support exporting only managed collections!
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
#[get("/organizations/<org_id>/export")] #[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 { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
@ -4023,6 +4049,9 @@ mod tests {
("createNewCollections".to_owned(), json!(true)), ("createNewCollections".to_owned(), json!(true)),
("editAnyCollection".to_owned(), json!(true)), ("editAnyCollection".to_owned(), json!(true)),
("deleteAnyCollection".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); let custom = CustomRolePermissions::from_request(MembershipType::Custom, &permissions);
@ -4032,6 +4061,9 @@ mod tests {
assert!(custom.create_new_collections); assert!(custom.create_new_collections);
assert!(custom.edit_any_collection); assert!(custom.edit_any_collection);
assert!(custom.delete_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); let user = CustomRolePermissions::from_request(MembershipType::User, &permissions);
assert_eq!(user, CustomRolePermissions::default()); assert_eq!(user, CustomRolePermissions::default());
@ -4052,6 +4084,9 @@ mod tests {
create_new_collections: true, create_new_collections: true,
edit_any_collection: true, edit_any_collection: true,
delete_any_collection: true, delete_any_collection: true,
access_event_logs: true,
access_import_export: true,
access_reports: true,
..CustomRolePermissions::default() ..CustomRolePermissions::default()
}; };
@ -4061,5 +4096,8 @@ mod tests {
assert!(membership.create_new_collections); assert!(membership.create_new_collections);
assert!(membership.edit_any_collection); assert!(membership.edit_any_collection);
assert!(membership.delete_any_collection); assert!(membership.delete_any_collection);
assert!(membership.access_event_logs);
assert!(membership.access_import_export);
assert!(membership.access_reports);
} }
} }

21
src/auth.rs

@ -755,6 +755,14 @@ impl OrgHeaders {
|| self.membership.has_manage_users() || self.membership.has_manage_users()
|| self.membership.has_manage_groups()) || 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/<org_id>"), // org_id is usually the second path param ("/organizations/<org_id>"),
@ -839,6 +847,9 @@ impl<'r> FromRequest<'r> for OrgHeaders {
} }
pub struct AdminHeaders { 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 host: String,
pub device: Device, pub device: Device,
pub user: User, pub user: User,
@ -937,6 +948,16 @@ generate_manage_headers!(
can_manage_users_or_groups, 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" "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/<org_id>/collections/<col_id>"), // 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. // but there could be cases where it is a query value.

62
src/db/models/organization.rs

@ -64,6 +64,9 @@ pub struct Membership {
pub create_new_collections: bool, pub create_new_collections: bool,
pub edit_any_collection: bool, pub edit_any_collection: bool,
pub delete_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)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -283,6 +286,9 @@ impl Membership {
create_new_collections: false, create_new_collections: false,
edit_any_collection: false, edit_any_collection: false,
delete_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 membership_type = self.atype;
let permissions = json!({ let permissions = json!({
"accessEventLogs": false, "accessEventLogs": membership_type == MembershipType::Custom as i32 && self.access_event_logs,
"accessImportExport": false, "accessImportExport": membership_type == MembershipType::Custom as i32 && self.access_import_export,
"accessReports": false, "accessReports": membership_type == MembershipType::Custom as i32 && self.access_reports,
"createNewCollections": membership_type == MembershipType::Custom as i32 && self.create_new_collections, "createNewCollections": membership_type == MembershipType::Custom as i32 && self.create_new_collections,
"editAnyCollection": membership_type == MembershipType::Custom as i32 && self.edit_any_collection, "editAnyCollection": membership_type == MembershipType::Custom as i32 && self.edit_any_collection,
"deleteAnyCollection": membership_type == MembershipType::Custom as i32 && self.delete_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. // all-false defaults and the role itself supplies any elevated capabilities.
let permissions = if membership_type == MembershipType::Custom as i32 { let permissions = if membership_type == MembershipType::Custom as i32 {
json!({ json!({
"accessEventLogs": false, "accessEventLogs": self.access_event_logs,
"accessImportExport": false, "accessImportExport": self.access_import_export,
"accessReports": false, "accessReports": self.access_reports,
"createNewCollections": self.create_new_collections, "createNewCollections": self.create_new_collections,
"editAnyCollection": self.edit_any_collection, "editAnyCollection": self.edit_any_collection,
"deleteAnyCollection": self.delete_any_collection, "deleteAnyCollection": self.delete_any_collection,
@ -847,6 +853,18 @@ impl Membership {
self.has_type(MembershipType::Custom) && self.delete_any_collection 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 /// 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, /// 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. /// 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.create_new_collections = false;
self.edit_any_collection = false; self.edit_any_collection = false;
self.delete_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<Self> { pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
@ -1514,6 +1535,9 @@ mod tests {
member.create_new_collections = true; member.create_new_collections = true;
member.edit_any_collection = true; member.edit_any_collection = true;
member.delete_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(); member.clear_custom_permissions();
@ -1523,5 +1547,31 @@ mod tests {
assert!(!member.create_new_collections); assert!(!member.create_new_collections);
assert!(!member.edit_any_collection); assert!(!member.edit_any_collection);
assert!(!member.delete_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());
} }
} }

3
src/db/schema.rs

@ -248,6 +248,9 @@ table! {
create_new_collections -> Bool, create_new_collections -> Bool,
edit_any_collection -> Bool, edit_any_collection -> Bool,
delete_any_collection -> Bool, delete_any_collection -> Bool,
access_event_logs -> Bool,
access_import_export -> Bool,
access_reports -> Bool,
} }
} }

Loading…
Cancel
Save