mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-05 04:48:37 +02:00
Compare commits
1
Commits
main
..
threat_model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad9e5c8c4 |
No files matched your search
@@ -497,15 +497,6 @@
|
|||||||
## KNOW WHAT YOU ARE DOING!
|
## KNOW WHAT YOU ARE DOING!
|
||||||
# ORG_GROUPS_ENABLED=false
|
# ORG_GROUPS_ENABLED=false
|
||||||
|
|
||||||
## EXPERIMENTAL FEATURE: Custom roles
|
|
||||||
## Enables the Custom member role with granular permissions for organizations.
|
|
||||||
## This is still being developed: only the permissions this version enforces can be granted.
|
|
||||||
## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS.
|
|
||||||
## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled.
|
|
||||||
## Disabled by default because this is an experimental feature!
|
|
||||||
## KNOW WHAT YOU ARE DOING!
|
|
||||||
# EXPERIMENTAL_CUSTOM_ROLES=false
|
|
||||||
|
|
||||||
## Increase secure note size limit (Know the risks!)
|
## Increase secure note size limit (Know the risks!)
|
||||||
## Sets the secure note size limit to 100_000 instead of the default 10_000.
|
## Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||||
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
/.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
|
||||||
+25
-9
@@ -8,30 +8,43 @@ notify us. We welcome working with you to resolve the issue promptly. Thanks in
|
|||||||
effort to quickly resolve the issue.
|
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
|
- 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.
|
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
|
- Vaultwarden has no hosted service. Test only on instances you run yourself, and make a good faith effort
|
||||||
degradation of our service. Only interact with accounts you own or with explicit permission of the
|
to avoid privacy violations, destruction of data, and interruption or degradation of other people's
|
||||||
account holder.
|
servers. Only interact with accounts you own or with explicit permission of the account holder.
|
||||||
|
|
||||||
|
# What happens after a report
|
||||||
|
|
||||||
|
This is how we usually handle reports. We decide each one on its own.
|
||||||
|
|
||||||
|
- **It affects a tagged release:** we publish an advisory, request a CVE and list it in the release notes. Advisories are published about 14 days after the release containing the fix, so users have time to update.
|
||||||
|
- **It only ever existed on `main` / `testing`:** we publish an advisory and credit the reporter, but there is no CVE or release notes entry, because no released version was affected.
|
||||||
|
- **Low-impact issues, including low-impact differences from upstream Bitwarden, and most denial of service or resource exhaustion:** we usually fix these as ordinary hardening in a public pull request, with no advisory.
|
||||||
|
- **A bug in the web vault we bundle:** if it's in Bitwarden's code, the advisory and CVE belong to Bitwarden, so report it to them. Tell us as well, and we'll ship a security release that references theirs. If our own patches added it, report it to us.
|
||||||
|
- **Credit** goes on the advisory and needs a GitHub account or email address. If several people report the same issue, we close the later reports and link them to the first advisory, and credit their reporters as finders on it as long as the fix isn't public yet. A fix is public from the moment its pull request is opened, so reports of the same issue after that point are normally not credited.
|
||||||
|
- **Rewards:** Vaultwarden is an open source project and does not offer rewards.
|
||||||
|
|
||||||
# In-scope
|
# In-scope
|
||||||
|
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
|
Our [threat model](THREAT_MODEL.md) describes who is trusted with what, how we rate severity, and behaviour we have already reviewed and consider intended. Please read it before reporting.
|
||||||
|
|
||||||
# Exclusions
|
# Exclusions
|
||||||
|
|
||||||
The following bug classes are out-of scope:
|
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 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
|
- Bugs that are not part of Vaultwarden, like in Bitwarden's web-vault code or the mobile and desktop clients. These issues need to be reported to Bitwarden through their [HackerOne program](https://hackerone.com/bitwarden/). We are not associated with Bitwarden. Bugs that our own web vault patches add are in scope
|
||||||
- Issues in an upstream software dependency (ex: Rust, or External Libraries) which are already reported to the upstream maintainer
|
- Issues in an upstream software dependency (ex: Rust, or External Libraries) or base image that are not shown to be reachable through Vaultwarden
|
||||||
- Attacks requiring physical access to a user's device
|
- Attacks requiring physical access to a user's device
|
||||||
- Issues related to software or protocols not under Vaultwarden's control
|
- Issues related to software or protocols not under Vaultwarden's control, including things attached to it like log parsers (Fail2Ban and similar) and misconfigured reverse proxies, and attacks that need another component to violate its own specification
|
||||||
- Vulnerabilities in outdated versions of Vaultwarden
|
- 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)
|
- 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
|
- Actions of someone who already holds the admin token or has access to the host, which are trusted by design
|
||||||
|
|
||||||
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
|
- Denial of service against servers you do not run. Availability issues that still apply behind a reverse proxy can be reported, so reproduce them on your own instance
|
||||||
- Spamming
|
- Spamming
|
||||||
- Social engineering (including phishing) of Vaultwarden developers, contributors or users
|
- Social engineering (including phishing) of Vaultwarden developers, contributors or users
|
||||||
|
|
||||||
@@ -39,7 +52,7 @@ Thank you for helping keep Vaultwarden and our users safe!
|
|||||||
|
|
||||||
# How to contact us
|
# How to contact us
|
||||||
|
|
||||||
- You can contact us on Matrix https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`)
|
- You can open a [private vulnerability report](https://github.com/dani-garcia/vaultwarden/security/advisories/new) on GitHub.
|
||||||
- You can send an  to report a security issue.<br>
|
- You can send an  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>
|
||||||
@@ -47,3 +60,6 @@ Thank you for helping keep Vaultwarden and our users safe!
|
|||||||
* 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`)
|
||||||
|
|
||||||
|
A good report states the version or commit you tested, the lowest privileges needed, any non-default settings, and the steps to reproduce it.
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
# 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 and handles login, two-factor authentication, organizations, file and text sharing through Send, emergency access, and 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 origin, 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, such as `hide_passwords`, where the client still receives the password, and some organization policies.
|
||||||
|
|
||||||
|
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 email addresses, names, organization names, 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.
|
||||||
|
|
||||||
|
### 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, Send, organization or member data; the admin panel; the internal network through outbound requests |
|
||||||
|
| 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 (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, role changes |
|
||||||
|
| 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 |
|
||||||
|
| Admin panel (`ADMIN_TOKEN`) | All users, organizations and settings. We expect the holder to also have admin access to the machine | No limit once authenticated |
|
||||||
|
| Host operator, database and data folder access | Everything | No limit |
|
||||||
|
| Configured SSO provider, SMTP server, push relay | What they need for their function | Vault contents |
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Optional features that operators turn on carry the same expectations as the defaults: SSO, groups, 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`, `IP_HEADER_TRUSTED_PROXIES=all` on a directly exposed instance, turning off `HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS`, and `SSO_DEBUG_TOKENS`.
|
||||||
|
|
||||||
|
### Upstream Bitwarden
|
||||||
|
|
||||||
|
Where this document does not cover a behaviour, we follow upstream Bitwarden by default.
|
||||||
|
|
||||||
|
## Security issues
|
||||||
|
|
||||||
|
A security issue is a bug in Vaultwarden that breaks these expectations. Examples:
|
||||||
|
|
||||||
|
- someone reading, adding, changing or deleting data that the table above says they must not reach;
|
||||||
|
- an organization member reaching collections they are not assigned to, or a revoked or unconfirmed member reaching organization items or collections;
|
||||||
|
- someone gaining a role or permission they should not have;
|
||||||
|
- getting past an organization policy the server enforces;
|
||||||
|
- a login session or refresh token surviving a password change or security stamp rotation, or a remembered device still skipping 2FA after a 2FA method is removed;
|
||||||
|
- getting past authentication, 2FA or login brute-force protection;
|
||||||
|
- logging in as someone else through SSO;
|
||||||
|
- reading a Send that is disabled or expired, or exceeding its limits;
|
||||||
|
- reaching an emergency access outcome early or out of order;
|
||||||
|
- reaching the admin panel without the token, or acting through an admin's session with script or a cross-site request;
|
||||||
|
- outbound requests reaching the internal network;
|
||||||
|
- attacker-controlled script or HTML served from the vault origin, where the web vault runs and holds decrypted keys;
|
||||||
|
- code execution on the server, or exposure of keys or other server-held secrets, including in logs;
|
||||||
|
- denial of service or resource exhaustion that still applies with a reverse proxy in front.
|
||||||
|
|
||||||
|
## Not security issues
|
||||||
|
|
||||||
|
These are intended behaviour or outside this model. Several match upstream Bitwarden.
|
||||||
|
|
||||||
|
- What the admin panel or host operator can do once authenticated, including an admin token holder 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. Both are by design.
|
||||||
|
- Organization Managers listing basic member details, such as names and emails. Upstream allows this for all members.
|
||||||
|
- `hide_passwords` ("except passwords"). It helps avoid accidental leaks and copy-pastes, but the client still holds the password.
|
||||||
|
- Bugs in Bitwarden's client code, including that code 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. 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` returning KDF settings for any email, with deterministic fake settings for unknown ones.
|
||||||
|
- Login timing not being equalized.
|
||||||
|
- 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.
|
||||||
|
- Organization API tokens staying valid until they expire (1 hour) after the key is rotated.
|
||||||
|
- The personal API key working after a password change until the user rotates it. It is a separate credential.
|
||||||
|
- 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.
|
||||||
|
- The notifications WebSocket accepting a login token until it expires, and keeping open connections, after a password change. It carries change notifications and login requests, not vault data.
|
||||||
|
- Anonymous `clear-token` on `/devices/identifier/<id>/clear-token`. The impact is delayed push delivery.
|
||||||
|
- No server-side cleanup of attachments that were reserved but never uploaded.
|
||||||
|
- The number of items a user can store. There is no limit, by design.
|
||||||
|
- Log injection. Changing log lines has no effect on Vaultwarden and only matters to third-party software parsing them, such as Fail2Ban, including filters from our community wiki.
|
||||||
|
- Reverse proxy misconfiguration, such as a proxy not sending `X-Forwarded-Proto`, which decides the admin cookie's `Secure` flag.
|
||||||
|
- Attacks that need another component to break its own specification.
|
||||||
|
- 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.
|
||||||
|
- Weak KDF settings a user chooses for their own account.
|
||||||
|
- File permissions in the data folder. Host security belongs to the operator.
|
||||||
|
- The WebSocket `access_token` query parameter. Clients send it that way, and our hardening guide covers log redaction.
|
||||||
|
- Dependency and base image CVEs without a path reachable through Vaultwarden.
|
||||||
|
- Missing best practices with no demonstrated impact, such as headers, `style-src 'unsafe-inline'` (the web vault needs it) and version disclosure.
|
||||||
|
- Outdated versions.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
|
||||||
|
We rate issues by what the attacker holds at the end (plaintext, ciphertext they have the key for, ciphertext they have no key for, or the power to write or delete, which needs no key) and by the access they needed first. An attack that needs no account usually rates higher than one that does. This applies to new issues. Older advisories may be rated differently.
|
||||||
|
|
||||||
|
Denial of service and resource exhaustion only count if they still apply with a reverse proxy in front. We do not consider them a serious problem in practice, so they are never High and Medium should be rare.
|
||||||
|
|
||||||
|
Secrets written to logs are rated by what the secret gives someone who reads the logs and how long it stays valid. Reading logs does not assume host access.
|
||||||
|
|
||||||
|
### Critical
|
||||||
|
|
||||||
|
Anything that lets someone read another account's data decrypted is Critical by default, whether or not they have an account of their own. That covers:
|
||||||
|
|
||||||
|
- attacker-controlled script running on the vault origin for other users during normal use, caused by how Vaultwarden stores or serves content;
|
||||||
|
- code execution on the server by anyone other than an admin token holder, because it can change the web vault the server serves;
|
||||||
|
- exposing key material that lets someone else decrypt an account's data;
|
||||||
|
- anyone other than the named grantee reaching an emergency access outcome.
|
||||||
|
|
||||||
|
Forging login or admin tokens, reading the JWT signing key, reaching the admin panel without the token, and taking over arbitrary accounts with no action by the victim are also Critical when no account is needed.
|
||||||
|
|
||||||
|
### High
|
||||||
|
|
||||||
|
- Decrypted reads within the same organization, including a member reaching collections they are not assigned to, or a revoked member who kept the key. Members share one key and have some trust in each other, and the scope is limited to that organization.
|
||||||
|
- Changing or deleting another user's or organization's existing data.
|
||||||
|
- Reading another user's server-held secrets, such as their account's 2FA secrets or API keys.
|
||||||
|
- Reading another user's stored master password hash or encrypted user key, which allow offline guessing of the master password.
|
||||||
|
- SSRF that returns the response to the attacker.
|
||||||
|
- Escalating within an organization with real effect on data or members.
|
||||||
|
- Account takeover through SSO, unless it meets the Critical conditions above.
|
||||||
|
- The named emergency access grantee reaching the outcome early. The grantor chose them and the scope is one account.
|
||||||
|
- Script or a cross-site request acting through an admin's session without them noticing. This includes script that only runs in the admin panel. It is Critical if it reaches code execution or the files the server serves.
|
||||||
|
- Attacker-controlled script on the vault origin that needs unusual victim interaction.
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
|
||||||
|
- Ciphertext read by someone with no key.
|
||||||
|
- Defeating login brute-force protection. The attacker still has to guess the password, and get past 2FA if the victim has it.
|
||||||
|
- 2FA bypass that needs the password, and a login session or refresh token surviving a password change or security stamp rotation.
|
||||||
|
- Getting past an organization policy the server enforces, such as required 2FA or single organization.
|
||||||
|
- Adding data to another user's or organization's space without permission, such as a read-only member adding items.
|
||||||
|
- Data loss for another user 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 or resource exhaustion from one request with no account needed, in the default configuration, where the denial stays after the attacker stops until someone has to act, such as by restarting the server or repairing data.
|
||||||
|
|
||||||
|
### Low
|
||||||
|
|
||||||
|
These may be handled as ordinary hardening in some cases.
|
||||||
|
|
||||||
|
- Behaviour that differs from upstream Bitwarden with low impact.
|
||||||
|
- Bypassing a non-default limit (`USER_ATTACHMENT_LIMIT`, `ORG_ATTACHMENT_LIMIT`, `USER_SEND_LIMIT`).
|
||||||
|
- Vaultwarden mishandling the headers of a correctly configured proxy (`IP_HEADER`, `IP_HEADER_TRUSTED_PROXIES`), even if that weakens rate limits.
|
||||||
|
- 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, with no other effect.
|
||||||
|
- Forging organization audit events, as long as existing events cannot be modified.
|
||||||
|
- Other ways to tell whether an email has an account, apart from the prelogin and login timing cases under "Not security issues".
|
||||||
|
- Denial of service or resource exhaustion from one operation that needs an account or a non-default setting, where the denial stays after the attacker stops until someone has to act.
|
||||||
|
- Denial of service or resource exhaustion that lasts only while the attacker keeps going, or that recovers by itself, such as a crash the container restarts from or a panic that yields a 500 with no lasting effect. We handle these as hardening.
|
||||||
|
|
||||||
|
### CVSS
|
||||||
|
|
||||||
|
Our ratings can differ from CVSS 3.1, because its scoring parameters don't translate well to an end-to-end encrypted system.
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
|
||||||
-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything
|
|
||||||
-- is dropped: change their role first, or restore the backup taken before the upgrade.
|
|
||||||
-- A failed attempt leaves the temporary table on this connection. Clear it before retrying.
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade;
|
|
||||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
|
||||||
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
|
||||||
INSERT INTO custom_role_downgrade
|
|
||||||
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
|
||||||
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
|
||||||
DROP TEMPORARY TABLE custom_role_downgrade;
|
|
||||||
|
|
||||||
ALTER TABLE users_organizations
|
|
||||||
DROP COLUMN manage_users,
|
|
||||||
DROP COLUMN manage_groups,
|
|
||||||
DROP COLUMN manage_policies,
|
|
||||||
DROP COLUMN create_new_collections,
|
|
||||||
DROP COLUMN edit_any_collection,
|
|
||||||
DROP COLUMN delete_any_collection,
|
|
||||||
DROP COLUMN access_event_logs,
|
|
||||||
DROP COLUMN access_import_export,
|
|
||||||
DROP COLUMN access_reports;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
|
||||||
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
|
||||||
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
|
|
||||||
ALTER TABLE users_organizations
|
|
||||||
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
|
||||||
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
|
|
||||||
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
|
|
||||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
|
||||||
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
|
||||||
INSERT INTO custom_role_downgrade
|
|
||||||
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
|
||||||
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
|
||||||
DROP TABLE custom_role_downgrade;
|
|
||||||
|
|
||||||
ALTER TABLE users_organizations
|
|
||||||
DROP COLUMN manage_users,
|
|
||||||
DROP COLUMN manage_groups,
|
|
||||||
DROP COLUMN manage_policies,
|
|
||||||
DROP COLUMN create_new_collections,
|
|
||||||
DROP COLUMN edit_any_collection,
|
|
||||||
DROP COLUMN delete_any_collection,
|
|
||||||
DROP COLUMN access_event_logs,
|
|
||||||
DROP COLUMN access_import_export,
|
|
||||||
DROP COLUMN access_reports;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
|
||||||
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
|
||||||
-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind.
|
|
||||||
ALTER TABLE users_organizations
|
|
||||||
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the
|
|
||||||
-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their
|
|
||||||
-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does.
|
|
||||||
CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY);
|
|
||||||
INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade');
|
|
||||||
INSERT INTO custom_role_downgrade
|
|
||||||
SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade'
|
|
||||||
FROM users_organizations WHERE atype = 4 LIMIT 1;
|
|
||||||
DROP TABLE custom_role_downgrade;
|
|
||||||
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN manage_users;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN manage_groups;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN manage_policies;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN access_import_export;
|
|
||||||
ALTER TABLE users_organizations DROP COLUMN access_reports;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- The granular permissions of the Custom member role. They only take effect on a Custom membership
|
|
||||||
-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any.
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
|
||||||
+6
-32
@@ -1,4 +1,4 @@
|
|||||||
use std::{collections::HashMap, env, sync::LazyLock};
|
use std::{env, sync::LazyLock};
|
||||||
|
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
@@ -26,7 +26,6 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||||
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||||
custom_roles_enabled,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
@@ -257,7 +256,6 @@ struct AdminTemplateData {
|
|||||||
logged_in: bool,
|
logged_in: bool,
|
||||||
urlpath: String,
|
urlpath: String,
|
||||||
sso_enabled: bool,
|
sso_enabled: bool,
|
||||||
custom_roles_enabled: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminTemplateData {
|
impl AdminTemplateData {
|
||||||
@@ -268,7 +266,6 @@ impl AdminTemplateData {
|
|||||||
logged_in: true,
|
logged_in: true,
|
||||||
urlpath: CONFIG.domain_path(),
|
urlpath: CONFIG.domain_path(),
|
||||||
sso_enabled: CONFIG.sso_enabled(),
|
sso_enabled: CONFIG.sso_enabled(),
|
||||||
custom_roles_enabled: custom_roles_enabled(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,18 +390,6 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
|
|||||||
|
|
||||||
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
||||||
|
|
||||||
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
|
|
||||||
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
|
|
||||||
.await
|
|
||||||
.into_iter()
|
|
||||||
.map(|member| (member.org_uuid.to_string(), member.atype))
|
|
||||||
.collect();
|
|
||||||
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
|
|
||||||
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
|
|
||||||
org["type"] = json!(atype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
users_json.push(usr);
|
users_json.push(usr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,14 +565,10 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
err!("The specified user isn't member of the organization")
|
err!("The specified user isn't member of the organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
||||||
// Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
|
new_type as i32
|
||||||
// are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
|
} else {
|
||||||
Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
|
err!("Invalid type")
|
||||||
MembershipType::Manager
|
|
||||||
}
|
|
||||||
Some(new_type) => new_type,
|
|
||||||
None => err!("Invalid type"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||||
@@ -597,14 +578,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Manager only becomes a Custom member if that changes nothing they may do
|
member_to_edit.atype = new_type;
|
||||||
if new_type == MembershipType::Custom
|
|
||||||
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await
|
|
||||||
{
|
|
||||||
err!(loss)
|
|
||||||
}
|
|
||||||
|
|
||||||
member_to_edit.set_type(new_type);
|
|
||||||
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
||||||
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
|
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
||||||
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
|
EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
|
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
|
||||||
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
|
OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -1008,12 +1008,14 @@ async fn send_invite(
|
|||||||
data.validate(&org_id, &conn).await?;
|
data.validate(&org_id, &conn).await?;
|
||||||
|
|
||||||
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
||||||
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
// The from_str() will convert the custom role type into a manager role type
|
||||||
let raw_type = &data.r#type.into_string();
|
let raw_type = &data.r#type.into_string();
|
||||||
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
// Membership::from_str will convert custom (4) to manager (3)
|
||||||
|
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
|
||||||
|
new_type as i32
|
||||||
|
} else {
|
||||||
err!("Invalid type")
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled());
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -1023,19 +1025,11 @@ async fn send_invite(
|
|||||||
// 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
|
||||||
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
||||||
let access_all = new_type >= MembershipType::Admin
|
let access_all = new_type >= MembershipType::Admin
|
||||||
|| (new_type == MembershipType::Manager
|
|| (raw_type.eq("4")
|
||||||
&& raw_type.eq("4")
|
|
||||||
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
||||||
&& 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;
|
||||||
@@ -1077,11 +1071,8 @@ async fn send_invite(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
||||||
new_member.set_type(new_type);
|
|
||||||
new_member.access_all = access_all;
|
new_member.access_all = access_all;
|
||||||
if let Some(permissions) = &custom_permissions {
|
new_member.atype = new_type;
|
||||||
new_member.set_custom_permissions(permissions);
|
|
||||||
}
|
|
||||||
new_member.status = member_status;
|
new_member.status = member_status;
|
||||||
new_member.save(&conn).await?;
|
new_member.save(&conn).await?;
|
||||||
|
|
||||||
@@ -1517,32 +1508,24 @@ async fn edit_member(
|
|||||||
let data: EditUserData = data.into_inner();
|
let data: EditUserData = data.into_inner();
|
||||||
|
|
||||||
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
||||||
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
// The from_str() will convert the custom role type into a manager role type
|
||||||
let raw_type = &data.r#type.into_string();
|
let raw_type = &data.r#type.into_string();
|
||||||
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
// MembershipType::from_str will convert custom (4) to manager (3)
|
||||||
|
let Some(new_type) = MembershipType::from_str(raw_type) else {
|
||||||
err!("Invalid type")
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
|
||||||
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")
|
|
||||||
};
|
|
||||||
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled());
|
|
||||||
|
|
||||||
// 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
|
||||||
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
||||||
let access_all = new_type >= MembershipType::Admin
|
let access_all = new_type >= MembershipType::Admin
|
||||||
|| (new_type == MembershipType::Manager
|
|| (raw_type.eq("4")
|
||||||
&& raw_type.eq("4")
|
|
||||||
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
||||||
&& 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 Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
|
||||||
let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
|
err!("The specified user isn't member of the organization")
|
||||||
Some(CustomPermission::requested(&data.permissions)?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_type != member_to_edit.atype
|
if new_type != member_to_edit.atype
|
||||||
@@ -1566,11 +1549,8 @@ async fn edit_member(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
member_to_edit.atype = new_type as i32;
|
||||||
member_to_edit.set_custom_permissions(permissions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||||
// We need to perform the check after changing the type since `admin` is exempt.
|
// We need to perform the check after changing the type since `admin` is exempt.
|
||||||
|
|||||||
+1
-2
@@ -20,7 +20,7 @@ use crate::{
|
|||||||
crypto::sha256_hex,
|
crypto::sha256_hex,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AttachmentId, CipherId, custom_roles_enabled},
|
models::{AttachmentId, CipherId},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
util::{Cached, EtagCached},
|
util::{Cached, EtagCached},
|
||||||
@@ -90,7 +90,6 @@ fn vaultwarden_css() -> EtagCached<String> {
|
|||||||
|
|
||||||
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
||||||
let css_options = json!({
|
let css_options = json!({
|
||||||
"custom_roles_enabled": custom_roles_enabled(),
|
|
||||||
"email_change_allowed": CONFIG.email_change_allowed(),
|
"email_change_allowed": CONFIG.email_change_allowed(),
|
||||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||||
"load_user_scss": true,
|
"load_user_scss": true,
|
||||||
|
|||||||
@@ -261,10 +261,6 @@ macro_rules! make_config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn from_file() -> Result<Option<Self>, Error> {
|
async fn from_file() -> Result<Option<Self>, Error> {
|
||||||
// The Fs operator would create the folder if it doesn't exist, which we never want
|
|
||||||
if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||||
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
|
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
@@ -821,10 +817,6 @@ make_config! {
|
|||||||
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
||||||
org_groups_enabled: bool, false, def, false;
|
org_groups_enabled: bool, false, def, false;
|
||||||
|
|
||||||
/// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations.
|
|
||||||
/// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled.
|
|
||||||
experimental_custom_roles: bool, false, def, false;
|
|
||||||
|
|
||||||
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
|
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||||
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||||
increase_note_size_limit: bool, true, def, false;
|
increase_note_size_limit: bool, true, def, false;
|
||||||
|
|||||||
@@ -534,74 +534,3 @@ mod postgresql_migrations {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A throwaway SQLite database with the real schema, for tests that need to run queries.
|
|
||||||
#[cfg(all(test, sqlite))]
|
|
||||||
pub mod test_db {
|
|
||||||
use super::sqlite_migrations::MIGRATIONS;
|
|
||||||
use diesel::connection::SimpleConnection;
|
|
||||||
use diesel_migrations::MigrationHarness;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_role_migration_preserves_memberships_and_guards_downgrade() {
|
|
||||||
use super::schema::users_organizations::dsl::*;
|
|
||||||
use diesel::{migration::MigrationSource, prelude::*};
|
|
||||||
let mut conn = SqliteConnection::establish(":memory:").unwrap();
|
|
||||||
for migration in conn.pending_migrations(MIGRATIONS).unwrap() {
|
|
||||||
if migration.name().version().to_string() == "20261010120000" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
conn.run_migration(&migration).unwrap();
|
|
||||||
}
|
|
||||||
conn.batch_execute(
|
|
||||||
"PRAGMA foreign_keys=OFF;
|
|
||||||
INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b');
|
|
||||||
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES
|
|
||||||
('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1),
|
|
||||||
('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let rows = |conn: &mut SqliteConnection| {
|
|
||||||
users_organizations
|
|
||||||
.select((uuid, user_uuid, org_uuid, access_all, akey, status, atype))
|
|
||||||
.order(uuid)
|
|
||||||
.load::<(String, String, String, bool, String, i32, i32)>(conn)
|
|
||||||
.unwrap()
|
|
||||||
};
|
|
||||||
let before = rows(&mut conn);
|
|
||||||
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
|
||||||
assert_eq!(rows(&mut conn), before);
|
|
||||||
let grants = |conn: &mut SqliteConnection| {
|
|
||||||
diesel::select(diesel::dsl::sql::<diesel::sql_types::BigInt>(
|
|
||||||
"(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies
|
|
||||||
OR create_new_collections OR edit_any_collection OR delete_any_collection
|
|
||||||
OR access_event_logs OR access_import_export OR access_reports)",
|
|
||||||
))
|
|
||||||
.get_result::<i64>(conn)
|
|
||||||
.unwrap()
|
|
||||||
};
|
|
||||||
assert_eq!(grants(&mut conn), 0);
|
|
||||||
let migration = MigrationSource::<diesel::sqlite::Sqlite>::migrations(&MIGRATIONS)
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.find(|m| m.name().version().to_string() == "20261010120000")
|
|
||||||
.unwrap();
|
|
||||||
for member_status in [1, 2] {
|
|
||||||
conn.batch_execute(&format!(
|
|
||||||
"UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'"
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let custom = rows(&mut conn);
|
|
||||||
assert!(conn.revert_migration(&*migration).is_err());
|
|
||||||
assert_eq!(rows(&mut conn), custom);
|
|
||||||
assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::<bool>(&mut conn).unwrap());
|
|
||||||
}
|
|
||||||
conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'")
|
|
||||||
.unwrap();
|
|
||||||
conn.revert_migration(&*migration).unwrap();
|
|
||||||
assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err());
|
|
||||||
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
|
||||||
assert_eq!(grants(&mut conn), 0);
|
|
||||||
assert_eq!(rows(&mut conn), before);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@ 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, MembershipType, OrganizationId, User, UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -872,7 +872,7 @@ impl Cipher {
|
|||||||
|
|
||||||
if !visible_only {
|
if !visible_only {
|
||||||
query = query.or_filter(
|
query = query.or_filter(
|
||||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,7 +907,7 @@ impl Cipher {
|
|||||||
|
|
||||||
if !visible_only {
|
if !visible_only {
|
||||||
query = query.or_filter(
|
query = query.or_filter(
|
||||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,7 +1111,7 @@ 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.le(MembershipType::Admin as i32)), // User is admin or owner
|
||||||
)
|
)
|
||||||
.select(ciphers_collections::collection_uuid)
|
.select(ciphers_collections::collection_uuid)
|
||||||
.load::<CollectionId>(conn)
|
.load::<CollectionId>(conn)
|
||||||
@@ -1140,7 +1140,7 @@ 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.le(MembershipType::Admin as i32)), // User is admin or owner
|
||||||
)
|
)
|
||||||
.select(ciphers_collections::collection_uuid)
|
.select(ciphers_collections::collection_uuid)
|
||||||
.load::<CollectionId>(conn)
|
.load::<CollectionId>(conn)
|
||||||
@@ -1182,7 +1182,7 @@ impl Cipher {
|
|||||||
)
|
)
|
||||||
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
|
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
|
||||||
.or_filter(users_organizations::access_all.eq(true)) // User has access all
|
.or_filter(users_organizations::access_all.eq(true)) // User has access all
|
||||||
.or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
|
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
||||||
.or_filter(groups::access_all.eq(true)) //Access via group
|
.or_filter(groups::access_all.eq(true)) //Access via group
|
||||||
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
||||||
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
@@ -375,7 +375,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.or(
|
.or(
|
||||||
@@ -412,7 +412,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
.select(collections::all_columns)
|
.select(collections::all_columns)
|
||||||
@@ -455,7 +455,7 @@ impl Collection {
|
|||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::atype
|
users_organizations::atype
|
||||||
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
|
.le(MembershipType::Admin as i32) // Org admin or owner
|
||||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||||
.or(users_collections::collection_uuid
|
.or(users_collections::collection_uuid
|
||||||
.eq(&self.uuid) // write access given to collection
|
.eq(&self.uuid) // write access given to collection
|
||||||
@@ -489,7 +489,7 @@ impl Collection {
|
|||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
users_organizations::atype
|
users_organizations::atype
|
||||||
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
|
.le(MembershipType::Admin as i32) // Org admin or owner
|
||||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||||
.or(users_collections::collection_uuid
|
.or(users_collections::collection_uuid
|
||||||
.eq(&self.uuid) // write access given to collection
|
.eq(&self.uuid) // write access given to collection
|
||||||
@@ -540,7 +540,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.or(
|
.or(
|
||||||
@@ -600,7 +600,7 @@ impl Collection {
|
|||||||
// Directly accessed collection
|
// Directly accessed collection
|
||||||
users_organizations::access_all.eq(true).or(
|
users_organizations::access_all.eq(true).or(
|
||||||
// access_all in Organization
|
// access_all in Organization
|
||||||
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.or(
|
.or(
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ 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};
|
||||||
pub use self::organization::{
|
pub use self::organization::{
|
||||||
CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
||||||
OrganizationApiKey, OrganizationId, custom_roles_enabled,
|
OrganizationId,
|
||||||
};
|
};
|
||||||
pub use self::send::{Send, SendFileId, SendId, SendType};
|
pub use self::send::{Send, SendFileId, SendId, SendType};
|
||||||
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
||||||
|
|||||||
+24
-319
@@ -1,7 +1,4 @@
|
|||||||
use std::{
|
use std::cmp::Ordering;
|
||||||
cmp::Ordering,
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
};
|
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
@@ -24,8 +21,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)]
|
||||||
@@ -44,7 +41,6 @@ pub struct Organization {
|
|||||||
#[diesel(table_name = users_organizations)]
|
#[diesel(table_name = users_organizations)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
#[diesel(primary_key(uuid))]
|
#[diesel(primary_key(uuid))]
|
||||||
#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")]
|
|
||||||
pub struct Membership {
|
pub struct Membership {
|
||||||
pub uuid: MembershipId,
|
pub uuid: MembershipId,
|
||||||
pub user_uuid: UserId,
|
pub user_uuid: UserId,
|
||||||
@@ -58,17 +54,6 @@ pub struct Membership {
|
|||||||
pub atype: i32,
|
pub atype: i32,
|
||||||
pub reset_password_key: Option<String>,
|
pub reset_password_key: Option<String>,
|
||||||
pub external_id: Option<String>,
|
pub external_id: Option<String>,
|
||||||
|
|
||||||
// The permissions of a Custom role, only accessed through `CustomPermission`.
|
|
||||||
manage_users: bool,
|
|
||||||
manage_groups: bool,
|
|
||||||
manage_policies: bool,
|
|
||||||
create_new_collections: bool,
|
|
||||||
edit_any_collection: bool,
|
|
||||||
delete_any_collection: bool,
|
|
||||||
access_event_logs: bool,
|
|
||||||
access_import_export: bool,
|
|
||||||
access_reports: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -104,68 +89,42 @@ impl MembershipStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)]
|
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
|
||||||
pub enum MembershipType {
|
pub enum MembershipType {
|
||||||
Owner = 0,
|
Owner = 0,
|
||||||
Admin = 1,
|
Admin = 1,
|
||||||
User = 2,
|
User = 2,
|
||||||
// The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead.
|
|
||||||
Manager = 3,
|
Manager = 3,
|
||||||
// Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled.
|
|
||||||
Custom = 4,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MembershipType {
|
impl MembershipType {
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
#[expect(
|
||||||
|
clippy::match_same_arms,
|
||||||
|
reason = "Specifically define `4|Custom` since this is a hack, not a default"
|
||||||
|
)]
|
||||||
match s {
|
match s {
|
||||||
"0" | "Owner" => Some(MembershipType::Owner),
|
"0" | "Owner" => Some(MembershipType::Owner),
|
||||||
"1" | "Admin" => Some(MembershipType::Admin),
|
"1" | "Admin" => Some(MembershipType::Admin),
|
||||||
"2" | "User" => Some(MembershipType::User),
|
"2" | "User" => Some(MembershipType::User),
|
||||||
"3" | "Manager" => Some(MembershipType::Manager),
|
"3" | "Manager" => Some(MembershipType::Manager),
|
||||||
"4" | "Custom" => Some(MembershipType::Custom),
|
// HACK: We convert the custom role to a manager role
|
||||||
|
"4" | "Custom" => Some(MembershipType::Manager),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The role a member invite or edit from the Bitwarden clients asks for, given the member's current
|
|
||||||
/// 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
|
|
||||||
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing
|
|
||||||
/// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never
|
|
||||||
/// turns one into the other.
|
|
||||||
pub fn requested(
|
|
||||||
requested: MembershipType,
|
|
||||||
current: Option<&Membership>,
|
|
||||||
custom_roles_enabled: bool,
|
|
||||||
) -> MembershipType {
|
|
||||||
if requested != MembershipType::Custom {
|
|
||||||
return requested;
|
|
||||||
}
|
|
||||||
match current {
|
|
||||||
Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
|
|
||||||
Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
|
|
||||||
_ if custom_roles_enabled => MembershipType::Custom,
|
|
||||||
_ => MembershipType::Manager,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The authority of a role, which is what roles are compared by. A Custom member gets its authority from
|
|
||||||
/// its permissions, never from its role, so the role itself ranks right above User and below Manager.
|
|
||||||
const fn rank(self) -> u8 {
|
|
||||||
match self {
|
|
||||||
MembershipType::User => 0,
|
|
||||||
MembershipType::Custom => 1,
|
|
||||||
MembershipType::Manager => 2,
|
|
||||||
MembershipType::Admin => 3,
|
|
||||||
MembershipType::Owner => 4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ord for MembershipType {
|
impl Ord for MembershipType {
|
||||||
fn cmp(&self, other: &MembershipType) -> Ordering {
|
fn cmp(&self, other: &MembershipType) -> Ordering {
|
||||||
self.rank().cmp(&other.rank())
|
// For easy comparison, map each variant to an access level (where 0 is lowest).
|
||||||
|
const ACCESS_LEVEL: [i32; 4] = [
|
||||||
|
3, // Owner
|
||||||
|
2, // Admin
|
||||||
|
0, // User
|
||||||
|
1, // Manager && Custom
|
||||||
|
];
|
||||||
|
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,91 +180,6 @@ impl PartialOrd<MembershipType> for i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The granular permissions of the Custom role.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub enum CustomPermission {
|
|
||||||
ManageUsers,
|
|
||||||
ManageGroups,
|
|
||||||
ManagePolicies,
|
|
||||||
CreateNewCollections,
|
|
||||||
EditAnyCollection,
|
|
||||||
DeleteAnyCollection,
|
|
||||||
AccessEventLogs,
|
|
||||||
AccessImportExport,
|
|
||||||
AccessReports,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[];
|
|
||||||
|
|
||||||
impl CustomPermission {
|
|
||||||
pub const ALL: [CustomPermission; 9] = [
|
|
||||||
CustomPermission::ManageUsers,
|
|
||||||
CustomPermission::ManageGroups,
|
|
||||||
CustomPermission::ManagePolicies,
|
|
||||||
CustomPermission::CreateNewCollections,
|
|
||||||
CustomPermission::EditAnyCollection,
|
|
||||||
CustomPermission::DeleteAnyCollection,
|
|
||||||
CustomPermission::AccessEventLogs,
|
|
||||||
CustomPermission::AccessImportExport,
|
|
||||||
CustomPermission::AccessReports,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The key of the permission in the `permissions` object of the Bitwarden clients.
|
|
||||||
pub const fn json_key(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
CustomPermission::ManageUsers => "manageUsers",
|
|
||||||
CustomPermission::ManageGroups => "manageGroups",
|
|
||||||
CustomPermission::ManagePolicies => "managePolicies",
|
|
||||||
CustomPermission::CreateNewCollections => "createNewCollections",
|
|
||||||
CustomPermission::EditAnyCollection => "editAnyCollection",
|
|
||||||
CustomPermission::DeleteAnyCollection => "deleteAnyCollection",
|
|
||||||
CustomPermission::AccessEventLogs => "accessEventLogs",
|
|
||||||
CustomPermission::AccessImportExport => "accessImportExport",
|
|
||||||
CustomPermission::AccessReports => "accessReports",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_supported(self) -> bool {
|
|
||||||
SUPPORTED_CUSTOM_PERMISSIONS.contains(&self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The permissions a member invite or edit grants a Custom role.
|
|
||||||
///
|
|
||||||
/// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this
|
|
||||||
/// replaces the member's permissions, so a malformed value must not silently remove one. Granting a
|
|
||||||
/// permission this version does not enforce is rejected as well. The clients also send permissions
|
|
||||||
/// Vaultwarden does not know at all, like `manageSso`, those are ignored.
|
|
||||||
pub fn requested(permissions: &HashMap<String, Value>) -> Result<HashSet<CustomPermission>, crate::Error> {
|
|
||||||
let mut granted = HashSet::new();
|
|
||||||
for permission in CustomPermission::ALL {
|
|
||||||
match permissions.get(permission.json_key()) {
|
|
||||||
None | Some(Value::Bool(false)) => {}
|
|
||||||
Some(Value::Bool(true)) if permission.is_supported() => {
|
|
||||||
granted.insert(permission);
|
|
||||||
}
|
|
||||||
Some(Value::Bool(true)) => {
|
|
||||||
err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key()))
|
|
||||||
}
|
|
||||||
Some(_) => {
|
|
||||||
err!(format!("The Custom role permission '{}' must be true or false", permission.json_key()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The stored roles with authority over the whole organization. Queries match them by value, comparing
|
|
||||||
/// `atype <= Admin` would also match any unknown value below it.
|
|
||||||
pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32];
|
|
||||||
|
|
||||||
/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise.
|
|
||||||
pub fn custom_roles_enabled() -> bool {
|
|
||||||
CONFIG.experimental_custom_roles()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -397,92 +271,9 @@ impl Membership {
|
|||||||
atype: MembershipType::User as i32,
|
atype: MembershipType::User as i32,
|
||||||
reset_password_key: None,
|
reset_password_key: None,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
|
|
||||||
manage_users: false,
|
|
||||||
manage_groups: false,
|
|
||||||
manage_policies: false,
|
|
||||||
create_new_collections: false,
|
|
||||||
edit_any_collection: false,
|
|
||||||
delete_any_collection: false,
|
|
||||||
access_event_logs: false,
|
|
||||||
access_import_export: false,
|
|
||||||
access_reports: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool {
|
|
||||||
match permission {
|
|
||||||
CustomPermission::ManageUsers => &mut self.manage_users,
|
|
||||||
CustomPermission::ManageGroups => &mut self.manage_groups,
|
|
||||||
CustomPermission::ManagePolicies => &mut self.manage_policies,
|
|
||||||
CustomPermission::CreateNewCollections => &mut self.create_new_collections,
|
|
||||||
CustomPermission::EditAnyCollection => &mut self.edit_any_collection,
|
|
||||||
CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection,
|
|
||||||
CustomPermission::AccessEventLogs => &mut self.access_event_logs,
|
|
||||||
CustomPermission::AccessImportExport => &mut self.access_import_export,
|
|
||||||
CustomPermission::AccessReports => &mut self.access_reports,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are
|
|
||||||
/// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy
|
|
||||||
/// `access_all` flag, its reach only comes from its permissions.
|
|
||||||
pub fn set_type(&mut self, new_type: MembershipType) {
|
|
||||||
if self.atype != new_type as i32 {
|
|
||||||
for permission in CustomPermission::ALL {
|
|
||||||
*self.custom_permission_mut(permission) = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if new_type == MembershipType::Custom {
|
|
||||||
self.access_all = false;
|
|
||||||
}
|
|
||||||
self.atype = new_type as i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a
|
|
||||||
/// permission this version does not enforce is kept as it is: it grants nothing here.
|
|
||||||
pub fn set_custom_permissions(&mut self, permissions: &HashSet<CustomPermission>) {
|
|
||||||
for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) {
|
|
||||||
*self.custom_permission_mut(permission) = permissions.contains(&permission);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A
|
|
||||||
/// conversion must not silently change their access, and a Custom role can't keep access to all collections
|
|
||||||
/// (which also puts every collection in the Manager's own vault), nor manage any collection yet.
|
|
||||||
pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> {
|
|
||||||
if !self.has_type(MembershipType::Manager) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if self.access_all {
|
|
||||||
return Some(
|
|
||||||
"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, \
|
|
||||||
or keep the Manager role.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Whatever their status, the collections they manage directly, through a group or through a group with
|
|
||||||
// access to all collections
|
|
||||||
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)
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.any(|assignment| assignment.manage);
|
|
||||||
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)
|
|
||||||
.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.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -921,12 +712,6 @@ impl Membership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||||
// The legacy `access_all` flag reaches every collection, which a Custom role may only do through its
|
|
||||||
// permissions, so it must never be stored on one. `set_type` takes care of this.
|
|
||||||
if self.has_type(MembershipType::Custom) && self.access_all {
|
|
||||||
err!("A Custom role membership can't have access to all collections")
|
|
||||||
}
|
|
||||||
|
|
||||||
User::update_uuid_revision(&self.user_uuid, conn).await;
|
User::update_uuid_revision(&self.user_uuid, conn).await;
|
||||||
|
|
||||||
db_run! { conn:
|
db_run! { conn:
|
||||||
@@ -1443,93 +1228,13 @@ pub struct OrgApiKeyId(String);
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use MembershipType::{Admin, Custom, Manager, Owner, User};
|
|
||||||
|
|
||||||
fn member(role: MembershipType) -> Membership {
|
|
||||||
let mut member = Membership::new("user".into(), "org".into(), None);
|
|
||||||
member.atype = role as i32;
|
|
||||||
member.status = MembershipStatus::Confirmed as i32;
|
|
||||||
member
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn membership_type_authority() {
|
#[allow(non_snake_case)]
|
||||||
assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
|
fn partial_cmp_MembershipType() {
|
||||||
assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
|
assert!(MembershipType::Owner > MembershipType::Admin);
|
||||||
for raw in [99, -1] {
|
assert!(MembershipType::Admin > MembershipType::Manager);
|
||||||
assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
|
assert!(MembershipType::Manager > MembershipType::User);
|
||||||
}
|
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
|
||||||
for (value, expected) in [
|
|
||||||
("3", Some(Manager)),
|
|
||||||
("Manager", Some(Manager)),
|
|
||||||
("4", Some(Custom)),
|
|
||||||
("Custom", Some(Custom)),
|
|
||||||
("5", None),
|
|
||||||
] {
|
|
||||||
assert_eq!(MembershipType::from_str(value), expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn requested_membership_type() {
|
|
||||||
for (current, disabled, enabled) in [
|
|
||||||
(None, Manager, Custom),
|
|
||||||
(Some(User), Manager, Custom),
|
|
||||||
(Some(Manager), Manager, Manager),
|
|
||||||
(Some(Custom), Custom, Custom),
|
|
||||||
] {
|
|
||||||
let current = current.map(member);
|
|
||||||
for (flag, expected) in [(false, disabled), (true, enabled)] {
|
|
||||||
assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User);
|
|
||||||
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User);
|
|
||||||
for requested in [Owner, Admin, Manager] {
|
|
||||||
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_permission_requests() {
|
|
||||||
let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap();
|
|
||||||
assert!(CustomPermission::requested(&ignored).unwrap().is_empty());
|
|
||||||
for permission in CustomPermission::ALL {
|
|
||||||
for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] {
|
|
||||||
let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]);
|
|
||||||
let result = CustomPermission::requested(&request);
|
|
||||||
if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) {
|
|
||||||
assert_eq!(result.unwrap(), HashSet::from([permission]));
|
|
||||||
} else {
|
|
||||||
assert!(result.is_err(), "{permission:?}: {value}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn role_changes_drop_custom_permissions() {
|
|
||||||
for (from, to) in [
|
|
||||||
(Custom, Owner),
|
|
||||||
(Custom, Admin),
|
|
||||||
(Custom, Manager),
|
|
||||||
(Custom, User),
|
|
||||||
(User, Custom),
|
|
||||||
(Manager, Custom),
|
|
||||||
(Custom, Custom),
|
|
||||||
(Admin, User),
|
|
||||||
] {
|
|
||||||
let mut member = member(from);
|
|
||||||
member.access_all = true;
|
|
||||||
for permission in CustomPermission::ALL {
|
|
||||||
*member.custom_permission_mut(permission) = true;
|
|
||||||
}
|
|
||||||
member.set_type(to);
|
|
||||||
for permission in CustomPermission::ALL {
|
|
||||||
assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}");
|
|
||||||
}
|
|
||||||
assert_eq!(member.access_all, to != Custom);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,15 +244,6 @@ table! {
|
|||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
reset_password_key -> Nullable<Text>,
|
reset_password_key -> Nullable<Text>,
|
||||||
external_id -> Nullable<Text>,
|
external_id -> Nullable<Text>,
|
||||||
manage_users -> Bool,
|
|
||||||
manage_groups -> Bool,
|
|
||||||
manage_policies -> Bool,
|
|
||||||
create_new_collections -> Bool,
|
|
||||||
edit_any_collection -> Bool,
|
|
||||||
delete_any_collection -> Bool,
|
|
||||||
access_event_logs -> Bool,
|
|
||||||
access_import_export -> Bool,
|
|
||||||
access_reports -> Bool,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
-12
@@ -173,14 +173,10 @@ const ORG_TYPES = {
|
|||||||
"name": "User",
|
"name": "User",
|
||||||
"bg": "blue"
|
"bg": "blue"
|
||||||
},
|
},
|
||||||
"3": {
|
"4": {
|
||||||
"name": "Manager",
|
"name": "Manager",
|
||||||
"bg": "green"
|
"bg": "green"
|
||||||
},
|
},
|
||||||
"4": {
|
|
||||||
"name": "Custom",
|
|
||||||
"bg": "teal"
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
|
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
|
||||||
@@ -191,19 +187,15 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) {
|
|||||||
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
|
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
|
||||||
// Get org specific values
|
// Get org specific values
|
||||||
const userOrgType = event.relatedTarget.dataset.vwOrgType;
|
const userOrgType = event.relatedTarget.dataset.vwOrgType;
|
||||||
|
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
|
||||||
const orgName = event.relatedTarget.dataset.vwOrgName;
|
const orgName = event.relatedTarget.dataset.vwOrgName;
|
||||||
const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
|
const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
|
||||||
|
|
||||||
document.getElementById("userOrgTypeForm").reset();
|
|
||||||
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
|
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
|
||||||
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
|
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
|
||||||
document.getElementById("userOrgTypeUserUuid").value = userUuid;
|
document.getElementById("userOrgTypeUserUuid").value = userUuid;
|
||||||
document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
|
document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
|
||||||
// The Custom role can only be selected while it is enabled
|
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true;
|
||||||
const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`);
|
|
||||||
if (userOrgTypeRadio) {
|
|
||||||
userOrgTypeRadio.checked = true;
|
|
||||||
}
|
|
||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
|
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
|
||||||
@@ -230,7 +222,7 @@ function updateUserOrgType(event) {
|
|||||||
function initUserTable() {
|
function initUserTable() {
|
||||||
// Color all the org buttons per type
|
// Color all the org buttons per type
|
||||||
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
|
document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) {
|
||||||
const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" };
|
const orgType = ORG_TYPES[e.dataset.vwOrgType];
|
||||||
e.style.backgroundColor = orgType.bg;
|
e.style.backgroundColor = orgType.bg;
|
||||||
if (orgType.font !== undefined) {
|
if (orgType.font !== undefined) {
|
||||||
e.style.color = orgType.font;
|
e.style.color = orgType.font;
|
||||||
|
|||||||
@@ -135,11 +135,6 @@
|
|||||||
<div class="radio">
|
<div class="radio">
|
||||||
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager"> Manager</label>
|
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager"> Manager</label>
|
||||||
</div>
|
</div>
|
||||||
{{#if custom_roles_enabled}}
|
|
||||||
<div class="radio">
|
|
||||||
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom"> Custom</label>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
<div class="radio">
|
<div class="radio">
|
||||||
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin"> Admin</label>
|
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin"> Admin</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,17 +98,10 @@ app-security > app-two-factor-setup > form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide unsupported Custom Role options */
|
/* Hide unsupported Custom Role options */
|
||||||
{{#if custom_roles_enabled}}
|
|
||||||
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
|
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
|
||||||
: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;
|
||||||
}
|
}
|
||||||
{{else}}
|
|
||||||
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
|
|
||||||
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
|
|
||||||
@extend %vw-hide;
|
|
||||||
}
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
/* Change collapsed menu icon to Vaultwarden */
|
/* Change collapsed menu icon to Vaultwarden */
|
||||||
bit-nav-logo bit-nav-item a:before {
|
bit-nav-logo bit-nav-item a:before {
|
||||||
|
|||||||
+2
-42
@@ -302,20 +302,6 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
|||||||
// Effectively ignores, any static file route, and the alive endpoint
|
// Effectively ignores, any static file route, and the alive endpoint
|
||||||
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
||||||
|
|
||||||
const MAX_LOGGED_QUERY_BYTES: usize = 100;
|
|
||||||
|
|
||||||
// Query values can hold secrets, like the access code of a login request, so we don't log them
|
|
||||||
fn redact_query_values(query: &str) -> String {
|
|
||||||
query
|
|
||||||
.split('&')
|
|
||||||
.map(|param| match param.split_once('=') {
|
|
||||||
Some((name, _)) => format!("{name}=…"),
|
|
||||||
None => param.to_owned(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&")
|
|
||||||
}
|
|
||||||
|
|
||||||
// When extra is true, we ignore the whitelist above and also print the mounts
|
// When extra is true, we ignore the whitelist above and also print the mounts
|
||||||
pub struct BetterLogging {
|
pub struct BetterLogging {
|
||||||
extra: bool,
|
extra: bool,
|
||||||
@@ -395,9 +381,8 @@ impl Fairing for BetterLogging {
|
|||||||
};
|
};
|
||||||
match uri.query() {
|
match uri.query() {
|
||||||
Some(q) => {
|
Some(q) => {
|
||||||
let query = redact_query_values(q.as_str());
|
let q = q.as_str();
|
||||||
let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)];
|
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug());
|
||||||
info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug());
|
|
||||||
}
|
}
|
||||||
None => info!(target: "request", "{method} {}", path.escape_debug()),
|
None => info!(target: "request", "{method} {}", path.escape_debug()),
|
||||||
}
|
}
|
||||||
@@ -1042,28 +1027,3 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod query_log_tests {
|
|
||||||
use super::{MAX_LOGGED_QUERY_BYTES, redact_query_values};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_redact_query_values() {
|
|
||||||
assert_eq!(redact_query_values("code=SECRET"), "code=…");
|
|
||||||
assert_eq!(redact_query_values("code=SECRET&other=SECRET"), "code=…&other=…");
|
|
||||||
assert_eq!(redact_query_values("a=1&b=2&flag"), "a=…&b=…&flag");
|
|
||||||
assert_eq!(redact_query_values("a=b=SECRET"), "a=…");
|
|
||||||
assert_eq!(redact_query_values("a=1&&=SECRET"), "a=…&&=…");
|
|
||||||
assert_eq!(redact_query_values("a=x%26b%3DSECRET"), "a=…");
|
|
||||||
assert_eq!(redact_query_values("a=1;b=SECRET"), "a=…");
|
|
||||||
assert_eq!(redact_query_values("a=1&a=SECRET"), "a=…&a=…");
|
|
||||||
assert_eq!(redact_query_values(""), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_redacted_query_truncates_on_a_char_boundary() {
|
|
||||||
let name = "a".repeat(MAX_LOGGED_QUERY_BYTES - 1);
|
|
||||||
let query = redact_query_values(&format!("{name}=SECRET"));
|
|
||||||
assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}="));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user