The bundled web vault (2026.6.4) requires seven query parameters in the
accept-organization URL and rejects the invite client-side when any of them is
null, showing only "Unable to accept invitation" without sending a request to
the server.
send_invite() never appended initOrganization, and appended
orgUserHasExistingUser only for users who already had an account, so every
organization invitation e-mail produced a link that could not be accepted.
Web vault 2026.4.1 (shipped with 1.36.0) read these parameters null-safely,
which is why this only appeared in 1.37.0.
Fixes#7481
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Clippy (with --all-targets) reported two issues introduced by this branch:
events.rs:356 items_after_test_module
events.rs:395 redundant_clone
Move the test module to the end of the file and drop the redundant clone.
The relocation is content-identical; the only semantic change is the removed
`org_id.clone()`, which was the last use of the binding. Note that CI runs
clippy without --all-targets, so neither of these was failing the pipeline.
Remove `AccessReportsHeaders` and its `can_access_reports` helper. The guard
was never applied to any route and could not be: Vaultwarden has no
server-side report endpoints, because the clients compute every report
locally from the organization cipher list. `accessReports` is therefore
enforced inline in `get_org_details`, where that list is actually served. A
note at both former sites records this, so the guard is not reintroduced and
an endpoint is not gated on "may call reports" instead of "may read these
ciphers".
Document the resulting authorization semantics above `get_org_details`:
the endpoint serializes with CipherSyncType::Organization, which skips the
per-cipher access restrictions, so readOnly/hidePasswords are not applied and
collection assignments are ignored. `accessReports` is by design a full
organization read permission. This matches Bitwarden's CanAccessAllCiphersAsync,
which admits Owner/Admin plus Custom members holding AccessImportExport,
EditAnyCollection or AccessReports. We stay deliberately stricter than
Bitwarden for accessImportExport: it does not open this endpoint, and
get_org_export scopes its output to the caller's own collections, so
"may export" never widens what a member can read.
No behavioural change.
A security review of this branch (finding F1) pointed out that the
'Access Import/Export' permission, which this branch added and which
opens GET /organizations/<id>/export via AccessImportExportHeaders,
decided *whether* a member may export but not *what* they get: the
handler always dumped Cipher::find_by_org(), and CipherSyncType::
Organization deliberately skips the per-cipher access restrictions.
A confirmed Custom member holding only accessImportExport - assigned to
no collection at all - therefore received every cipher of the
organization, including collections they were explicitly excluded from.
Every confirmed member holds the organization key, so the exported blobs
are decryptable by the caller.
The export is now built from the caller's own assignments unless they
already reach every collection anyway (Admin/Owner, or a Custom member
with 'Edit any collection'), which is what Bitwarden's export controller
does. Group-based access is covered: both scoped queries honour group
assignments and group access_all.
* new helper may_export_entire_organization() names the decision and
keeps it unit-testable
* get_org_details_impl() split into ciphers_to_org_json() so the export
can serialize an already-authorized cipher list; the serializer's doc
comment states that requirement
* new regression test access_import_export_alone_does_not_widen_the_export
Verified: cargo check, cargo clippy --features sqlite (clean),
cargo test --features sqlite (48 passed), cargo fmt --all -- --check.
- Updated API response to more closely match v2026.6.0+ server versions.
- Updated all the crates
- Updated Rust to v1.97.1
- Updated the web-vault to v2026.6.4
- Updated GitHub Actions
Signed-off-by: BlackDex <black.dex@gmail.com>
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).
The per-membership `access_all` flag was Vaultwarden's pre-permissions patch for
"this member reaches every collection". It is now fully represented by the role
model: Owners/Admins hold it implicitly, and a Custom member holds it via
`edit_any_collection`. Every authorization query that read
`users_organizations.access_all` now reads `edit_any_collection = true OR
atype <= Admin` instead, which is exactly the set the flag ever identified, so
admin/owner and edit-any access is preserved. The column is dropped via a new
migration (down-migration restores it from the role/permission model).
`groups.access_all` is a separate, still-supported group feature and is untouched.
The legacy Manager role (wire value 3) is folded into Custom: the
`MembershipType::Manager` variant is removed, all `>= Manager` rank checks become
`>= Custom` (identical authorization rank), the `LegacyManager` collection-access
path collapses into the Custom rules, and an incoming wire `type=3` is mapped onto
Custom for backward compatibility. Custom stays `type=4` because that is the only
role modern Bitwarden clients render with custom permissions. Existing type-3
members were already converted to Custom by the earlier migration.
Server-only change; verified against the unmodified web-vault. Unit tests, clippy
and rustfmt pass; migration up/down verified against SQLite.
A per-collection `manage` row carries collection delete authority via
`has_explicit_collection_manage_access` -> `CollectionDeleteHeaders`, so
only a caller who could delete the collection may confer it. Two write
paths still passed the client-supplied `manage` bit through unguarded:
- `send_invite` gated the initial collection assignments only on
`has_full_access()`, which `edit_any_collection` alone satisfies, so a
Custom member with manage-users and edit-any-collection could plant a
manage row on any collection for an account they control.
- `post_organization_collections` wrote `group.manage` / `user.manage`
directly. With the create guard narrowed to `can_create_new_collections()`,
a create-only Custom member could grant manage on the new collection to
itself, another member, or a group.
Both now AND the requested bit with `caller_may_grant_collection_manage`,
the same gate `post_bulk_access_collections`,
`post_organization_collection_update`, `edit_member` and `add_update_group`
already use. In the create handler the gate is evaluated once, before both
assignment loops, so no grant can bootstrap the next. The check is strictly
subtractive: Admin/Owner, Custom-with-delete-any-collection and the
access_all Manager are unaffected.
post_bulk_access_collections authorized each requested collection via the
legacy Collection::is_manageable_by_user helper, which also accepts a member's
membership/group access_all. For a Custom member this diverged from the
single-collection edit endpoint (ManagerHeaders -> collection_edit_access),
which requires a real per-collection Manage grant and never treats a Custom
member's access_all as one. A flagless Custom member placed in an access_all
group could therefore rewrite collection user/group assignments in bulk while
the single-collection edit endpoint denied the exact same change.
Add auth::can_edit_collection - the collection_edit_access + can_manage_collection
pair the ManagerHeaders guard already uses - and call it per collection in the
bulk-access endpoint. Legacy Managers, Admins/Owners and Edit-any-collection
members are unaffected; only a Custom member's access_all shortcut is removed,
so bulk-access now enforces exactly what the single-collection edit endpoint does.
A Custom member holding only `edit_any_collection` (whose `access_all` mirror
makes every collection "manageable") could grant a per-collection `manage` row
to a group it belongs to -- via post_bulk_access_collections,
post_organization_collection_update, add_update_group or edit_member -- and then
delete that collection through CollectionDeleteHeaders'
`has_explicit_collection_manage_access` path, despite holding no
`delete_any_collection` permission. This defeated the intended edit != delete
separation (confirmed end-to-end: the delete returned 200 before this change).
Gate every caller-controlled collection-assignment `manage` write behind a new
`caller_may_grant_collection_manage()` check that mirrors the delete
authorization exactly: a caller may confer `manage` (which carries delete
authority) on a collection only if it could delete that collection itself --
Admin/Owner or `delete_any_collection` always; an exact legacy Manager via its
per-collection manage helper; any other Custom member only with a real persisted
`users_collections.manage` / `collections_groups.manage` grant.
`edit_any_collection`'s `access_all` mirror deliberately does not count.
The change is strictly subtractive: it can only ever downgrade a requested
`manage` to false, never grant it, so it opens no new access and leaves
Admins/Owners and genuinely delete-capable members unaffected. The create,
organization-import and invite paths are intentionally left alone (new-collection
creators must manage their own collection; non-owner invites can only create
plain Users, which cannot delete).
Adds a unit test for the collection-independent part of the gate.
Follow-up hardening on the custom-role work, found during a static review:
- Admin panel type change (apply_membership_type_change): when converting a
Custom member to the legacy Manager role, only preserve `access_all` if the
member actually held the full "manage all collections" grant (all three
collection flags). Previously an Edit-any-collection-only Custom member
(whose access_all is just the Edit mirror) became a Manager with a broad
access_all grant, silently escalating Edit-only into Create + Edit + Delete.
Mirrors the collection-permissions down-migration. Unit test updated.
- Bulk collection access (post_bulk_access_collections): drop the blanket
has_full_access() requirement and rely on the existing per-collection
is_manageable_by_user check (as the single-collection endpoint and the
pre-existing behavior do). Pure manage_users / manage_groups / manage_policies
Custom members hold no per-collection manage grant and are still rejected,
while a Manager/Custom member who manages some collections regains the ability
to bulk-edit exactly those collections.
- Group details (GET /organizations/<org>/groups/<id>/details): align the guard
with the list endpoint (/groups/details) to ManageUsersOrGroups, so a
manage_users member is not denied the single-group view of the same data it
can already read in bulk.
The custom-role migration converts every legacy Manager (atype 3) into a
Custom member (atype 4) with all collection flags false. collection_delete_access
only granted the per-collection (ManagedOnly) path to atype == Manager, so those
migrated members silently lost the ability to delete collections they still
manage via an explicit users_collections.manage / collections_groups.manage grant
(audit finding F-1).
Grant ManagedOnly to confirmed Custom members as well, but only when they hold
neither edit_any_collection nor access_all, so Edit any collection (which is
mirrored onto access_all) can never become a blanket delete. The downstream
is_coll_manageable_by_user check still requires a real per-collection Manage
grant, so a flagless Custom member without any assignment gains nothing; a
blanket "delete any collection" still requires the explicit delete_any_collection
permission handled by the Any branch.
Adds regression test migrated_legacy_manager_retains_managed_collection_delete.
Addresses the confirmed High/Medium findings of the granular-collection-
permissions security audit:
- H-1: bind direct (non-sync) cipher access to a confirmed membership in the
cipher's organization, and harden the user/group collection access-flag
queries to require org consistency plus confirmed status. Revoked/invited
members can no longer reach ciphers via stale assignment rows.
- H-2: validate group ids against the org before mutating group membership in
edit_member, and reject cross-org group<->membership links in GroupUser::save
and Group::is_in_full_access_group.
- H-3: fully pre-validate collections/groups/users in bulk-access and collection
create before any mutation, and reject cross-org collection<->group links in
CollectionGroup::save.
- M-1: require Manage Users for the full member list and Manage Users/Groups for
group details (new ManageUsersOrGroupsHeaders guard).
- M-2: validate the whole bulk-access request before the destructive
delete/replace so an invalid element can't leave partial state behind.
- M-3: bounds-validate import collection relationships before writing and
propagate cipher-save errors instead of discarding them.
A Custom member with the manage_users permission reaches edit_member via
ManageUsersHeaders. Every mutated field there (access_all, the manage_* flags,
collection and group assignments) is gated behind an Admin/collection-management
check -- except the role type itself, which was written unconditionally aside
from the existing Admin/Owner-elevation guard.
Because collection "manage" rights are role-derived (`atype >= Manager` grants
manage on any collection the member can write), a manage_users caller with no
collection access could promote a plain User to Manager/Custom to grant them
collection administration (rename/delete/re-share), or demote to revoke it --
a separation-of-duties break between the user-management and data planes.
Add a may_change_member_type() guard so callers below Admin may only submit an
unchanged role (keeping the regular edit dialog working), and cover it with a
regression unit test.
The manage flag in collection JSON was gated on atype == Manager, a raw
comparison that now excludes Custom (4). A Custom member holding an
explicit per-collection manage assignment (or full read/write access)
would have lost the manage capability in /sync and the collection
details endpoints - before this PR they were stored as Manager and
matched. Compare by access level (>= Manager, which Manager and Custom
share) instead, restoring the exact pre-PR behavior for migrated
members. Admins/Owners are unaffected: they are caught by the earlier
has_full_access / >= Admin arms.
The admin panel only knew types 0/1/2 plus the masqueraded "4":
Manager mapping. A Custom member would have been shown as "Manager"
with the Manager radio (value 3) preselected in the role dialog, so
saving it silently converted the member to Manager and wiped their
manage_* flags. Any remaining legacy Manager member (type 3) would
have thrown a TypeError and broken the dialog entirely.
Add a proper Custom (4) entry and radio button, and keep a Manager (3)
entry for members created through older clients.
* Misc updates and fixes
- Updated Rust to v1.96.1
- Updated all the crates
- Updated GitHub Actions
- Updated the web-vault to v2026.6.2
- Updated Alpine to v3.24
- Fixed several clippy lints
- The `send` UUID wrappers didn't need the special namespace anymore since an updated crate, so removed this extra mod.
Signed-off-by: BlackDex <black.dex@gmail.com>
* Update MSRV to v1.94.1
Signed-off-by: BlackDex <black.dex@gmail.com>
---------
Signed-off-by: BlackDex <black.dex@gmail.com>
A Custom member with only the manage_groups permission could revoke other
members' collection access via two endpoints that were missing the
collection-access check enforced elsewhere (put_group_members, edit_member):
- POST /organizations/<org>/groups/<group>/delete-user/<member>
(post_delete_group_member) removed a member from any group, including
collection-bearing ones.
- DELETE /organizations/<org>/groups/<group> and its bulk variant
(delete_group_impl / bulk_delete_groups) deleted collection-bearing groups
outright, revoking access for all their members.
Neither path can grant access, so confidentiality was never at risk, but both
let a manage_groups-only user tamper with other members' collection access,
contradicting the permission's invariant. Both now require Admin/Owner or full
collection access before touching a group that confers collection access (via
access_all or assigned collections).
send_invite computed access_all from the invite permissions but never
wrote the manage_users / manage_groups / manage_policies flags onto the
new membership, so a Custom member invited with a management permission
checked was created without it (the web-vault sends everything in a
single invite POST, with no follow-up edit). Mirror the handling in
edit_member and persist the flags at invite time.
Only Owners can invite Custom members, so the caller is always
authorized to grant these; the flags are gated on the Custom type and
forced false for every other type.
A Custom member holding manage_users + manage_groups (but without collection
access) could add a member -- including themselves via edit_member, or an
attacker-controlled invitee via send_invite -- to an access_all or
collection-bearing group, and thereby indirectly gain read access to those
collections' cipher contents. This bypassed the collection-access boundary
already enforced in put_group_members and add_update_group.
Gate both member-centric group-assignment paths on collection-management
rights via a shared `may_change_group_membership` predicate: callers who
cannot manage collections may only add/remove membership of groups that
confer no collection access, and collection-bearing memberships are left
untouched (neither granted nor revoked). Admins/Owners and full-access
callers keep the previous full-replace behaviour.
Add a regression unit test for the predicate.
Tighten two custom-role read paths that were broader than intended:
- get_group, get_group_details and get_group_members only required
ManagerHeadersLoose, exposing group metadata, collection mappings and
membership to any confirmed Manager/Custom member. Require
ManageGroupsHeaders (Admin/Owner or manage_groups) instead.
- list_policies returned the full policy configuration to any manage_*
member. Keep the endpoint reachable so the Admin Console still loads,
but return an empty list to callers without manage_policies.
Security fixes and hardening following a review of the custom-role
permissions feature:
- Gate the manage_* flags on the Custom membership type via new
Membership::has_manage_* helpers. Previously a stale flag (e.g. left
over after changing a member's type through the admin panel, which
does not go through edit_member) would keep granting management
permissions to a member of any type.
- Clear the manage_* flags in admin::update_membership_type when a
member is changed away from the Custom type.
- edit_member: reject any *change* to the manage_* flags by non-admin
callers. This keeps the previous protection against granting flags,
and additionally prevents a manage_users member from silently
stripping flags an Admin/Owner granted to fellow Custom members.
Unchanged flags still pass, so the regular edit dialog keeps working.
- list_policies: restrict the full policy list to Admins/Owners and
Custom members holding at least one management permission. The
previous loosening to ManagerHeadersLoose also exposed all policies
(including their configuration data) to plain Managers.
- Collection list metadata (get_org_collections{,_details}) is now only
readable with manage_users or manage_groups; manage_policies does not
need the collection list.
- find_confirmed_and_manage_all_by_org: include Custom members with
access_all, matching the pre-existing behaviour for Managers.
- Fix the partial_cmp_MembershipType unit test (Custom is no longer
converted to Manager) and document that Manager and Custom share an
access level in Ord while remaining distinct roles under PartialEq.
- Replace the remaining stale "HACK" comments and the raw "4" type
comparison in send_invite; drop unrelated .gitignore entries.
The /api/sync response serialized a Send hide_email field directly from
Option<bool>, so a NULL value in the sends table (the column is
Nullable<Bool> with no default) produced "hideEmail": null.
The Bitwarden Android client deserializes SyncResponseJson.Send.hideEmail
as a non-null Kotlin Boolean and aborts the entire sync with a
JsonDecodingException when it encounters null. Web, desktop and CLI
clients coerce null to false, so only accounts with at least one Send
are affected and only on Android.
Default None to false at the serialization boundary, matching the
official Bitwarden server where hideEmail is non-nullable. This needs no
database migration and fixes both legacy NULL rows and any future NULLs.
The hide_email field stays Option<bool> internally.
A Custom member with only manage_users could set the "manage all
collections" child permissions (createNewCollections / editAnyCollection /
deleteAnyCollection) on any member, including themselves, via
POST /organizations/<org_id>/users/<member_id>. edit_member wrote
member_to_edit.access_all unconditionally, so the resulting access_all=true
granted full access to every collection's contents - defeating the
"manage users without collection access" guarantee.
Gate the access_all change on caller_can_manage_collections (Admins/Owners
or full-access members), mirroring how put_group preserves a group's
access_all for callers without collection rights. For everyone else the
member's existing access_all is left untouched. The collection- and
group-assignment paths were already gated; this closes the remaining
direct path.
- Updated Rust to v1.96.0
- Updated all the crates, and adjusted code where needed
- Fixed some nightly reported clippy lints
- Updated all the GitHub actions
Signed-off-by: BlackDex <black.dex@gmail.com>