Compare commits

..
Author SHA1 Message Date
Daniel García b66777b330 Speed up the Build workflow
- Pass the MariaDB client library to mysqlclient-sys through its environment variables, so a cached build no longer recompiles mysqlclient-sys, diesel and diesel_migrations in every MySQL build. The cache prefix changes once, because the old caches would keep rebuilding it.
- Drop the sqlite,mysql,postgresql,enable_mimalloc test: enable_mimalloc is still tested on and off.
- Only save caches on main, so pull requests and branches no longer push main's caches out of the 10 GB limit.
2026-11-13 22:08:57 +01:00
StarHzjandDaniel García 258b7f0254 Add macOS native credential sync to supported feature flags (#7942)
* Add macOS native credential sync to supported feature flags

* Document the macOS native credential sync flag in .env.template

* Align the macOS passkey provider description with Windows

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-11-13 20:38:00 +01:00
Tom a8de8c7820 [PR 3] Add collection permissions for Custom Roles (#7951)
* Add Custom Roles collection permissions

* Return NotFound for denied collection access

* Grant Manage on imported collections to every Custom member
2026-11-13 20:24:59 +01:00
16 changed files with 818 additions and 413 deletions

No files matched your search

+1
View File
@@ -411,6 +411,7 @@
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0) ## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers. ## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet. ## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
## - "macos-native-credential-sync": Enable the desktop app as a passkey provider on macOS, so passkeys in the vault can be used by other apps and browsers.
## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1) ## - "pm-27278-v2-password-registration": Register new accounts with v2 encryption. (Web >= 2026.6.4, Desktop/Browser/CLI >= 2026.7.0, Android >= 2026.4.1, iOS >= 2026.5.1)
## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0) ## - "enable-account-encryption-v2-jit-password-registration": Give SSO users who set their master password on their first login v2 encryption. (Web >= 2026.1.1, Desktop/Browser/CLI >= 2026.1.0, Android >= 2026.4.1, iOS >= 2026.6.0)
## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one. ## These two flags can't be undone: a v2 account stays v2, and it can only log in from clients that are 2026.10.1 or newer (2026.10.2 for the desktop app), so the versions above are only enough to create one.
-1
View File
@@ -4,4 +4,3 @@
/.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex /.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex
/.github/workflows/** @dani-garcia @BlackDex /.github/workflows/** @dani-garcia @BlackDex
/SECURITY.md @dani-garcia @BlackDex /SECURITY.md @dani-garcia @BlackDex
/THREAT_MODEL.md @dani-garcia @BlackDex
+24 -10
View File
@@ -57,7 +57,25 @@ jobs:
steps: steps:
# Install dependencies # Install dependencies
- name: "Install dependencies Ubuntu" - name: "Install dependencies Ubuntu"
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config run: |
# Separate commands, so a failing update fails the step. The image already has Oracle's MySQL client
# library, so a skipped install would otherwise go unnoticed and build against that one.
sudo apt-get update
sudo apt-get install -y --no-install-recommends openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config
# Pass the MariaDB client library to mysqlclient-sys through its environment variables.
# When it finds the library with pkg-config, its build script makes cargo rerun it whenever
# anything in the library directory changes, and the install above changes that directory on
# every run. So every MySQL build recompiled mysqlclient-sys, diesel and diesel_migrations,
# even with a cached build. Cargo compares environment variables by their value instead.
lib_dir="$(pkg-config --variable=libdir mysqlclient)"
version="$(pkg-config --modversion mysqlclient)"
{
echo "MYSQLCLIENT_NO_PKG_CONFIG=1"
echo "LIBMARIADB_NO_PKG_CONFIG=1"
echo "MYSQLCLIENT_LIB_DIR=${lib_dir}"
echo "MYSQLCLIENT_VERSION=${version}"
} | tee -a "${GITHUB_ENV}"
# End Install dependencies # End Install dependencies
# Checkout the repo # Checkout the repo
@@ -118,23 +136,21 @@ jobs:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example. # Like changing the build host from Ubuntu 20.04 to 22.04 for example.
# Only update when really needed! Use a <year>.<month>[.<inc>] format. # Only update when really needed! Use a <year>.<month>[.<inc>] format.
prefix-key: "v2025.09-rust" prefix-key: "v2026.11-rust"
# Only main saves caches. The repository can keep 10 GB of caches, and the ones saved by pull
# requests and other branches were mostly copies of main's that pushed other caches out.
save-if: ${{ github.ref == 'refs/heads/main' }}
# End Enable Rust Caching # End Enable Rust Caching
# Run cargo tests # Run cargo tests
# First test all features together, afterwards test them separately. # First test all features together, afterwards test them separately.
# enable_mimalloc only selects the allocator, so it is tested on and off, not with every combination.
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3" - name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3"
id: test_sqlite_mysql_postgresql_mimalloc_s3 id: test_sqlite_mysql_postgresql_mimalloc_s3
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
run: | run: |
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3 cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
id: test_sqlite_mysql_postgresql_mimalloc
if: ${{ !cancelled() }}
run: |
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc
- name: "test features: sqlite,mysql,postgresql" - name: "test features: sqlite,mysql,postgresql"
id: test_sqlite_mysql_postgresql id: test_sqlite_mysql_postgresql
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
@@ -198,7 +214,6 @@ jobs:
if: ${{ failure() }} if: ${{ failure() }}
env: env:
TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }} TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }}
TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }} TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
TEST_SQLITE: ${{ steps.test_sqlite.outcome }} TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
TEST_MYSQL: ${{ steps.test_mysql.outcome }} TEST_MYSQL: ${{ steps.test_mysql.outcome }}
@@ -213,7 +228,6 @@ jobs:
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}" echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}" echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
+35 -24
View File
@@ -1,38 +1,49 @@
Vaultwarden tries to prevent security issues, but something could always slip through. If you believe you've found a security issue in our application, we encourage you to notify us. We welcome working with you to resolve the issue promptly. Thanks in advance! Vaultwarden tries to prevent security issues but there could always slip something through.
If you believe you've found a security issue in our application, we encourage you to
notify us. We welcome working with you to resolve the issue promptly. Thanks in advance!
# Scope # Disclosure Policy
Security issues in the current release of Vaultwarden and in the `main` branch (the `testing` image) are in scope. Our [threat model](THREAT_MODEL.md) describes who is trusted with what, what is out of scope, and how we rate severity. Please read it before reporting. - Let us know as soon as possible upon discovery of a potential security issue, and we'll make every
effort to quickly resolve the issue.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
third-party. We may publicly disclose the issue before resolving it, if appropriate.
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
degradation of our service. Only interact with accounts you own or with explicit permission of the
account holder.
# How to report a security issue # In-scope
- Let us know as soon as possible upon discovery of a potential security issue, and we'll make every effort to quickly resolve the issue. - Security issues in any current release of Vaultwarden. Source code is available at https://github.com/dani-garcia/vaultwarden. This includes the current `latest` release and `main / testing` release.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a third party. We may publicly disclose the issue before resolving it, if appropriate.
- Credit goes on the advisory and needs a GitHub account or email address. Reports of an issue whose fix is already public, from the moment its pull request is opened, are normally not credited.
- Vaultwarden is an open source project and does not offer rewards.
Vaultwarden has no hosted service. Test only on instances you run yourself, and make a good-faith effort to avoid privacy violations, destruction of data, and interruption or degradation of other people's servers. Only interact with accounts you own or with the explicit permission of the account holder. # Exclusions
The following bug classes are out-of scope:
- Bugs that are already reported on Vaultwarden's issue tracker (https://github.com/dani-garcia/vaultwarden/issues)
- Bugs that are not part of Vaultwarden, like on the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated
- Issues in an upstream software dependency (ex: Rust, or External Libraries) which are already reported to the upstream maintainer
- Attacks requiring physical access to a user's device
- Issues related to software or protocols not under Vaultwarden's control
- Vulnerabilities in outdated versions of Vaultwarden
- Missing security best practices that do not directly lead to a vulnerability (You may still report them as a normal issue)
- Issues that do not have any impact on the general public
While researching, we'd like to ask you to refrain from: While researching, we'd like to ask you to refrain from:
- Denial of service against servers you do not run. You can still report availability issues you reproduce on your own instance. - Denial of service
- Spamming. - Spamming
- Social engineering (including phishing) of Vaultwarden developers, contributors or users. - Social engineering (including phishing) of Vaultwarden developers, contributors or users
A good report covers one root cause and states the version or commit you tested, the lowest privileges needed, what the attacker gains, any non-default settings, the steps to reproduce it, and whether you ran them or only read the code. Thank you for helping keep Vaultwarden and our users safe!
If you use AI tools, you remain responsible for what you send. Reproduce the issue on your own instance before reporting it, check its scope and severity against our threat model rather than the tool's assessment, and include the requests, responses or logs that show it.
# How to contact us # How to contact us
- You can open a [private vulnerability report](https://github.com/dani-garcia/vaultwarden/security/advisories/new) on GitHub. - You can contact us on Matrix https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`)
- You can send an ![security-contact](/.github/security-contact.gif) to report a security issue.<br> - You can send an ![security-contact](/.github/security-contact.gif) to report a security issue.<br>
If you want to send an encrypted email, you can use the following GPG key: `13BB3A34C9E380258CE43D595CB150B31F6426BC`<br> If you want to send an encrypted email you can use the following GPG key: 13BB3A34C9E380258CE43D595CB150B31F6426BC<br>
It can be found on several public GPG key servers.<br> It can be found on several public GPG key servers.<br>
- https://keys.openpgp.org/search?q=security%40vaultwarden.org * https://keys.openpgp.org/search?q=security%40vaultwarden.org
- https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org * https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org
- https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index * https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
- https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index * https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
- You can contact us on Matrix for questions, without vulnerability details: https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`).
Thank you for helping keep Vaultwarden and our users safe!
-148
View File
@@ -1,148 +0,0 @@
# Vaultwarden threat model
To report a security issue, see [SECURITY.md](SECURITY.md).
## Overview
Vaultwarden is an unofficial, self-hosted server for the Bitwarden clients, written in Rust. It stores encrypted vaults, handles login, two-factor authentication, organizations, file and text sharing through Send, and emergency access, and provides an admin panel for the operator. It is not affiliated with Bitwarden. The browser, desktop, mobile and CLI clients come from Bitwarden. Our container images include a web vault built from Bitwarden's web client with our own patches.
## Security expectations
### Deployment
We expect a TLS-terminating reverse proxy in front of the server, `DOMAIN` set to its public URL, and an operator who keeps the host secure. This model covers Vaultwarden itself. Third-party software that consumes its output or sits in front of it, such as log parsers and reverse proxies, is the operator's to configure and secure.
### Encryption
Clients encrypt vault contents end to end. The server stores and serves ciphertext it cannot decrypt, and it never sees the master password. The server enforces access to data and actions itself, without relying on a client to behave. A few settings rely on the client for part of their effect, listed under [Out of scope](#out-of-scope).
A member receives the organization key when they are confirmed. Invited and accepted members do not have it. A revoked member who was once confirmed may still have it. Every confirmed member holds the same key for the whole organization, so only the server separates collections.
The server can read everything that is not end-to-end encrypted, such as user, group, device and organization names, email addresses, who owns what, item types and timestamps, attachment sizes, password hints, the stored master password hash and KDF settings, the TOTP and other 2FA secrets that protect login to the account, API keys, refresh tokens, security stamps, the JWT signing key, and SMTP and push credentials. TOTP secrets that users store in vault items are end-to-end encrypted with the rest of the item.
Someone who controls the server can also serve a modified web vault, so end-to-end encryption does not protect web vault users from them.
### Trust
Registration is open by default (`SIGNUPS_ALLOWED=true`), but many personal and small-team instances turn it off, so holding an account usually implies a minimal level of trust. Account holders are still never trusted with anyone else's data. Members of an organization have some trust in each other.
| Actor | Can reach | Must not reach |
|---|---|---|
| Anonymous internet | Login, prelogin, registration, public Send access, icons, the anonymous notifications hub, static files | Any vault, organization or member data; Sends they have no link to; the admin panel; the internal network through outbound requests |
| Websites and icon sources | The icon fetcher, through any response, redirect or DNS answer; the server, through cross-site requests from a visitor's browser | The internal network through outbound requests; script on the vault origin; acting through a visitor's session |
| Registered user | Their own vault, Sends, devices and settings; organizations they belong to | Another user's data; any other organization; raising their own role or status |
| Invited or accepted member | Accepting the invitation, enrolling in account recovery, and the organization's name, public key and policies | Items, collections, the member list, the organization key, any other privileged action before confirmation |
| Revoked member | Nothing in that organization | Any organization data from the server, even data they could once read |
| Organization User | Items in assigned collections, with `read_only` and `manage` enforced by the server | Other collections, write access to read-only collections, Admin-level metadata |
| Organization Manager (a legacy role, shown as Custom in clients) | The above, plus managing collections where they have `manage`. With "Manage all collections" (`access_all`), every collection and full member and group details | Without `access_all`: managing other collections, full member and group details. Always: changing policies or roles |
| Organization Admin | All data in that organization, members below Owner (some actions on Managers and Admins are limited to Owners), policies, events, account recovery for enrolled members below Owner | Owners, other organizations, the personal vault of a member who is not enrolled in recovery, the organization API key |
| Organization Owner | The whole organization, including account recovery for enrolled members | Other organizations, the personal vault of a member who is not enrolled in recovery |
| Organization API key holder (Owner-issued) | Directory import into its own organization | Other organizations, item content, Owners |
| Send recipient | One Send, within its limits | Other Sends, access beyond the configured limits, the owner's vault |
| Emergency access grantee | After approval or the wait time: the access the grantor configured, which can be full takeover by design | Anything before that, or more than the access the grantor configured |
| Admin panel (`ADMIN_TOKEN`) | All users, organizations and settings | No limit once authenticated |
| Host operator, database and data folder access | Everything | No limit |
| Services the operator configures (SSO provider, SMTP server, push relay, S3 endpoint, Yubico) | What they need for their function | Vault contents |
### Configuration
Optional features that operators turn on, other than experimental and beta ones, carry the same expectations as the defaults, such as SSO, organization events, anything needing SMTP (email 2FA, invitations), push, S3 storage, the admin panel, and the MySQL and PostgreSQL backends.
Some settings remove a protection on purpose, and using them is the operator's choice: `DISABLE_ADMIN_TOKEN`, a plain-text `ADMIN_TOKEN`, `IP_HEADER_TRUSTED_PROXIES=all` on a directly exposed instance, turning off `HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS`, `SSO_DEBUG_TOKENS`, `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`, `SSO_PKCE=false`, `SHOW_PASSWORD_HINT`, `SMTP_SECURITY=off`, `SMTP_ACCEPT_INVALID_CERTS`, `SMTP_ACCEPT_INVALID_HOSTNAMES`, `SMTP_DEBUG`, `ALLOWED_IFRAME_ANCESTORS`, `ALLOWED_CONNECT_SRC`, and `LOG_LEVEL` set to `debug` or `trace`. Issues that need one of them are out of scope.
### Upstream Bitwarden
Where this document does not cover a behavior, we follow upstream Bitwarden by default.
## Out of scope
These are intended behavior or outside this model. Several match upstream Bitwarden.
- What an admin token holder or the host operator can do, including running code on the server. We expect them to have admin access to the machine.
- Organization Owners and Admins reading all organization data, and account recovery of enrolled members.
- Organization Managers listing basic member details, such as names and emails. Upstream allows this for all members.
- Settings the client applies: `hide_passwords` ("hidden passwords" in the clients), master password reprompt, and organization policies on values the server never sees, such as master password complexity and password generator rules. The client already holds the data.
- Bugs in Bitwarden's client code, including in the web vault we bundle, such as CSV formula injection (exports are built in the browser) or a client failing on a malformed stored value. The advisory and CVE for those belong to Bitwarden. Report them through [Bitwarden's HackerOne program](https://hackerone.com/bitwarden/). When the bundled web vault is affected, we ship a security release that references theirs. Bugs that our own web vault patches add are ours.
- The server not decrypting or authenticating ciphertext. It holds no key.
- `/identity/accounts/prelogin` and `/identity/accounts/prelogin/password` returning KDF settings for any email, with deterministic fake settings for unknown ones.
- Login timing not being equalized.
- Probing whether an icon is already cached. Setting `ICON_SERVICE` to an external service avoids it.
- Download link replay: an attachment token or Send access token being reusable by its holder until it expires (1 and 2 minutes), and object storage presigned URLs that we cannot count or revoke. The holder already has the content.
- Short-lived tokens that stay valid after an authorization change elsewhere until they expire, such as an organization API token (1 hour) after its key is rotated. This includes connections the notifications WebSocket accepted before the change, which stay open and carry change notifications and device login requests, not vault data.
- The access token in the notifications WebSocket's `access_token` query parameter, which reverse proxies may log. Clients send it that way, and our [hardening guide](https://github.com/dani-garcia/vaultwarden/wiki/Hardening-Guide#access-logs-contain-access_token-parameter) covers redacting it.
- A client's tokens staying valid after it logs out. Logging out discards them on that device, as upstream, and deauthorizing sessions or changing the password ends them.
- The personal API key skipping 2FA, and working after a password change until the user rotates it. It is a separate credential.
- A linked SSO identity still logging in after a master password change, and the SSO provider deciding how long a session lasts (`SSO_AUTH_ONLY_NOT_SESSION` turns that off). The provider manages that credential, and it doesn't unlock the vault.
- A revoked or removed member's login session staying valid. It belongs to their own account, and the server checks membership on every request.
- A revoked or removed member keeping what their client already synced, and the organization key not being rotated when a member leaves.
- Unauthenticated requests to `/api/devices/identifier/<id>/clear-token`. The impact is delayed push delivery.
- Limits we leave out or keep simple by design: no cap on the number of items a user stores, no cleanup of attachments reserved but never uploaded, and rate limits kept in memory per process and IP, which reset on restart and are shared by users behind one address.
- Log injection. It only affects software that parses our logs, such as Fail2Ban with our community wiki's filters.
- Attacks that need a missing or misconfigured reverse proxy, such as one not sending `X-Forwarded-Proto` (it decides the admin cookie's `Secure` flag).
- Attacks that need another component to break its own specification, such as a NAT64 translator that forwards the `64:ff9b::/96` prefix to private addresses, against RFC 6052.
- Outbound requests to the SSO provider, Yubico server or S3 endpoint the operator configures, which skip the outbound filter and can be on the internal network.
- File permissions in the data folder. Host security belongs to the operator.
- Attacks that need physical access to a user's device.
- Self-exploitation, where someone can only affect their own account, such as self-XSS or weak KDF settings they choose.
- Text that users choose showing up in emails or pages, such as an organization, user or device name, as long as it can't cause XSS or modify the page.
- Dependency and base image CVEs without a path reachable through Vaultwarden.
- Packages, images and deployment templates that we don't publish. Report those to their maintainers.
- Missing best practices with no demonstrated impact, such as security headers, `style-src 'unsafe-inline'` (the web vault needs it) or version disclosure, and design recommendations without a concrete issue.
- Bugs already reported on our [issue tracker](https://github.com/dani-garcia/vaultwarden/issues).
- Experimental and beta features. They are opt-in and outside this model until they are stable.
- Issues that only affect outdated versions.
## Severity
We rate issues by what the attacker ends up with, such as decrypted data, ciphertext, or the power to change or delete data, and by the access they needed first. An attack that needs no account usually rates higher than one that does. Our ratings can differ from CVSS 3.1, which doesn't translate well to an end-to-end encrypted system. Older advisories may be rated differently.
Denial of service and resource exhaustion only count if they still apply with a reverse proxy in front. Races, denial of service and resource exhaustion are never rated above Medium.
Secrets written to logs at the default log level are rated by what they give someone who can read the logs but not the host.
### Critical
- Reading another account's data decrypted, with or without an account, such as through script on the vault origin or exposed key material.
- Code execution on the server by anyone other than an admin token holder.
- Changing released builds or images, including through our CI workflows or their secrets.
- Anyone other than the named grantee reaching an emergency access outcome.
- With no account: forging login or admin tokens, reading the JWT signing key, reaching the admin panel without the token, or taking over accounts with no action by the victim.
### High
- Decrypted reads within the same organization, such as a member reaching collections they are not assigned to.
- Changing or deleting another user's or organization's existing data.
- Reading another user's server-held secrets, such as their 2FA secrets, API keys, stored master password hash or encrypted user key.
- SSRF that returns the response to the attacker.
- Escalating within an organization.
- Account takeover through SSO.
- The named emergency access grantee reaching the outcome early.
- Script or a cross-site request acting through an admin panel session.
- Script on the vault origin that needs unusual victim interaction.
### Medium
- Ciphertext read by someone with no key.
- Defeating login brute-force protection.
- 2FA bypass that needs the password, such as a remembered device still skipping 2FA after the method is removed.
- A refresh token still working after a password change or "Deauthorize sessions".
- Getting past an organization policy the server enforces.
- Adding data to another user's or organization's space without permission.
- Data loss for another user or organization that the attacker cannot reliably trigger or aim.
- SSRF where the response is filtered or blind.
- Reading a Send that is disabled or expired.
- Denial of service that lasts until someone restarts the server or repairs data, from one request with no account in the default configuration.
### Low
These may be handled as ordinary hardening.
- Behavior that differs from upstream Bitwarden with low impact.
- Bypassing a non-default limit, such as `USER_ATTACHMENT_LIMIT`.
- Metadata across a boundary, such as who is assigned to which collection.
- Exceeding a Send's access limits when the attacker already holds the link.
- A missing rate limit outside login.
- Forging organization audit events.
- Other ways to tell whether an email has an account.
- Denial of service that lasts until someone acts, from one operation that needs an account or a non-default setting.
- Denial of service that lasts only while the attacker keeps going or that recovers by itself, such as a crash the container restarts from.
+4 -2
View File
@@ -597,9 +597,11 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
} }
} }
// A Manager only becomes a Custom member if that changes nothing they may do // A Manager only becomes a Custom member if that changes nothing they may do. Here they would keep the permissions
// they are shown with, the admin panel can't give up their access to all collections, so that is refused.
if new_type == MembershipType::Custom if new_type == MembershipType::Custom
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await && let Some(loss) =
member_to_edit.custom_role_conversion_loss(&member_to_edit.legacy_manager_permissions(), &conn).await
{ {
err!(loss) err!(loss)
} }
+11 -6
View File
@@ -1185,7 +1185,7 @@ async fn update_collections_by_scope(
let current = HashSet::<CollectionId>::from_iter(match scope { let current = HashSet::<CollectionId>::from_iter(match scope {
CipherAccessScope::User => cipher.get_collections(headers.user.uuid.clone(), &conn).await, CipherAccessScope::User => cipher.get_collections(headers.user.uuid.clone(), &conn).await,
CipherAccessScope::OrganizationAdmin => { CipherAccessScope::OrganizationAdmin => {
cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await cipher.get_admin_collections(headers.user.uuid.clone(), scope, &conn).await
} }
}); });
// The admin route accepts Edit Any for all collections of this cipher's organization. // The admin route accepts Edit Any for all collections of this cipher's organization.
@@ -1241,11 +1241,15 @@ async fn update_collections_by_scope(
&conn, &conn,
) )
.await; .await;
let sync_type = match scope { let response = match scope {
CipherAccessScope::User => CipherSyncType::User, CipherAccessScope::User => {
CipherAccessScope::OrganizationAdmin => CipherSyncType::Organization, cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?
}
CipherAccessScope::OrganizationAdmin => {
cipher.to_json_org_admin_collections(&headers.host, &headers.user.uuid, &conn).await?
}
}; };
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, sync_type, &conn).await?)) Ok(Json(response))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -2907,8 +2911,9 @@ mod custom_role_tests {
let admin = cipher.to_json_org_admin("http://localhost", &member.user_uuid, &conn).await.unwrap(); let admin = cipher.to_json_org_admin("http://localhost", &member.user_uuid, &conn).await.unwrap();
assert_eq!(personal["collectionIds"], json!([collection.uuid])); assert_eq!(personal["collectionIds"], json!([collection.uuid]));
let admin_ids = admin["collectionIds"].as_array().unwrap(); let admin_ids = admin["collectionIds"].as_array().unwrap();
assert_eq!(admin_ids.len(), 1); assert_eq!(admin_ids.len(), 2);
assert!(admin_ids.contains(&json!(collection.uuid))); assert!(admin_ids.contains(&json!(collection.uuid)));
assert!(admin_ids.contains(&json!(hidden.uuid)));
// Exercise PR2's SQL authorization independently of Custom permission support added in PR3. // Exercise PR2's SQL authorization independently of Custom permission support added in PR3.
let sql = format!("DELETE FROM users_collections WHERE user_uuid='{}'", user.uuid); let sql = format!("DELETE FROM users_collections WHERE user_uuid='{}'", user.uuid);
conn.run(move |c| c.batch_execute(&sql)).await.unwrap(); conn.run(move |c| c.batch_execute(&sql)).await.unwrap();
+277 -82
View File
@@ -11,7 +11,10 @@ use crate::{
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
}, },
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, auth::{
AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManagerHeaders,
ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite,
},
db::{ db::{
DbConn, DbConn,
models::{ models::{
@@ -365,12 +368,24 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
if !headers.membership.has_full_access() { let collections = if headers.membership.can_view_all_collections() {
get_org_collections_impl(&org_id, &conn).await
} else if headers.membership.has_type(MembershipType::Custom) {
// A Custom member gets the collections they may manage
let manageable =
Collection::find_explicitly_manageable_by_user(&org_id, &headers.membership.user_uuid, &conn).await;
Collection::find_by_organization(&org_id, &conn)
.await
.iter()
.filter(|collection| manageable.contains(&collection.uuid))
.map(Collection::to_json)
.collect::<Value>()
} else {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
} };
Ok(Json(json!({ Ok(Json(json!({
"data": get_org_collections_impl(&org_id, &conn).await, "data": collections,
"object": "list", "object": "list",
"continuationToken": null, "continuationToken": null,
}))) })))
@@ -397,6 +412,12 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
// check if current user has full access to the organization (either directly or via any group) // check if current user has full access to the organization (either directly or via any group)
let has_full_access_to_org = member.has_full_access() let has_full_access_to_org = member.has_full_access()
|| (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await); || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await);
// A Custom member gets every collection when they may see all of them, else the ones they may manage
let custom_collections = if member.has_type(MembershipType::Custom) && !member.can_view_all_collections() {
Some(Collection::find_explicitly_manageable_by_user(&org_id, &member.user_uuid, &conn).await)
} else {
None
};
// Get all admins, owners and managers who can manage/access all // Get all admins, owners and managers who can manage/access all
// Those are currently not listed in the col_users but need to be listed too. // Those are currently not listed in the col_users but need to be listed too.
@@ -422,7 +443,12 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
&& GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await);
// If the user is a manager, and is not assigned to this collection, skip this and continue with the next collection // If the user is a manager, and is not assigned to this collection, skip this and continue with the next collection
if !assigned { let listed = if member.has_type(MembershipType::Custom) {
custom_collections.as_ref().is_none_or(|manageable| manageable.contains(&col.uuid))
} else {
assigned
};
if !listed {
continue; continue;
} }
@@ -482,7 +508,10 @@ async fn post_organization_collections(
let data: FullCollectionData = data.into_inner(); let data: FullCollectionData = data.into_inner();
data.validate(&org_id, &conn).await?; data.validate(&org_id, &conn).await?;
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all { if (headers.membership.atype == MembershipType::Manager && !headers.membership.access_all)
|| (headers.membership.has_type(MembershipType::Custom)
&& !headers.membership.has_custom_permission(CustomPermission::CreateNewCollections))
{
err!("You don't have permission to create collections") err!("You don't have permission to create collections")
} }
@@ -565,7 +594,12 @@ async fn post_bulk_access_collections(
err!("Collection not found") err!("Collection not found")
}; };
if !collection.is_manageable_by_user(&headers.membership.user_uuid, &conn).await { let manageable = if headers.membership.has_type(MembershipType::Custom) {
custom_member_may(CollectionAction::Edit, &headers.membership, &collection.uuid, &conn).await
} else {
collection.is_manageable_by_user(&headers.membership.user_uuid, &conn).await
};
if !manageable {
err!("Collection not found", "The current user isn't a manager for this collection") err!("Collection not found", "The current user isn't a manager for this collection")
} }
@@ -678,7 +712,7 @@ async fn put_organization_collection_update(
async fn delete_organization_collection_impl( async fn delete_organization_collection_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
col_id: &CollectionId, col_id: &CollectionId,
headers: &ManagerHeaders, headers: &CollectionDeleteHeaders,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != &headers.org_id { if org_id != &headers.org_id {
@@ -704,7 +738,7 @@ async fn delete_organization_collection_impl(
async fn delete_organization_collection( async fn delete_organization_collection(
org_id: OrganizationId, org_id: OrganizationId,
col_id: CollectionId, col_id: CollectionId,
headers: ManagerHeaders, headers: CollectionDeleteHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
@@ -730,7 +764,7 @@ async fn bulk_delete_organization_collections(
let collections = data.ids; let collections = data.ids;
let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; let headers = CollectionDeleteHeaders::from_loose(headers, &collections, &conn).await?;
for col_id in collections { for col_id in collections {
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?; delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?;
@@ -742,23 +776,28 @@ async fn bulk_delete_organization_collections(
async fn get_org_collection_detail( async fn get_org_collection_detail(
org_id: OrganizationId, org_id: OrganizationId,
col_id: CollectionId, col_id: CollectionId,
headers: ManagerHeaders, headers: CollectionReadHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> 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");
} }
match Collection::find_by_uuid_and_user(&col_id, headers.user.uuid.clone(), &conn).await { let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else {
err!("User is not part of organization")
};
// A Custom member may be allowed to read a collection they are not assigned to
let collection = if member.has_type(MembershipType::Custom) {
Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await
} else {
Collection::find_by_uuid_and_user(&col_id, headers.user.uuid.clone(), &conn).await
};
match collection {
None => err!("Collection not found"), None => err!("Collection not found"),
Some(collection) => { Some(collection) => {
if collection.org_uuid != org_id { if collection.org_uuid != org_id {
err!("Collection is not owned by organization") err!("Collection is not owned by organization")
} }
let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else {
err!("User is not part of organization")
};
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
CollectionGroup::find_by_collection(&collection.uuid, &conn) CollectionGroup::find_by_collection(&collection.uuid, &conn)
.await .await
@@ -807,7 +846,7 @@ async fn get_org_collection_detail(
async fn get_collection_users( async fn get_collection_users(
org_id: OrganizationId, org_id: OrganizationId,
col_id: CollectionId, col_id: CollectionId,
headers: ManagerHeaders, headers: CollectionReadHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -1053,7 +1092,13 @@ async fn send_invite(
let Some(requested_type) = MembershipType::from_str(raw_type) else { let Some(requested_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type") err!("Invalid type")
}; };
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled()); // Checked before anything is created, so a rejected permission leaves nothing behind
let custom_permissions = if requested_type == MembershipType::Custom && custom_roles_enabled() {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
let new_type = MembershipType::requested(requested_type, None, custom_permissions.as_ref());
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can invite Managers, Admins or Owners") err!("Only Owners can invite Managers, Admins or Owners")
@@ -1069,13 +1114,6 @@ async fn send_invite(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))); && data.permissions.get("createNewCollections") == Some(&json!(true)));
// Checked before anything is created, so a rejected permission leaves nothing behind
let custom_permissions = if new_type == MembershipType::Custom {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
let mut user_created: bool; let mut user_created: bool;
for email in &data.emails { for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32; let mut member_status = MembershipStatus::Invited as i32;
@@ -1498,6 +1536,11 @@ async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeade
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");
} }
if headers.membership.has_type(MembershipType::Custom)
&& !may_assign_collection_access(&headers.membership, &conn).await
{
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
}
let mut members_json = Vec::new(); let mut members_json = Vec::new();
for m in Membership::find_by_org(&org_id, &conn).await { for m in Membership::find_by_org(&org_id, &conn).await {
members_json.push(m.to_json_mini_details(&conn).await); members_json.push(m.to_json_mini_details(&conn).await);
@@ -1566,7 +1609,20 @@ async fn edit_member(
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled()); // While Custom roles are disabled, a Custom member keeps their stored permissions untouched
let custom_permissions = if requested_type == MembershipType::Custom && custom_roles_enabled() {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_permissions.as_ref());
// A Manager whose permissions are changed only becomes a Custom member if that keeps what they may do
if new_type == MembershipType::Custom
&& let Some(permissions) = &custom_permissions
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(permissions, &conn).await
{
err!(loss)
}
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
@@ -1578,13 +1634,6 @@ async fn edit_member(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))); && data.permissions.get("createNewCollections") == Some(&json!(true)));
// While Custom roles are disabled, a Custom member keeps their stored permissions untouched
let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
if new_type != member_to_edit.atype if new_type != member_to_edit.atype
&& (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin)
&& headers.membership_type != MembershipType::Owner && headers.membership_type != MembershipType::Owner
@@ -1608,7 +1657,7 @@ async fn edit_member(
member_to_edit.set_type(new_type); member_to_edit.set_type(new_type);
member_to_edit.access_all = access_all; member_to_edit.access_all = access_all;
if let Some(permissions) = &custom_permissions { if let Some(permissions) = custom_permissions.as_ref().filter(|_| new_type == MembershipType::Custom) {
member_to_edit.set_custom_permissions(permissions); member_to_edit.set_custom_permissions(permissions);
} }
@@ -1839,6 +1888,46 @@ struct RelationsData {
value: usize, value: usize,
} }
fn validate_org_import_relationships(data: &ImportData) -> ApiResult<Vec<(usize, usize)>> {
let mut relations = Vec::with_capacity(data.collection_relationships.len());
let mut seen = HashSet::new();
for relation in &data.collection_relationships {
if relation.key >= data.ciphers.len() || relation.value >= data.collections.len() {
err!(Compact, "Invalid collection relationship")
}
if seen.insert((relation.key, relation.value)) {
relations.push((relation.key, relation.value));
}
}
Ok(relations)
}
async fn validate_org_import_collections(
imported: &[ImportCollectionData],
org_id: &OrganizationId,
membership: &Membership,
conn: &DbConn,
) -> ApiResult<HashMap<CollectionId, Collection>> {
let existing: HashMap<CollectionId, Collection> =
Collection::find_by_organization(org_id, conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
for col in imported {
if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) {
if membership.atype < MembershipType::Admin
&& !membership.can_edit_any_collection()
&& !collection.is_writable_by_user(&membership.user_uuid, conn).await
{
err!(Compact, "The current user isn't allowed to manage this collection")
}
} else if membership.atype <= MembershipType::Manager
&& !membership.has_full_access()
&& !membership.has_custom_permission(CustomPermission::CreateNewCollections)
{
err!(Compact, "The current user isn't allowed to create new collections")
}
}
Ok(existing)
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62
#[post("/ciphers/import-organization?<query..>", data = "<data>")] #[post("/ciphers/import-organization?<query..>", data = "<data>")]
async fn post_org_import( async fn post_org_import(
@@ -1864,74 +1953,171 @@ async fn post_org_import(
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.ciphers)?; Cipher::validate_cipher_data(&data.ciphers)?;
let existing_collections: HashMap<CollectionId, Collection> = let relations = validate_org_import_relationships(&data)?;
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); let ImportData {
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); ciphers: imported_ciphers,
for col in data.collections { collections: imported_collections,
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); ..
let collection_uuid = if let Some(collection) = existing { } = data;
// When not an Owner or Admin, check if the member is allowed to write to the collection. let grant_new_collection_manage = headers.membership.has_type(MembershipType::Custom);
if headers.membership.atype < MembershipType::Admin let importer_uuid = headers.membership.user_uuid.clone();
&& !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await let OrgMemberHeaders {
{ host,
err!(Compact, "The current user isn't allowed to manage this collection") device,
} user,
collection.uuid.clone() membership,
} else { ip,
// We do not allow users or managers which can not manage all collections to create new collections } = headers;
// If there is any collection other than an existing import collection, abort the import. let headers = Headers {
if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { host,
err!(Compact, "The current user isn't allowed to create new collections") device,
} user,
let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); ip,
new_collection.save(&conn).await?;
new_collection.uuid
}; };
// Keep authorization and every write on the same connection. A failure at any later cipher or
// relationship must also roll back newly created collections and their Manage assignments.
conn.transaction(async || {
let existing = validate_org_import_collections(&imported_collections, &org_id, &membership, &conn).await?;
let mut collections: Vec<CollectionId> = Vec::with_capacity(imported_collections.len());
for col in imported_collections {
let collection_uuid = if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) {
collection.uuid.clone()
} else {
let new_collection = Collection::new(org_id.clone(), col.name, col.external_id);
new_collection.save(&conn).await?;
if grant_new_collection_manage {
CollectionUser::save(&importer_uuid, &new_collection.uuid, false, false, true, &conn).await?;
}
new_collection.uuid
};
collections.push(collection_uuid); collections.push(collection_uuid);
} }
// Read the relations between collections and ciphers let mut assigned = vec![Vec::<CollectionId>::new(); imported_ciphers.len()];
// Ciphers can be in multiple collections at the same time for (cipher_index, collection_index) in relations {
let mut relations = Vec::with_capacity(data.collection_relationships.len()); assigned[cipher_index].push(collections[collection_index].clone());
for relation in data.collection_relationships {
relations.push((relation.key, relation.value));
} }
for (mut cipher_data, collection_ids) in imported_ciphers.into_iter().zip(assigned) {
let headers: Headers = headers.into();
let mut ciphers: Vec<CipherId> = Vec::with_capacity(data.ciphers.len());
for mut cipher_data in data.ciphers {
// Always clear folder_id's via an organization import
cipher_data.folder_id = None; cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org // The authenticated membership, rather than the imported file, selects the organization.
cipher_data.organization_id = Some(org_id.clone()); cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type); let mut cipher = Cipher::new(cipher_data.r#type);
update_cipher_from_data( update_cipher_from_data(
&mut cipher, &mut cipher,
cipher_data, cipher_data,
&headers, &headers,
Some(collections.clone()), Some(collection_ids.clone()),
&conn, &conn,
&nt, &nt,
UpdateType::None, UpdateType::None,
true, false,
) )
.await .await?;
.ok(); for collection_id in collection_ids {
ciphers.push(cipher.uuid); CollectionCipher::save(&cipher.uuid, &collection_id, &conn).await?;
} }
// Assign the collections
for (cipher_index, col_index) in relations {
let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
err!(Compact, "Invalid collection relationship")
};
CollectionCipher::save(cipher_id, col_id, &conn).await?;
} }
let mut user = headers.user; let mut user = headers.user;
user.update_revision(&conn).await user.update_revision(&conn).await
})
.await
}
#[cfg(all(test, feature = "sqlite"))]
mod org_import_tests {
use super::*;
use crate::{
api::notifications::{WS_USERS, WebSocketUsers},
db::{models::test_flag, test_db},
};
use diesel::connection::SimpleConnection;
use std::sync::Arc;
#[test]
fn imports_preserve_authorization_atomicity_and_creator_access() {
test_db::run(async |db| {
let conn = db.conn();
for (case, role, permission) in [
("owner", MembershipType::Owner, CustomPermission::CreateNewCollections),
("admin", MembershipType::Admin, CustomPermission::CreateNewCollections),
("cipher", MembershipType::Custom, CustomPermission::CreateNewCollections),
("custom", MembershipType::Custom, CustomPermission::CreateNewCollections),
("legacy", MembershipType::Manager, CustomPermission::CreateNewCollections),
("authorization", MembershipType::Custom, CustomPermission::CreateNewCollections),
("rollback", MembershipType::Custom, CustomPermission::CreateNewCollections),
("index", MembershipType::Custom, CustomPermission::CreateNewCollections),
("disabled", MembershipType::Custom, CustomPermission::CreateNewCollections),
("export", MembershipType::Owner, CustomPermission::CreateNewCollections),
] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(case != "disabled"));
let org = test_db::organization(&conn).await;
let permissions = if role == MembershipType::Custom {
vec![permission]
} else {
vec![]
};
let (user, mut membership) = test_db::member(&conn, &org.uuid, role, &permissions).await;
membership.access_all = role == MembershipType::Manager;
membership.save(&conn).await.unwrap();
let mut payload = json!({"ciphers":[{"name":"Cipher","type":2,"encryptedFor":user.uuid,"secureNote":{"type":0}}],
"collections":[{"name":"New"}],"collectionRelationships":[{"key":0,"value":0}]});
match case {
"authorization" => {
let existing=test_db::collection(&conn,&org.uuid).await;
payload["collections"].as_array_mut().unwrap().push(json!({"name":"Existing","id":existing.uuid}));
}
"cipher" => {
payload["ciphers"].as_array_mut().unwrap().push(json!({"name":"Invalid","type":99,"encryptedFor":user.uuid,"secureNote":{"type":0}}));
payload["collectionRelationships"].as_array_mut().unwrap().push(json!({"key":1,"value":0}));
}
"rollback" => conn.run(|c| c.batch_execute("CREATE TRIGGER reject_link BEFORE INSERT ON ciphers_collections BEGIN SELECT RAISE(ABORT,'test failure'); END")).await.unwrap(),
"index" => payload["collectionRelationships"][0]["value"]=json!(1),
"export" => payload["collectionRelationships"]=json!([]),
_ => {}
}
let user_id = user.uuid.clone();
let headers = OrgMemberHeaders {
host: "http://localhost".into(),
device: Device::new("test".to_owned().into(), user_id.clone(), "test".into(), 1),
user,
membership,
ip: crate::auth::ClientIp {
ip: "127.0.0.1".parse().unwrap(),
},
};
let rocket = rocket::build().manage(Arc::clone(&WS_USERS));
let notify = rocket.state::<Arc<WebSocketUsers>>().unwrap();
let result = post_org_import(
OrgIdData {
organization_id: org.uuid.clone(),
},
Json(serde_json::from_value(payload).unwrap()),
headers,
db.conn(),
notify.into(),
)
.await;
conn.run(|c| c.batch_execute("DROP TRIGGER IF EXISTS reject_link")).await.unwrap();
let success = matches!(case, "owner" | "admin" | "custom" | "legacy" | "export");
assert_eq!(result.is_ok(), success, "{case}");
assert_eq!(
Collection::find_by_organization(&org.uuid, &conn).await.len(),
usize::from(success || case == "authorization")
);
assert_eq!(Cipher::find_by_org(&org.uuid, &conn).await.len(), usize::from(success));
assert_eq!(
CollectionUser::find_by_user(&user_id, &conn).await.len(),
usize::from(success && role == MembershipType::Custom)
);
assert_eq!(
Cipher::get_collections_with_cipher_by_organization(&org.uuid, &conn).await.len(),
usize::from(success && case != "export")
);
}
});
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -2529,6 +2715,14 @@ async fn restore_member_impl(
Ok(()) Ok(())
} }
/// Whether a member may give others access to collections, and so needs to see the members and groups: by seeing
/// or creating any collection, or by the manage option on a collection.
async fn may_assign_collection_access(member: &Membership, conn: &DbConn) -> bool {
member.can_view_all_collections()
|| member.has_custom_permission(CustomPermission::CreateNewCollections)
|| Collection::has_manageable_collection_by_user(&member.org_uuid, &member.user_uuid, conn).await
}
async fn get_groups_data( async fn get_groups_data(
details: bool, details: bool,
org_id: OrganizationId, org_id: OrganizationId,
@@ -2542,14 +2736,15 @@ async fn get_groups_data(
// The details view (group→collection/user mappings) needs full org access; the plain list only // The details view (group→collection/user mappings) needs full org access; the plain list only
// needs manage access to a collection, so a manager of a collection (directly or via a group) // needs manage access to a collection, so a manager of a collection (directly or via a group)
// can load it to assign groups. // can load it to assign groups.
// A group with access to all collections gives a Custom member access to them, but not to the Admin Console.
let has_full_access = headers.membership.has_full_access() let has_full_access = headers.membership.has_full_access()
|| (CONFIG.org_groups_enabled() || (CONFIG.org_groups_enabled()
&& !headers.membership.has_type(MembershipType::Custom)
&& GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await); && GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await);
let allowed = if details { let allowed = if details {
has_full_access has_full_access
} else { } else {
has_full_access has_full_access || may_assign_collection_access(&headers.membership, &conn).await
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
}; };
if !allowed { if !allowed {
err_code!("Resource not found.", "User does not have access", Status::NotFound.code); err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
+119 -34
View File
@@ -32,9 +32,9 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, AttachmentId, CipherId, Collection, CollectionId, CustomPermission, Device, DeviceId, DeviceType,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, EmergencyAccessId, EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId,
SendFileId, SendId, User, UserId, OrganizationId, SendFileId, SendId, User, UserId, custom_roles_enabled,
}, },
}, },
error::Error, error::Error,
@@ -757,6 +757,12 @@ impl OrgHeaders {
fn is_confirmed_and_owner(&self) -> bool { fn is_confirmed_and_owner(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner
} }
// A Custom member, while Custom roles are enabled. What they may do depends on their permissions.
fn is_confirmed_and_custom(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed
&& self.membership_type == MembershipType::Custom
&& custom_roles_enabled()
}
} }
// org_id is usually the second path param ("/organizations/<org_id>"), // org_id is usually the second path param ("/organizations/<org_id>"),
@@ -895,36 +901,90 @@ fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
None None
} }
/// The ManagerHeaders are used to check if you are at least a Manager /// What a collection route does with its collection, which decides what a Custom member needs for it.
/// and have access to the specific collection provided via the <col_id>/collections/collectionId. #[derive(Clone, Copy)]
/// This does strict checking on the collection_id, ManagerHeadersLoose does not. pub enum CollectionAction {
pub struct ManagerHeaders { Edit,
Read,
Delete,
}
/// Whether `member`, a confirmed Custom member, may `action` the collection `col_id` of their organization.
///
/// Deleting needs Delete any collection: the clients are told collection deletion is limited, so the manage option
/// on a collection is no permission to delete it. Editing is open with Edit any collection, reading also with Delete
/// any collection, and otherwise both need the manage option on the collection itself. Neither the role nor any
/// `access_all` counts as that.
pub async fn custom_member_may(
action: CollectionAction,
member: &Membership,
col_id: &CollectionId,
conn: &DbConn,
) -> bool {
if !(member.has_type(MembershipType::Custom)
&& member.has_status(MembershipStatus::Confirmed)
&& custom_roles_enabled())
{
return false;
}
match action {
CollectionAction::Delete => member.has_custom_permission(CustomPermission::DeleteAnyCollection),
CollectionAction::Edit if member.can_edit_any_collection() => true,
CollectionAction::Read if member.can_view_all_collections() => true,
CollectionAction::Edit | CollectionAction::Read => {
Collection::find_explicitly_manageable_by_user(&member.org_uuid, &member.user_uuid, conn)
.await
.contains(col_id)
}
}
}
/// Checks a collection route for the collection provided via the <col_id>/collections/collectionId: a Manager, Admin
/// or Owner needs to be able to manage the collection, a Custom member is checked for the `action`.
async fn collection_headers(request: &Request<'_>, action: CollectionAction) -> Outcome<OrgHeaders, &'static str> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
let is_custom = headers.is_confirmed_and_custom();
if !headers.is_confirmed_and_manager() && !is_custom {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
}
let Some(col_id) = get_col_id(request) else {
err_handler!("Error getting the collection id")
};
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
let allowed = if is_custom {
custom_member_may(action, &headers.membership, &col_id, &conn).await
} else {
Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
// NotFound as in Bitwarden, the web vault logs the user out on a 401
error!(target: "auth", "Collection not found. The current user isn't a manager for this collection");
return Outcome::Error((rocket::http::Status::NotFound, "Collection not found"));
}
Outcome::Success(headers)
}
macro_rules! collection_headers {
($(#[$doc:meta])* $name:ident, $action:expr) => {
$(#[$doc])*
pub struct $name {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
pub user: User, pub user: User,
pub ip: ClientIp, pub ip: ClientIp,
pub org_id: OrganizationId, pub org_id: OrganizationId,
} }
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ManagerHeaders { impl<'r> FromRequest<'r> for $name {
type Error = &'static str; type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await); let headers = try_outcome!(collection_headers(request, $action).await);
if headers.is_confirmed_and_manager() {
if let Some(col_id) = get_col_id(request) {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
err_handler!("The current user isn't a manager for this collection")
}
} else {
err_handler!("Error getting the collection id")
}
Outcome::Success(Self { Outcome::Success(Self {
host: headers.host, host: headers.host,
device: headers.device, device: headers.device,
@@ -932,14 +992,11 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
ip: headers.ip, ip: headers.ip,
org_id: headers.membership.org_uuid, org_id: headers.membership.org_uuid,
}) })
} else {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
} }
} }
}
impl From<ManagerHeaders> for Headers { impl From<$name> for Headers {
fn from(h: ManagerHeaders) -> Headers { fn from(h: $name) -> Headers {
Headers { Headers {
host: h.host, host: h.host,
device: h.device, device: h.device,
@@ -947,10 +1004,31 @@ impl From<ManagerHeaders> for Headers {
ip: h.ip, ip: h.ip,
} }
} }
}
};
} }
collection_headers!(
/// The ManagerHeaders are used to check if you are at least a Manager
/// and have access to the specific collection provided via the <col_id>/collections/collectionId.
/// This does strict checking on the collection_id, ManagerHeadersLoose does not.
ManagerHeaders,
CollectionAction::Edit
);
collection_headers!(
/// The same as ManagerHeaders, for the routes which only read the collection and its access.
CollectionReadHeaders,
CollectionAction::Read
);
collection_headers!(
/// The same as ManagerHeaders, for the routes which delete the collection.
CollectionDeleteHeaders,
CollectionAction::Delete
);
/// The ManagerHeadersLoose is used when you at least need to be a Manager, /// The ManagerHeadersLoose is used when you at least need to be a Manager,
/// but there is no collection_id sent with the request (either in the path or as form data). /// but there is no collection_id sent with the request (either in the path or as form data).
/// A Custom member is let through as well, the routes check what their permissions allow.
pub struct ManagerHeadersLoose { pub struct ManagerHeadersLoose {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
@@ -965,7 +1043,7 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await); let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.is_confirmed_and_manager() { if headers.is_confirmed_and_manager() || headers.is_confirmed_and_custom() {
Outcome::Success(Self { Outcome::Success(Self {
host: headers.host, host: headers.host,
device: headers.device, device: headers.device,
@@ -990,22 +1068,29 @@ impl From<ManagerHeadersLoose> for Headers {
} }
} }
impl ManagerHeaders { impl CollectionDeleteHeaders {
pub async fn from_loose( pub async fn from_loose(
h: ManagerHeadersLoose, h: ManagerHeadersLoose,
collections: &Vec<CollectionId>, collections: &Vec<CollectionId>,
conn: &DbConn, conn: &DbConn,
) -> Result<ManagerHeaders, Error> { ) -> Result<CollectionDeleteHeaders, Error> {
let is_custom = h.membership.has_type(MembershipType::Custom) && custom_roles_enabled();
for col_id in collections { for col_id in collections {
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() { if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
err!("Collection Id is malformed!"); err!("Collection Id is malformed!");
} }
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await { let allowed = if is_custom {
Collection::find_by_uuid_and_org(col_id, &h.membership.org_uuid, conn).await.is_some()
&& custom_member_may(CollectionAction::Delete, &h.membership, col_id, conn).await
} else {
Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await
};
if !allowed {
err!("Collection not found", "The current user isn't a manager for this collection") err!("Collection not found", "The current user isn't a manager for this collection")
} }
} }
Ok(ManagerHeaders { Ok(CollectionDeleteHeaders {
host: h.host, host: h.host,
device: h.device, device: h.device,
user: h.user, user: h.user,
+1
View File
@@ -1516,6 +1516,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Key Management Team // Key Management Team
"biometrics-sdk-ipc", "biometrics-sdk-ipc",
"windows-native-credential-sync", "windows-native-credential-sync",
"macos-native-credential-sync",
"enable-account-encryption-v2-jit-password-registration", "enable-account-encryption-v2-jit-password-registration",
"pm-27278-v2-password-registration", "pm-27278-v2-password-registration",
// Mobile Team // Mobile Team
+65
View File
@@ -626,6 +626,7 @@ pub mod test_db {
} }
// DbConn::run uses block_in_place, requiring a multi-threaded runtime. // DbConn::run uses block_in_place, requiring a multi-threaded runtime.
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async { tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
super::models::test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
test(&TestDb::new()).await; test(&TestDb::new()).await;
}); });
} }
@@ -758,4 +759,68 @@ pub mod test_db {
} }
}); });
} }
#[test]
fn collection_and_cipher_access_requires_active_membership_and_enabled_groups() {
use super::models::{Cipher, CipherAccessScope, CollectionCipher, CollectionUser, test_flag};
use crate::{
api::core::{CipherSyncData, CipherSyncType},
auth::{CollectionAction, custom_member_may},
};
run(async |db| {
let conn = db.conn();
let (org, user, mut member) = custom_member(&conn, CustomPermission::EditAnyCollection).await;
let collection = collection(&conn, &org.uuid).await;
let mut cipher = Cipher::new(2);
cipher.name = "Cipher".into();
cipher.organization_uuid = Some(org.uuid.clone());
cipher.save(&conn).await.unwrap();
CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await.unwrap();
for (enabled, status, allowed) in [(true, 2, true), (false, 2, false), (true, 1, false)] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
member.status = status;
member.save(&conn).await.unwrap();
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::User, &conn).await);
assert_eq!(
cipher.is_write_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await,
allowed
);
}
test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
member.status = 2;
member.save(&conn).await.unwrap();
let foreign = organization(&conn).await;
cipher.organization_uuid = Some(foreign.uuid);
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await);
cipher.organization_uuid = Some(org.uuid.clone());
member.set_custom_permissions(&std::collections::HashSet::new());
member.save(&conn).await.unwrap();
let sql=format!("INSERT INTO groups (uuid,organizations_uuid,name,access_all,creation_date,revision_date)
VALUES ('g','{}','Group',FALSE,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP);
INSERT INTO groups_users (groups_uuid,users_organizations_uuid) VALUES ('g','{}');
INSERT INTO collections_groups (collections_uuid,groups_uuid,read_only,hide_passwords,manage) VALUES ('{}','g',FALSE,FALSE,TRUE);",
org.uuid,member.uuid,collection.uuid);
conn.run(move |c| c.batch_execute(&sql)).await.unwrap();
for status in [2, 1, -3, -126] {
member.status = status;
member.save(&conn).await.unwrap();
let allowed = status == 2 && crate::CONFIG.org_groups_enabled();
assert_eq!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await, allowed);
let sync = CipherSyncData::new(&user.uuid, CipherSyncType::User, &conn).await;
for cached in [None, Some(&sync)] {
assert_eq!(
cipher
.get_access_restrictions(&user.uuid, CipherAccessScope::User, cached, &conn)
.await
.is_some(),
allowed
);
}
}
member.status = 2;
member.save(&conn).await.unwrap();
CollectionUser::save(&user.uuid, &collection.uuid, false, false, true, &conn).await.unwrap();
assert!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await);
});
}
} }
+32 -5
View File
@@ -25,7 +25,8 @@ use macros::UuidFromParam;
use super::{ use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES, MembershipStatus, OrganizationId, User, UserId,
organization::{ORG_ADMIN_ATYPES, custom_edit_any_collection},
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -230,6 +231,23 @@ impl Cipher {
.await .await
} }
pub async fn to_json_org_admin_collections(
&self,
host: &str,
user_uuid: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
self.to_json_scoped(
host,
user_uuid,
None,
CipherSyncType::Organization,
CipherAccessScope::OrganizationAdmin,
conn,
)
.await
}
async fn to_json_scoped( async fn to_json_scoped(
&self, &self,
host: &str, host: &str,
@@ -352,7 +370,7 @@ impl Cipher {
Cow::from(Vec::new()) Cow::from(Vec::new())
} }
} else { } else {
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await) Cow::from(self.get_admin_collections(user_uuid.clone(), scope, conn).await)
}; };
// There are three types of cipher response models in upstream // There are three types of cipher response models in upstream
@@ -1194,7 +1212,12 @@ impl Cipher {
} }
} }
pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec<CollectionId> { pub async fn get_admin_collections(
&self,
user_uuid: UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> Vec<CollectionId> {
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
conn.run(move |conn| { conn.run(move |conn| {
ciphers_collections::table ciphers_collections::table
@@ -1234,7 +1257,9 @@ impl Cipher {
.or(collections_groups::collections_uuid .or(collections_groups::collections_uuid
.is_not_null() // Access via groups .is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false))) .and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or(custom_edit_any_collection()
.and::<_, diesel::sql_types::Bool>(scope == CipherAccessScope::OrganizationAdmin)),
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@@ -1263,7 +1288,9 @@ impl Cipher {
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or(custom_edit_any_collection()
.and::<_, diesel::sql_types::Bool>(scope == CipherAccessScope::OrganizationAdmin)),
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
+42 -10
View File
@@ -18,7 +18,7 @@ use macros::UuidFromParam;
use super::{ use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId, organization::ORG_ADMIN_ATYPES, User, UserId, custom_roles_enabled, organization::ORG_ADMIN_ATYPES,
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@@ -109,18 +109,22 @@ impl Collection {
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) => { Some(m) => {
// Only let a manager manage collections when the have full read/write access // Only let a manager manage collections when the have full read/write access
// A Custom member only manages a collection with the manage option
let is_manager = m.atype == MembershipType::Manager; let is_manager = m.atype == MembershipType::Manager;
let is_custom = m.has_type(MembershipType::Custom) && custom_roles_enabled();
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
( (
cu.read_only, cu.read_only,
cu.hide_passwords, cu.hide_passwords,
is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)), (is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)))
|| (is_custom && cu.manage),
) )
} else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) { } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) {
( (
cg.read_only, cg.read_only,
cg.hide_passwords, cg.hide_passwords,
is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)), (is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)))
|| (is_custom && cg.manage),
) )
} else { } else {
(false, false, false) (false, false, false)
@@ -134,6 +138,17 @@ impl Collection {
Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => { Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => {
(false, false, true) (false, false, true)
} }
// A Custom member reads and writes every collection with Edit any collection in the Admin Console, but
// only manages a collection with the manage option on it
Some(m) if m.has_type(MembershipType::Custom) && custom_roles_enabled() => {
let manage = self.is_explicitly_manageable_by_user(user_uuid, conn).await;
if manage || m.can_edit_any_collection() {
(false, false, manage)
} else {
let read_only = !self.is_writable_by_user(user_uuid, conn).await;
(read_only, self.hide_passwords_for_user(user_uuid, conn).await, false)
}
}
Some(m) => { Some(m) => {
let is_manager = m.atype == MembershipType::Manager; let is_manager = m.atype == MembershipType::Manager;
let read_only = !self.is_writable_by_user(user_uuid, conn).await; let read_only = !self.is_writable_by_user(user_uuid, conn).await;
@@ -635,8 +650,23 @@ impl Collection {
user_uuid: &UserId, user_uuid: &UserId,
conn: &DbConn, conn: &DbConn,
) -> bool { ) -> bool {
!Self::find_explicitly_manageable_by_user(org_uuid, user_uuid, conn).await.is_empty()
}
pub async fn is_explicitly_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::find_explicitly_manageable_by_user(&self.org_uuid, user_uuid, conn).await.contains(&self.uuid)
}
// The collections of the org the user has manage access to by the manage option, directly or via a group.
// Unlike is_coll_manageable_by_user, neither the role nor any access_all counts.
pub async fn find_explicitly_manageable_by_user(
org_uuid: &OrganizationId,
user_uuid: &UserId,
conn: &DbConn,
) -> Vec<CollectionId> {
let org_uuid = org_uuid.to_string(); let org_uuid = org_uuid.to_string();
let user_uuid = user_uuid.to_string(); let user_uuid = user_uuid.to_string();
let groups_enabled = CONFIG.org_groups_enabled();
conn.run(move |conn| { conn.run(move |conn| {
collections::table collections::table
.left_join( .left_join(
@@ -658,14 +688,16 @@ impl Collection {
.filter(collections::org_uuid.eq(&org_uuid)) .filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
// Manage permission on a collection assigned directly or via a group. users_collections::manage.eq(true).or(users_collections::user_uuid
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)), .is_null()
.or(users_organizations::atype.eq(MembershipType::Manager as i32))
.and(collections_groups::manage.eq(true))
.and::<_, diesel::sql_types::Bool>(groups_enabled)),
) )
.count() .select(collections::uuid)
.first::<i64>(conn) .distinct()
.ok() .load::<CollectionId>(conn)
.unwrap_or(0) .unwrap_or_default()
!= 0
}) })
.await .await
} }
+2
View File
@@ -33,6 +33,8 @@ pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
#[cfg(all(test, sqlite))]
pub(crate) use self::organization::test_flag;
pub use self::organization::{ pub use self::organization::{
CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
OrganizationApiKey, OrganizationId, custom_roles_enabled, OrganizationApiKey, OrganizationId, custom_roles_enabled,
+204 -90
View File
@@ -24,8 +24,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
OrgPolicyType, TwoFactor, User, UserId, TwoFactor, User, UserId,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -134,22 +134,28 @@ impl MembershipType {
/// role (`None` for an invite). /// role (`None` for an invite).
/// ///
/// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a /// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing /// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled, in which case
/// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never /// `custom_permissions` are the permissions requested along with it. An existing Manager or Custom member
/// turns one into the other. /// keeps their role when the request repeats it, so an unrelated edit never turns one into the other. A
/// Manager only becomes a Custom member when the permissions they are shown with are changed.
pub fn requested( pub fn requested(
requested: MembershipType, requested: MembershipType,
current: Option<&Membership>, current: Option<&Membership>,
custom_roles_enabled: bool, custom_permissions: Option<&HashSet<CustomPermission>>,
) -> MembershipType { ) -> MembershipType {
if requested != MembershipType::Custom { if requested != MembershipType::Custom {
return requested; return requested;
} }
match current { match (current, custom_permissions) {
Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom, (Some(member), _) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager, (Some(member), Some(permissions))
_ if custom_roles_enabled => MembershipType::Custom, if member.has_type(MembershipType::Manager) && *permissions != member.legacy_manager_permissions() =>
_ => MembershipType::Manager, {
MembershipType::Custom
}
(Some(member), _) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
(_, Some(_)) => MembershipType::Custom,
(_, None) => MembershipType::Manager,
} }
} }
@@ -240,7 +246,11 @@ pub enum CustomPermission {
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored /// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored
/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them. /// grant of any other one grants nothing. They are implemented one at a time, so this grows with them.
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[]; const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
];
impl CustomPermission { impl CustomPermission {
pub const ALL: [CustomPermission; 9] = [ pub const ALL: [CustomPermission; 9] = [
@@ -306,9 +316,38 @@ pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, Mem
/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise. /// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise.
pub fn custom_roles_enabled() -> bool { pub fn custom_roles_enabled() -> bool {
#[cfg(test)]
if let Some(enabled) = test_flag::CUSTOM_ROLES_ENABLED.get() {
return enabled;
}
CONFIG.experimental_custom_roles() CONFIG.experimental_custom_roles()
} }
type CustomEditAnyCollection = diesel::dsl::And<
diesel::dsl::And<
diesel::dsl::Eq<users_organizations::atype, i32>,
diesel::dsl::Eq<users_organizations::edit_any_collection, bool>,
>,
bool,
diesel::sql_types::Bool,
>;
/// SQL: a Custom member with Edit any collection, while Custom roles are enabled.
pub(super) fn custom_edit_any_collection() -> CustomEditAnyCollection {
users_organizations::atype
.eq(MembershipType::Custom as i32)
.and(users_organizations::edit_any_collection.eq(true))
.and::<_, diesel::sql_types::Bool>(custom_roles_enabled())
}
#[cfg(test)]
pub(crate) mod test_flag {
thread_local! {
/// Overrides EXPERIMENTAL_CUSTOM_ROLES for the tests running on this thread.
pub static CUSTOM_ROLES_ENABLED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
}
}
/// Local methods /// Local methods
impl Organization { impl Organization {
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self { pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
@@ -452,40 +491,36 @@ impl Membership {
} }
} }
/// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A /// Why this legacy Manager can't become a Custom member with `permissions` without losing what they may do now, if
/// conversion must not silently change their access, and a Custom role can't keep access to all collections /// they can't. A conversion must not silently change their access. A Custom role can't keep access to all
/// (which also puts every collection in the Manager's own vault), nor manage any collection yet. /// collections, which also puts every collection in the Manager's own vault, unless the permissions it is shown as
pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> { /// are given up too. And it only manages every collection with Edit any collection, not through a group with access
/// to all collections, which stays dynamic. The collections they manage with the manage permission stay managed.
pub async fn custom_role_conversion_loss(
&self,
permissions: &HashSet<CustomPermission>,
conn: &DbConn,
) -> Option<&'static str> {
if !self.has_type(MembershipType::Manager) { if !self.has_type(MembershipType::Manager) {
return None; return None;
} }
if self.access_all { if self.access_all && permissions.is_superset(&self.legacy_manager_permissions()) {
return Some( return Some(
"This Manager has access to all collections, which a Custom member can't have: the collections they \ "This Manager has access to all collections, which a Custom member can't have: the collections they \
aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \ aren't assigned to would disappear from their vault. Uncheck Manage all collections for them to give \
or keep the Manager role.", that access up, or keep the Manager role.",
); );
} }
// Whatever their status, the collections they manage directly, through a group or through a group with if !permissions.contains(&CustomPermission::EditAnyCollection)
// access to all collections && GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await
let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await {
|| CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) return Some(
.await "This Manager manages every collection through a group with access to all collections, which a Custom \
.iter() member only does with Edit any collection. Grant that permission, or remove them from the group \
.any(|assignment| assignment.manage); first.",
for group_user in GroupUser::find_by_member(&self.uuid, conn).await { );
if manages {
break;
} }
manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn) None
.await
.iter()
.any(|assignment| assignment.manage);
}
manages.then_some(
"This Manager manages collections, with the manage permission or through a group with access to all \
collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.",
)
} }
fn custom_permission(&self, permission: CustomPermission) -> bool { fn custom_permission(&self, permission: CustomPermission) -> bool {
@@ -517,6 +552,41 @@ impl Membership {
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission) self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
} }
/// The permissions the "Manage all collections" a legacy Manager is shown with stands for: their `access_all`.
pub fn legacy_manager_permissions(&self) -> HashSet<CustomPermission> {
if self.has_type(MembershipType::Manager) && self.access_all {
HashSet::from([
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
])
} else {
HashSet::new()
}
}
/// The permissions the clients are shown, which a legacy Manager is shown as a Custom member with.
pub fn shown_custom_permissions(&self) -> HashSet<CustomPermission> {
if self.has_type(MembershipType::Manager) {
self.legacy_manager_permissions()
} else {
CustomPermission::ALL.into_iter().filter(|p| self.grants_custom_permission(*p)).collect()
}
}
fn custom_permissions_json(&self) -> Value {
let shown = self.shown_custom_permissions();
let mut permissions = json!({
"manageSso": false, // Not supported
"manageResetPassword": false,
"manageScim": false, // Not supported (Not AGPLv3 Licensed)
});
for permission in CustomPermission::ALL {
permissions[permission.json_key()] = json!(shown.contains(&permission));
}
permissions
}
pub fn restore(&mut self) -> bool { pub fn restore(&mut self) -> bool {
if self.status < MembershipStatus::Invited as i32 { if self.status < MembershipStatus::Invited as i32 {
self.status += ACTIVATE_REVOKE_DIFF; self.status += ACTIVATE_REVOKE_DIFF;
@@ -685,24 +755,8 @@ impl Membership {
// It will be converted back on other locations // It will be converted back on other locations
let membership_type = self.type_manager_as_custom(); let membership_type = self.type_manager_as_custom();
let permissions = json!({ // A Manager is shown with the 3 Collection permissions set, when they have the access_all permission
// TODO: Add full support for Custom User Roles let permissions = self.custom_permissions_json();
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": membership_type == 4 && self.access_all,
"editAnyCollection": membership_type == 4 && self.access_all,
"deleteAnyCollection": membership_type == 4 && self.access_all,
"manageGroups": false,
"managePolicies": false,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
});
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({ json!({
@@ -832,29 +886,11 @@ impl Membership {
// It will be converted back on other locations // It will be converted back on other locations
let membership_type = self.type_manager_as_custom(); let membership_type = self.type_manager_as_custom();
// HACK: Only return permissions if the user is of type custom and has access_all // Only return permissions if there are any, else Bitwarden will assume the defaults of all false
// Else Bitwarden will assume the defaults of all false let permissions = if self.shown_custom_permissions().is_empty() {
let permissions = if membership_type == 4 && self.access_all {
json!({
// TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": true,
"editAnyCollection": true,
"deleteAnyCollection": true,
"manageGroups": false,
"managePolicies": false,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
} else {
json!(null) json!(null)
} else {
self.custom_permissions_json()
}; };
json!({ json!({
@@ -1042,6 +1078,12 @@ impl Membership {
self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection) self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection)
} }
/// Whether this member may see every collection and who has access to them in the Admin Console. Deleting any
/// collection needs that, but not the items in them.
pub fn can_view_all_collections(&self) -> bool {
self.can_edit_any_collection() || self.has_custom_permission(CustomPermission::DeleteAnyCollection)
}
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok() users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
@@ -1164,6 +1206,8 @@ impl Membership {
} }
// Get all users which are either owner or admin, or a manager which can manage/access all // Get all users which are either owner or admin, or a manager which can manage/access all
// A Custom member with Edit any collection is not listed: the Admin Console sends this list back when a collection
// is saved, which would assign them to it for good, while the others are skipped there for their access_all.
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> { pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
@@ -1484,6 +1528,7 @@ pub struct OrgApiKeyId(String);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use CustomPermission::*;
use MembershipType::{Admin, Custom, Manager, Owner, User}; use MembershipType::{Admin, Custom, Manager, Owner, User};
fn member(role: MembershipType) -> Membership { fn member(role: MembershipType) -> Membership {
@@ -1513,21 +1558,33 @@ mod tests {
#[test] #[test]
fn requested_membership_type() { fn requested_membership_type() {
for (current, disabled, enabled) in [ let none = HashSet::new();
(None, Manager, Custom), let edit = HashSet::from([EditAnyCollection]);
(Some(User), Manager, Custom), let legacy = HashSet::from([CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
(Some(Manager), Manager, Manager), for (current, all, permissions, disabled, enabled) in [
(Some(Custom), Custom, Custom), (None, false, &none, Manager, Custom),
(Some(User), false, &edit, Manager, Custom),
(Some(Owner), false, &none, Manager, Custom),
(Some(Manager), false, &none, Manager, Manager),
(Some(Manager), true, &legacy, Manager, Manager),
(Some(Manager), false, &edit, Manager, Custom),
(Some(Manager), true, &none, Manager, Custom),
(Some(Manager), true, &edit, Manager, Custom),
(Some(Custom), false, &none, Custom, Custom),
] { ] {
let current = current.map(member); let current = current.map(|role| {
for (flag, expected) in [(false, disabled), (true, enabled)] { let mut m = member(role);
assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected); m.access_all = all;
m
});
for (permissions, expected) in [(None, disabled), (Some(permissions), enabled)] {
assert_eq!(MembershipType::requested(Custom, current.as_ref(), permissions), expected);
} }
} }
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User); assert_eq!(MembershipType::requested(User, Some(&member(Custom)), None), User);
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User); assert_eq!(MembershipType::requested(User, Some(&member(Manager)), None), User);
for requested in [Owner, Admin, Manager] { for requested in [Owner, Admin, Manager] {
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested); assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), None), requested);
} }
} }
@@ -1546,7 +1603,7 @@ mod tests {
} }
} }
} }
assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []); assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, [CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
} }
#[test] #[test]
@@ -1573,4 +1630,61 @@ mod tests {
assert_eq!(member.access_all, to != Custom); assert_eq!(member.access_all, to != Custom);
} }
} }
#[test]
fn permission_matrix_preserves_role_status_flag_and_collection_boundaries() {
let legacy = HashSet::from([CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
for (role, all) in
[(Owner, false), (Admin, false), (Manager, false), (Manager, true), (Custom, false), (User, false)]
{
for permission in CustomPermission::ALL {
let mut member = member(role);
member.access_all = all;
*member.custom_permission_mut(permission) = true;
for enabled in [false, true] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
let shown = if role == Manager && all {
legacy.clone()
} else if role == Custom && enabled && permission.is_supported() {
HashSet::from([permission])
} else {
HashSet::new()
};
assert_eq!(member.shown_custom_permissions(), shown);
for status in [-128, -127, -126, -3, -2, -1, 0, 1, 2] {
member.status = status;
let allowed = enabled && role == Custom && status == 2 && permission.is_supported();
let full = status == 2 && (role == Owner || role == Admin || all);
let edit = full || (allowed && permission == EditAnyCollection);
let view = full || (allowed && matches!(permission, EditAnyCollection | DeleteAnyCollection));
assert_eq!(
(
member.has_custom_permission(permission),
member.can_edit_any_collection(),
member.can_view_all_collections()
),
(allowed, edit, view),
"{role:?} {all} {enabled} {status} {permission:?}"
);
}
}
}
}
for raw in [99, -1] {
let mut member = member(Custom);
member.atype = raw;
for p in CustomPermission::ALL {
*member.custom_permission_mut(p) = true;
}
for enabled in [false, true] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
assert!(CustomPermission::ALL.into_iter().all(|p| !member.has_custom_permission(p)));
assert!(!member.can_edit_any_collection() && !member.can_view_all_collections());
}
}
let empty = member(Custom);
assert!(!empty.can_edit_any_collection() && !empty.can_view_all_collections());
assert!(empty.shown_custom_permissions().is_empty());
test_flag::CUSTOM_ROLES_ENABLED.set(None);
}
} }
@@ -99,7 +99,7 @@ app-security > app-two-factor-setup > form {
/* Hide unsupported Custom Role options */ /* Hide unsupported Custom Role options */
{{#if custom_roles_enabled}} {{#if custom_roles_enabled}}
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), /* Only the collection permissions can be granted yet */
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide; @extend %vw-hide;
} }