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>
Completes the Custom role by adding the three remaining Bitwarden
permissions and finishing the role-model cleanup this PR started.
New Custom permissions:
accessEventLogs gates the organization and per-member event log
endpoints (AccessEventLogsHeaders); per-cipher event
reads are scoped to the caller's own organization.
accessImportExport gates GET /organizations/<org>/export and organization
import. The export is scoped to the collections the
caller can actually reach unless they already have full
access, so holding the permission decides *whether* a
member may export, never *what* they may read.
accessReports opens GET /ciphers/organization-details. Vaultwarden has
no server-side reports - clients compute them locally
from that list - so this is by design a full
organization read permission, matching Bitwarden's
CanAccessAllCiphersAsync. Documented at the call site.
Role model:
- Remove the membership-level access_all flag. It is now fully expressed by
the role model: Owners/Admins reach every collection implicitly, a Custom
member does so via edit_any_collection. groups.access_all is a separate,
still-supported feature and is untouched.
- Drop the legacy Manager role. Manager is no longer a variant: it is never
persisted or emitted, and an incoming wire value of 3 is folded onto Custom
for backward compatibility. Stored Manager rows are migrated to Custom.
MembershipType now orders by authorization rank (User < Custom < Admin <
Owner) and fails closed on unknown values.
Migrations (SQLite, PostgreSQL, MySQL/MariaDB):
- Legacy Managers become Custom; a Manager that held access_all keeps the
equivalent create/edit/delete-any-collection grants.
- A Manager whose full access came from an access_all group keeps deriving it
from that group instead of receiving a static copy, so removing the group
still revokes the access.
- A startup preflight refuses to migrate ambiguous legacy state (a plain User
carrying access_all) rather than guess between adding management authority
and removing vault access.
- The down migration chain deliberately blocks an automatic downgrade: nine
independent permissions cannot be represented by the old role/access_all
schema without silent data loss.
Resolved the organizations.rs conflict in favour of this branch, which
supersedes the earlier merge resolution.
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.
Brings the branch up to date with upstream main (5040bcb7, a6a88e79,
46ae59ea).
Conflict resolutions, all in src/api/core/organizations.rs:
* post_bulk_access_collections: kept this branch's up-front validation of
the whole request (groups, users and collections are checked before any
assignment is deleted), using upstream's single-query group check.
* send_invite / edit_member: kept this branch's permission gating around
group assignment. Upstream's new "group belongs to this organization"
check is already covered here - by InviteData::validate for the invite
path and by the explicit pre-validation loop for edit_member.
* post_org_import: took upstream's rewrite, which authorizes pre-existing
collections with Collection::is_writable_by_user instead of
can_access_collection. This closes an issue a security review of this
branch reported as F8: can_access_collection is satisfied by a
read-only assignment, so a member with import permission and read-only
access to a shared collection could plant ciphers in it. This branch's
relationship-index pre-validation is kept on top.
* get_groups_data: adopted upstream's authorization gate. The plain group
list now needs full organization access or a manageable collection; the
details view needs full access. Extended for the custom-role model:
the 'Manage Users'/'Manage Groups' permissions also satisfy the check,
since they are what the route guards require for the details view and a
member holding them may reach no collection of their own.
To make the membership available to that check, the permission-guard
macro in auth.rs now carries the caller's Membership record.
Verified: cargo check, cargo clippy --features sqlite (both clean),
cargo test --features sqlite (47 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.
`groups` is a reserved keyword in MySQL 8, so the unquoted `INNER JOIN groups`
in the second UPDATE of the MySQL variant of
2026-07-16-120000_add_custom_collection_permissions/up.sql fails with
ERROR 1064 (syntax error), aborting the migration and preventing the server
from starting on MySQL/MariaDB. Backtick the table references, matching the
existing 2022-07-27-110000_add_group_support migration. PostgreSQL and SQLite
do not reserve the word and are unchanged.
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.
Before this PR every member created with the Custom role was stored as
Manager (3) and masqueraded as Custom (4) in API responses. With the
masquerade removed, those members would suddenly surface as type 3,
which current clients no longer support and the admin panel cannot
render. Convert them to the now-persisted Custom type; access_all is
preserved and the new manage_* flags stay false, matching the exact
capabilities and appearance these members had before.
The down migration converts Custom members back to Manager so older
server versions (which cannot load type 4) keep working after a
rollback.
* 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>
* ci: remove dead BASE_TAGS reference in release bake step
steps.determine-version doesn't exist in docker-build; the expression
resolves to empty string. The HCL default (testing) would have
applied, but it's moot - the bake uses push-by-digest=true so tags are
only set in merge-manifests. Dead code.
* ci: replace unsecured curl hadolint download with an official action
hadolint/hadolint-action uses a Docker-based runner with hadolint
pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian,so no binary
downloaded at runtime. Pinning the action to a commit SHA covers the
Dockerfile that specifies the image version, closing the supply-chain
gap from the previous unverified curl | sudo install.
Split {debian,alpine}: the action takes a single dockerfile argument,
so debian and alpine are linted separately.
* ci: pin ubuntu-latest to ubuntu-24.04 in merge-manifests and zizmor
ubuntu-latest is a moving target that can silently change the runner OS
on the next GitHub-side update. All other jobs in this repo already pin
to ubuntu-24.04; this makes merge-manifests and zizmor consistent.
* ci: return BASE_TAGS - it's needed for bake step
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.