Browse Source

Merge Custom access permissions (point 1) into access_all removal + Manager fold (points 2+3)

pull/7397/head
tom27052006 3 weeks ago
parent
commit
cd1a4713e5
  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::{
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/<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 {

46
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,
@ -1189,6 +1189,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 {
@ -1205,6 +1208,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"),
}
}
@ -1223,6 +1229,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) {
@ -1232,6 +1241,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;
}
}
@ -2163,6 +2175,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
@ -3794,7 +3820,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");
}
@ -4006,6 +4032,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);
@ -4015,6 +4044,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());
@ -4035,6 +4067,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()
};
@ -4044,5 +4079,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);
}
}

21
src/auth.rs

@ -757,6 +757,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/<org_id>"),
@ -841,6 +849,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,
@ -939,6 +950,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/<org_id>/collections/<col_id>"),
// but there could be cases where it is a query value.

62
src/db/models/organization.rs

@ -63,6 +63,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)]
@ -284,6 +287,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,
}
}
@ -454,9 +460,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,
@ -621,9 +627,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,
@ -861,6 +867,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.
@ -964,6 +982,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<Self> {
@ -1520,6 +1541,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();
@ -1529,5 +1553,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());
}
}

3
src/db/schema.rs

@ -247,6 +247,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,
}
}

Loading…
Cancel
Save