mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-05 01:18:36 +02:00
Compare commits
25
Commits
f4696877b9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b159beff | ||
|
|
904b80115d | ||
|
|
82fc47e83e | ||
|
|
286499781a | ||
|
|
6ed6921cc2 | ||
|
|
5d870b796f | ||
|
|
79ee29d501 | ||
|
|
acb574b768 | ||
|
|
e056359a42 | ||
|
|
f1c7eccd4a | ||
|
|
0dbc92f2b7 | ||
|
|
42660af757 | ||
|
|
863395f5e2 | ||
|
|
be5d3ad907 | ||
|
|
80d1859452 | ||
|
|
b723afe08a | ||
|
|
64ae55ceb2 | ||
|
|
a96912bf21 | ||
|
|
e2d63cfca3 | ||
|
|
0f44744ef8 | ||
|
|
44c4853d37 | ||
|
|
e5a90968f2 | ||
|
|
7ce705be56 | ||
|
|
c925c04de0 | ||
|
|
b71cb7a832 |
No files matched your search
+25
-5
@@ -78,7 +78,7 @@
|
|||||||
# DB_CONNECTION_RETRIES=15
|
# DB_CONNECTION_RETRIES=15
|
||||||
|
|
||||||
## Database timeout
|
## Database timeout
|
||||||
## Timeout when acquiring database connection
|
## Timeout in seconds when acquiring database connection
|
||||||
# DATABASE_TIMEOUT=30
|
# DATABASE_TIMEOUT=30
|
||||||
|
|
||||||
## Database idle timeout
|
## Database idle timeout
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
|
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
|
||||||
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
|
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
|
||||||
## Number of days to retain events stored in the database.
|
## Number of days to retain events stored in the database.
|
||||||
## If unset (the default), events are kept indefinitely and the scheduled job is disabled!
|
## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
|
||||||
# EVENTS_DAYS_RETAIN=
|
# EVENTS_DAYS_RETAIN=
|
||||||
##
|
##
|
||||||
## Cron schedule of the job that cleans old auth requests from the auth request.
|
## Cron schedule of the job that cleans old auth requests from the auth request.
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
# USER_SEND_LIMIT=
|
# USER_SEND_LIMIT=
|
||||||
|
|
||||||
## Number of days to wait before auto-deleting a trashed item.
|
## Number of days to wait before auto-deleting a trashed item.
|
||||||
## If unset (the default), trashed items are not auto-deleted.
|
## If unset (the default) or 0, trashed items are not auto-deleted.
|
||||||
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
||||||
# TRASH_AUTO_DELETE_DAYS=
|
# TRASH_AUTO_DELETE_DAYS=
|
||||||
|
|
||||||
@@ -386,23 +386,31 @@
|
|||||||
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
||||||
|
|
||||||
## Client Settings
|
## Client Settings
|
||||||
|
## Server-wide Fill Assist rules feed base directory URL, not a JSON file.
|
||||||
|
## Defaults to https://fillassist.bitwarden.com.
|
||||||
|
## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace.
|
||||||
|
# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com
|
||||||
|
|
||||||
## Enable experimental feature flags for clients.
|
## Enable experimental feature flags for clients.
|
||||||
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
|
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
|
||||||
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
||||||
##
|
##
|
||||||
## The following flags are available:
|
## The following flags are available:
|
||||||
|
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
|
||||||
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||||
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
|
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
|
||||||
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||||
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
|
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
|
||||||
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
||||||
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
||||||
## - "pm-30529-webauthn-related-origins":
|
## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.0)
|
||||||
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
|
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
|
||||||
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
|
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
|
||||||
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
|
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
|
||||||
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
|
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
|
||||||
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
||||||
|
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
|
||||||
|
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||||
|
|
||||||
## Require new device emails. When a user logs in an email is required to be sent.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
@@ -489,6 +497,15 @@
|
|||||||
## 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!
|
||||||
@@ -606,6 +623,9 @@
|
|||||||
## Note that the checkbox would still be present, but ignored.
|
## Note that the checkbox would still be present, but ignored.
|
||||||
# DISABLE_2FA_REMEMBER=false
|
# DISABLE_2FA_REMEMBER=false
|
||||||
##
|
##
|
||||||
|
## Number of days before a remembered 2FA login expires (min: 1, max: 90).
|
||||||
|
# TWO_FACTOR_REMEMBER_DAYS=30
|
||||||
|
##
|
||||||
## Authenticator Settings
|
## Authenticator Settings
|
||||||
## Disable authenticator time drifted codes to be valid.
|
## Disable authenticator time drifted codes to be valid.
|
||||||
## TOTP codes of the previous and next 30 seconds will be invalid
|
## TOTP codes of the previous and next 30 seconds will be invalid
|
||||||
@@ -614,7 +634,7 @@
|
|||||||
## we allow by default the TOTP code which was valid one step back and one in the future.
|
## we allow by default the TOTP code which was valid one step back and one in the future.
|
||||||
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
||||||
## You can disable this, so that only the current TOTP Code is allowed.
|
## You can disable this, so that only the current TOTP Code is allowed.
|
||||||
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
|
## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid.
|
||||||
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
||||||
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
|
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -287,7 +287,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -303,7 +303,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
|
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
Generated
+598
-316
File diff suppressed because it is too large.
Load diff
+7
-7
@@ -66,7 +66,7 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.34"
|
log = "0.4.35"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||||
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||||
tracing = { version = "0.1.44", features = ["log"] }
|
tracing = { version = "0.1.44", features = ["log"] }
|
||||||
@@ -80,7 +80,7 @@ num-derive = "0.5.1"
|
|||||||
bigdecimal = "0.4.11"
|
bigdecimal = "0.4.11"
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] }
|
||||||
rocket_ws = { version = "0.1.1" }
|
rocket_ws = { version = "0.1.1" }
|
||||||
|
|
||||||
# WebSockets libraries
|
# WebSockets libraries
|
||||||
@@ -129,7 +129,7 @@ rustls = { version = "0.23.46", features = ["ring", "std"], default-features = f
|
|||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.28.0", features = ["v4"] }
|
uuid = { version = "1.29.0", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time libraries
|
# Date and time libraries
|
||||||
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
||||||
@@ -204,7 +204,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [
|
|||||||
hickory-resolver = "0.26.3"
|
hickory-resolver = "0.26.3"
|
||||||
|
|
||||||
# Favicon extraction libraries
|
# Favicon extraction libraries
|
||||||
html5gum = "0.8.4"
|
html5gum = "0.9.0"
|
||||||
regex = { version = "1.13.1", default-features = false, features = [
|
regex = { version = "1.13.1", default-features = false, features = [
|
||||||
"perf",
|
"perf",
|
||||||
"std",
|
"std",
|
||||||
@@ -215,7 +215,7 @@ bytes = "1.12.1"
|
|||||||
svg-hush = "0.9.7"
|
svg-hush = "0.9.7"
|
||||||
|
|
||||||
# Cache function results (Used for version check and favicon fetching)
|
# Cache function results (Used for version check and favicon fetching)
|
||||||
cached = { version = "4.0.3", features = ["async"] }
|
cached = { version = "4.1.0", features = ["async"] }
|
||||||
|
|
||||||
# Used for custom short lived cookie jar during favicon extraction
|
# Used for custom short lived cookie jar during favicon extraction
|
||||||
cookie = "0.18.2"
|
cookie = "0.18.2"
|
||||||
@@ -235,7 +235,7 @@ governor = "0.10.4"
|
|||||||
ipnet = "2.12.2"
|
ipnet = "2.12.2"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.0.1", default-features = false }
|
openidconnect = { version = "4.1.0", default-features = false }
|
||||||
moka = { version = "0.12.16", features = ["future"] }
|
moka = { version = "0.12.16", features = ["future"] }
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
@@ -268,7 +268,7 @@ aws-config = { version = "1.13.0", optional = true, default-features = false, fe
|
|||||||
"sso",
|
"sso",
|
||||||
] }
|
] }
|
||||||
aws-credential-types = { version = "1.3.1", optional = true }
|
aws-credential-types = { version = "1.3.1", optional = true }
|
||||||
aws-smithy-runtime-api = { version = "1.19.1", optional = true }
|
aws-smithy-runtime-api = { version = "1.19.2", optional = true }
|
||||||
http = { version = "1.5.0", optional = true }
|
http = { version = "1.5.0", optional = true }
|
||||||
reqsign-aws-v4 = { version = "3.3.1", optional = true }
|
reqsign-aws-v4 = { version = "3.3.1", optional = true }
|
||||||
reqsign-core = { version = "3.3.2", optional = true }
|
reqsign-core = { version = "3.3.2", optional = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2026.8.0"
|
vault_version: "v2026.10.1"
|
||||||
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
|
vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52"
|
||||||
# Cross Compile Docker Helper Scripts v1.10.0
|
# Cross Compile Docker Helper Scripts v1.10.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
|
|||||||
@@ -19,15 +19,15 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
|
|||||||
@@ -19,15 +19,15 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 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
|
||||||
+52
-11
@@ -1,4 +1,4 @@
|
|||||||
use std::{env, sync::LazyLock};
|
use std::{collections::HashMap, env, sync::LazyLock};
|
||||||
|
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
@@ -17,7 +17,7 @@ use crate::{
|
|||||||
api::{
|
api::{
|
||||||
ApiResult, EmptyResult, JsonResult, Notify,
|
ApiResult, EmptyResult, JsonResult, Notify,
|
||||||
core::{log_event, two_factor},
|
core::{log_event, two_factor},
|
||||||
unregister_push_device,
|
push_logout_to_relay, unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||||
config::ConfigBuilder,
|
config::ConfigBuilder,
|
||||||
@@ -26,6 +26,7 @@ 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},
|
||||||
@@ -201,12 +202,15 @@ fn post_admin_login(
|
|||||||
// If the token is invalid, redirect to login page
|
// If the token is invalid, redirect to login page
|
||||||
if validate_token(&data.token) {
|
if validate_token(&data.token) {
|
||||||
// If the token received is valid, generate JWT and save it as a cookie
|
// If the token received is valid, generate JWT and save it as a cookie
|
||||||
let claims = generate_admin_claims();
|
let claims = match generate_admin_claims() {
|
||||||
|
Ok(claims) => claims,
|
||||||
|
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
|
||||||
|
};
|
||||||
let jwt = encode_jwt(&claims);
|
let jwt = encode_jwt(&claims);
|
||||||
|
|
||||||
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
||||||
.path(admin_path())
|
.path(admin_path())
|
||||||
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
|
.max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
|
||||||
.same_site(SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.secure(secure.https);
|
.secure(secure.https);
|
||||||
@@ -253,6 +257,7 @@ 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 {
|
||||||
@@ -263,6 +268,7 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +393,18 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,13 +434,21 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/delete", format = "application/json")]
|
#[post("/users/<user_id>/delete", format = "application/json")]
|
||||||
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let user = get_user_or_404(&user_id, &conn).await?;
|
let user = get_user_or_404(&user_id, &conn).await?;
|
||||||
|
|
||||||
// Get the membership records before deleting the actual user
|
// Get the membership records and push devices before deleting the actual user
|
||||||
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
||||||
|
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await;
|
||||||
let res = user.delete(&conn).await;
|
let res = user.delete(&conn).await;
|
||||||
|
|
||||||
|
if res.is_ok() {
|
||||||
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
if has_push_device {
|
||||||
|
push_logout_to_relay(&user, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserDeleted,
|
EventType::OrganizationUserDeleted,
|
||||||
@@ -554,10 +580,14 @@ 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 = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
||||||
new_type as i32
|
// Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
|
||||||
} else {
|
// are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
|
||||||
err!("Invalid type")
|
Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
|
||||||
|
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 {
|
||||||
@@ -567,7 +597,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
member_to_edit.atype = new_type;
|
// A Manager only becomes a Custom member if that changes nothing they may do
|
||||||
|
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?;
|
||||||
|
|
||||||
@@ -779,6 +816,8 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
.into_keys()
|
.into_keys()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
|
||||||
|
|
||||||
let diagnostics_json = json!({
|
let diagnostics_json = json!({
|
||||||
"dns_resolved": dns_resolved,
|
"dns_resolved": dns_resolved,
|
||||||
"current_release": VERSION,
|
"current_release": VERSION,
|
||||||
@@ -801,7 +840,9 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
|
"config_file_status": &CONFIG.get_config_file_status(),
|
||||||
"template_overrides": check_template_overrides().join(", "),
|
"template_overrides": check_template_overrides().join(", "),
|
||||||
|
"invalid_env_vars": invalid_env_vars,
|
||||||
"invalid_feature_flags": invalid_feature_flags,
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
api::{
|
api::{
|
||||||
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||||
master_password_policy, register_push_device, unregister_push_device,
|
master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||||
crypto,
|
crypto,
|
||||||
@@ -1148,7 +1148,11 @@ async fn post_email_token(data: Json<EmailTokenData>, headers: Headers, conn: Db
|
|||||||
error!("Error sending change-email email: {e:#?}");
|
error!("Error sending change-email email: {e:#?}");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email);
|
debug!(
|
||||||
|
"Email change request for user ({}) to email ({}) with token ({token})",
|
||||||
|
user.uuid,
|
||||||
|
data.new_email.escape_debug()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.email_new = Some(data.new_email);
|
user.email_new = Some(data.new_email);
|
||||||
@@ -1301,7 +1305,7 @@ struct DeleteRecoverTokenData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/delete-recover-token", data = "<data>")]
|
#[post("/accounts/delete-recover-token", data = "<data>")]
|
||||||
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn) -> EmptyResult {
|
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let data: DeleteRecoverTokenData = data.into_inner();
|
let data: DeleteRecoverTokenData = data.into_inner();
|
||||||
|
|
||||||
let Ok(claims) = decode_delete(&data.token) else {
|
let Ok(claims) = decode_delete(&data.token) else {
|
||||||
@@ -1315,22 +1319,44 @@ async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, conn: DbC
|
|||||||
if claims.sub != *user.uuid {
|
if claims.sub != *user.uuid {
|
||||||
err!("Invalid claim");
|
err!("Invalid claim");
|
||||||
}
|
}
|
||||||
user.delete(&conn).await
|
|
||||||
|
// The devices are deleted together with the user, so check for push devices beforehand
|
||||||
|
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
|
||||||
|
user.delete(&conn).await?;
|
||||||
|
|
||||||
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
if has_push_device {
|
||||||
|
push_logout_to_relay(&user, None);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/delete", data = "<data>")]
|
#[post("/accounts/delete", data = "<data>")]
|
||||||
async fn post_delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn post_delete_account(
|
||||||
delete_account(data, headers, conn).await
|
data: Json<PasswordOrOtpData>,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> EmptyResult {
|
||||||
|
delete_account(data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/accounts", data = "<data>")]
|
#[delete("/accounts", data = "<data>")]
|
||||||
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, true, &conn).await?;
|
data.validate(&user, true, &conn).await?;
|
||||||
|
|
||||||
user.delete(&conn).await
|
// The devices are deleted together with the user, so check for push devices beforehand
|
||||||
|
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await;
|
||||||
|
user.delete(&conn).await?;
|
||||||
|
|
||||||
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
if has_push_device {
|
||||||
|
push_logout_to_relay(&user, None);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||||
@@ -1381,7 +1407,7 @@ async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn)
|
|||||||
mail::send_password_hint(email, hint).await?;
|
mail::send_password_hint(email, hint).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
} else if let Some(hint) = hint {
|
} else if let Some(hint) = hint {
|
||||||
err!(format!("Your password hint is: {hint}"));
|
err_silent!(format!("Your password hint is: {hint}"));
|
||||||
} else {
|
} else {
|
||||||
err!(NO_HINT);
|
err!(NO_HINT);
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-71
@@ -13,7 +13,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
||||||
auth::ClientVersion,
|
auth::ClientVersion,
|
||||||
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
@@ -23,7 +23,7 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
||||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
||||||
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
||||||
@@ -458,7 +458,7 @@ pub async fn update_cipher_from_data(
|
|||||||
{
|
{
|
||||||
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
||||||
// ISO 8601 format
|
// ISO 8601 format
|
||||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
|
||||||
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
||||||
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
||||||
}
|
}
|
||||||
@@ -521,7 +521,7 @@ pub async fn update_cipher_from_data(
|
|||||||
// A missing attachment means it was removed via an other client.
|
// A missing attachment means it was removed via an other client.
|
||||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||||
// Bitwarden it self ignores these mismatches server side.
|
// Bitwarden it self ignores these mismatches server side.
|
||||||
warn!("Attachment {id} doesn't exist");
|
warn!("Attachment {} doesn't exist", id.escape_debug());
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -580,7 +580,7 @@ pub async fn update_cipher_from_data(
|
|||||||
match data.archived_date {
|
match data.archived_date {
|
||||||
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||||
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
||||||
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()),
|
||||||
},
|
},
|
||||||
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
||||||
}
|
}
|
||||||
@@ -666,6 +666,9 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
||||||
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
||||||
cipher_data.folder_id = folder_id;
|
cipher_data.folder_id = folder_id;
|
||||||
|
// A personal import always creates personal ciphers, the organization ID is ignored instead of rejected
|
||||||
|
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
|
||||||
|
cipher_data.organization_id = None;
|
||||||
|
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
||||||
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||||
@@ -687,6 +690,11 @@ async fn put_cipher_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
// Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's
|
||||||
|
if data.organization_id.is_none() {
|
||||||
|
err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code);
|
||||||
|
}
|
||||||
|
|
||||||
put_cipher(cipher_id, data, headers, conn, nt).await
|
put_cipher(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +706,7 @@ async fn post_cipher_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
post_cipher(cipher_id, data, headers, conn, nt).await
|
put_cipher_admin(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/ciphers/<cipher_id>", data = "<data>")]
|
#[post("/ciphers/<cipher_id>", data = "<data>")]
|
||||||
@@ -735,6 +743,11 @@ async fn put_cipher(
|
|||||||
err!("Cipher is not write accessible")
|
err!("Cipher is not write accessible")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moving a cipher into an organization is only possible through the share endpoints
|
||||||
|
if cipher.organization_uuid != data.organization_id {
|
||||||
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
|
}
|
||||||
|
|
||||||
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
|
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
|
||||||
|
|
||||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
||||||
@@ -993,9 +1006,7 @@ async fn post_cipher_share(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let data: ShareCipherData = data.into_inner();
|
put_cipher_share(cipher_id, data, headers, conn, nt).await
|
||||||
|
|
||||||
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/ciphers/<cipher_id>/share", data = "<data>")]
|
#[put("/ciphers/<cipher_id>/share", data = "<data>")]
|
||||||
@@ -1008,6 +1019,14 @@ async fn put_cipher_share(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let data: ShareCipherData = data.into_inner();
|
let data: ShareCipherData = data.into_inner();
|
||||||
|
|
||||||
|
if data.cipher.organization_id.is_none() {
|
||||||
|
err!("Cipher OrganizationId is required.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.collection_ids.is_empty() {
|
||||||
|
err!("You must select at least one collection.")
|
||||||
|
}
|
||||||
|
|
||||||
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
|
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,14 +1037,53 @@ struct ShareSelectedCipherData {
|
|||||||
collection_ids: Vec<CollectionId>,
|
collection_ids: Vec<CollectionId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A bulk share returns cipherMini models, not the cipherDetails models used by
|
||||||
|
// the single-share route. Keep the encrypted legacy fields and a future v2
|
||||||
|
// `data` blob, but do not expose details-only fields in this response.
|
||||||
|
fn cipher_mini_from_json(mut cipher: Value) -> Value {
|
||||||
|
const FIELDS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"organizationId",
|
||||||
|
"type",
|
||||||
|
"data",
|
||||||
|
"partialData",
|
||||||
|
"name",
|
||||||
|
"notes",
|
||||||
|
"login",
|
||||||
|
"card",
|
||||||
|
"identity",
|
||||||
|
"secureNote",
|
||||||
|
"sshKey",
|
||||||
|
"bankAccount",
|
||||||
|
"driversLicense",
|
||||||
|
"passport",
|
||||||
|
"fields",
|
||||||
|
"passwordHistory",
|
||||||
|
"attachments",
|
||||||
|
"organizationUseTotp",
|
||||||
|
"revisionDate",
|
||||||
|
"creationDate",
|
||||||
|
"deletedDate",
|
||||||
|
"reprompt",
|
||||||
|
"key",
|
||||||
|
];
|
||||||
|
|
||||||
|
let Some(object) = cipher.as_object_mut() else {
|
||||||
|
return json!({ "object": "cipherMini" });
|
||||||
|
};
|
||||||
|
object.retain(|field, _| FIELDS.contains(&field.as_str()));
|
||||||
|
object.insert("object".into(), "cipherMini".into());
|
||||||
|
cipher
|
||||||
|
}
|
||||||
|
|
||||||
#[put("/ciphers/share", data = "<data>")]
|
#[put("/ciphers/share", data = "<data>")]
|
||||||
async fn put_cipher_share_selected(
|
async fn put_cipher_share_selected(
|
||||||
data: Json<ShareSelectedCipherData>,
|
data: Json<ShareSelectedCipherData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> JsonResult {
|
||||||
let mut data: ShareSelectedCipherData = data.into_inner();
|
let data: ShareSelectedCipherData = data.into_inner();
|
||||||
|
|
||||||
if data.ciphers.is_empty() {
|
if data.ciphers.is_empty() {
|
||||||
err!("You must select at least one cipher.")
|
err!("You must select at least one cipher.")
|
||||||
@@ -1039,25 +1097,43 @@ async fn put_cipher_share_selected(
|
|||||||
if cipher.id.is_none() {
|
if cipher.id.is_none() {
|
||||||
err!("Request missing ids field")
|
err!("Request missing ids field")
|
||||||
}
|
}
|
||||||
|
if cipher.organization_id.is_none() {
|
||||||
|
err!("Cipher OrganizationId is required.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some(cipher) = data.ciphers.pop() {
|
let mut responses = Vec::with_capacity(data.ciphers.len());
|
||||||
|
for cipher in data.ciphers {
|
||||||
let mut shared_cipher_data = ShareCipherData {
|
let mut shared_cipher_data = ShareCipherData {
|
||||||
cipher,
|
cipher,
|
||||||
collection_ids: data.collection_ids.clone(),
|
collection_ids: data.collection_ids.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
||||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
let response =
|
||||||
|
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?;
|
||||||
|
|
||||||
|
// Like upstream's VisibleToClient, withhold a cipher if sharing it
|
||||||
|
// removed the user's access to it. Only then include its encrypted
|
||||||
|
// mini response; the full single-share response stays internal.
|
||||||
|
if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await
|
||||||
|
&& shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
|
{
|
||||||
|
responses.push(cipher_mini_from_json(response.into_inner()));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
err!("Request missing ids field")
|
err!("Request missing ids field")
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi share actions do not send out a push for each cipher, we need to send a general sync here
|
// Multi share actions do not send out a push for each cipher, we need to send a general sync here
|
||||||
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
|
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(Json(json!({
|
||||||
|
"data": responses,
|
||||||
|
"object": "list",
|
||||||
|
"continuationToken": null,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn share_cipher_by_uuid(
|
async fn share_cipher_by_uuid(
|
||||||
@@ -1160,6 +1236,40 @@ enum FileUploadType {
|
|||||||
// Azure = 1, // only used upstream
|
// Azure = 1, // only used upstream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited.
|
||||||
|
/// `size_adjust` credits a v2 attachment whose record already counts as used.
|
||||||
|
async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult<Option<i64>> {
|
||||||
|
let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid {
|
||||||
|
let Some(limit_kb) = CONFIG.user_attachment_limit() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
(limit_kb, Attachment::size_by_user(user_id, conn).await)
|
||||||
|
} else if let Some(ref org_id) = cipher.organization_uuid {
|
||||||
|
let Some(limit_kb) = CONFIG.org_attachment_limit() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
(limit_kb, Attachment::size_by_org(org_id, conn).await)
|
||||||
|
} else {
|
||||||
|
err!("Cipher is neither owned by a user nor an organization");
|
||||||
|
};
|
||||||
|
|
||||||
|
if limit_kb == 0 {
|
||||||
|
err!("Attachments are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(left) =
|
||||||
|
limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust))
|
||||||
|
else {
|
||||||
|
err!("Attachment size overflow");
|
||||||
|
};
|
||||||
|
|
||||||
|
if left <= 0 {
|
||||||
|
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(left))
|
||||||
|
}
|
||||||
|
|
||||||
/// v2 API for creating an attachment associated with a cipher.
|
/// v2 API for creating an attachment associated with a cipher.
|
||||||
/// This redirects the client to the API it should use to upload the attachment.
|
/// This redirects the client to the API it should use to upload the attachment.
|
||||||
/// For upstream's cloud-hosted service, it's an Azure object storage API.
|
/// For upstream's cloud-hosted service, it's an Azure object storage API.
|
||||||
@@ -1170,6 +1280,7 @@ async fn post_attachment_v2(
|
|||||||
data: Json<AttachmentRequestData>,
|
data: Json<AttachmentRequestData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
err!("Cipher doesn't exist")
|
err!("Cipher doesn't exist")
|
||||||
@@ -1189,10 +1300,18 @@ async fn post_attachment_v2(
|
|||||||
if file_size > SIZE_525_MB {
|
if file_size > SIZE_525_MB {
|
||||||
err!("Max file size is 525 MB.")
|
err!("Max file size is 525 MB.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await?
|
||||||
|
&& file_size > size_limit
|
||||||
|
{
|
||||||
|
err!("Attachment storage limit exceeded with this file");
|
||||||
|
}
|
||||||
|
|
||||||
let attachment_id = crypto::generate_attachment_id();
|
let attachment_id = crypto::generate_attachment_id();
|
||||||
let attachment =
|
let attachment =
|
||||||
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
||||||
attachment.save(&conn).await.expect("Error saving attachment");
|
attachment.save(&conn).await.expect("Error saving attachment");
|
||||||
|
notify_attachment_created(&cipher, &headers, &conn, &nt).await;
|
||||||
|
|
||||||
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
|
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
|
||||||
let response_key = match data.admin_request {
|
let response_key = match data.admin_request {
|
||||||
@@ -1255,55 +1374,7 @@ async fn save_attachment(
|
|||||||
Some(a) => a.file_size, // v2 API
|
Some(a) => a.file_size, // v2 API
|
||||||
};
|
};
|
||||||
|
|
||||||
let size_limit = if let Some(ref user_id) = cipher.user_uuid {
|
if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await?
|
||||||
match CONFIG.user_attachment_limit() {
|
|
||||||
Some(0) => err!("Attachments are disabled"),
|
|
||||||
Some(limit_kb) => {
|
|
||||||
let already_used = Attachment::size_by_user(user_id, &conn).await;
|
|
||||||
let left = limit_kb
|
|
||||||
.checked_mul(1024)
|
|
||||||
.and_then(|l| l.checked_sub(already_used))
|
|
||||||
.and_then(|l| l.checked_add(size_adjust));
|
|
||||||
|
|
||||||
let Some(left) = left else {
|
|
||||||
err!("Attachment size overflow");
|
|
||||||
};
|
|
||||||
|
|
||||||
if left <= 0 {
|
|
||||||
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(left)
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
} else if let Some(ref org_id) = cipher.organization_uuid {
|
|
||||||
match CONFIG.org_attachment_limit() {
|
|
||||||
Some(0) => err!("Attachments are disabled"),
|
|
||||||
Some(limit_kb) => {
|
|
||||||
let already_used = Attachment::size_by_org(org_id, &conn).await;
|
|
||||||
let left = limit_kb
|
|
||||||
.checked_mul(1024)
|
|
||||||
.and_then(|l| l.checked_sub(already_used))
|
|
||||||
.and_then(|l| l.checked_add(size_adjust));
|
|
||||||
|
|
||||||
let Some(left) = left else {
|
|
||||||
err!("Attachment size overflow");
|
|
||||||
};
|
|
||||||
|
|
||||||
if left <= 0 {
|
|
||||||
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(left)
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err!("Cipher is neither owned by a user nor an organization");
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(size_limit) = size_limit
|
|
||||||
&& size > size_limit
|
&& size > size_limit
|
||||||
{
|
{
|
||||||
err!("Attachment storage limit exceeded with this file");
|
err!("Attachment storage limit exceeded with this file");
|
||||||
@@ -1360,13 +1431,23 @@ async fn save_attachment(
|
|||||||
|
|
||||||
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
||||||
|
|
||||||
|
// With the v2 API, post_attachment_v2() already did this when it created the attachment record
|
||||||
|
if attachment.is_none() {
|
||||||
|
notify_attachment_created(&cipher, headers, &conn, &nt).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((cipher, conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the cipher update to the clients and logs the attachment event
|
||||||
|
async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) {
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
UpdateType::SyncCipherUpdate,
|
UpdateType::SyncCipherUpdate,
|
||||||
&cipher,
|
cipher,
|
||||||
&cipher.update_users_revision(&conn).await,
|
&cipher.update_users_revision(conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
None,
|
None,
|
||||||
&conn,
|
conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1378,12 +1459,10 @@ async fn save_attachment(
|
|||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
&conn,
|
conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((cipher, conn))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v2 API for uploading the actual data content of an attachment.
|
/// v2 API for uploading the actual data content of an attachment.
|
||||||
@@ -1659,12 +1738,22 @@ async fn move_cipher_selected(
|
|||||||
// Find all ciphers a user has access to, all others will be ignored
|
// Find all ciphers a user has access to, all others will be ignored
|
||||||
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
|
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
|
||||||
let accessible_ciphers_count = accessible_ciphers.len();
|
let accessible_ciphers_count = accessible_ciphers.len();
|
||||||
|
let mut move_result = Ok(());
|
||||||
for cipher in accessible_ciphers {
|
for cipher in accessible_ciphers {
|
||||||
cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?;
|
move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await;
|
||||||
|
if move_result.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if cipher_count == 1 {
|
if cipher_count == 1 {
|
||||||
single_cipher = Some(cipher);
|
single_cipher = Some(cipher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Update the revision once for the whole batch. Also when a move failed halfway,
|
||||||
|
// since the ciphers moved before it are already changed.
|
||||||
|
if accessible_ciphers_count > 0 {
|
||||||
|
User::update_uuid_revision(user_id, &conn).await;
|
||||||
|
}
|
||||||
|
move_result?;
|
||||||
|
|
||||||
if let Some(cipher) = single_cipher {
|
if let Some(cipher) = single_cipher {
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::Utc;
|
||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
||||||
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -136,19 +136,21 @@ async fn post_emergency_access(
|
|||||||
|
|
||||||
let data: EmergencyAccessUpdateData = data.into_inner();
|
let data: EmergencyAccessUpdateData = data.into_inner();
|
||||||
|
|
||||||
|
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
|
||||||
|
err!("Emergency access wait time is out-of-bounds!")
|
||||||
|
}
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
emergency_access.atype = new_type as i32;
|
||||||
new_type as i32
|
|
||||||
} else {
|
|
||||||
err!("Invalid emergency access type.")
|
|
||||||
};
|
|
||||||
|
|
||||||
emergency_access.atype = new_type;
|
|
||||||
emergency_access.wait_time_days = data.wait_time_days;
|
emergency_access.wait_time_days = data.wait_time_days;
|
||||||
if data.key_encrypted.is_some() {
|
if data.key_encrypted.is_some() {
|
||||||
emergency_access.key_encrypted = data.key_encrypted;
|
emergency_access.key_encrypted = data.key_encrypted;
|
||||||
@@ -210,11 +212,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
let email = data.email.to_lowercase();
|
let email = data.email.to_lowercase();
|
||||||
let wait_time_days = data.wait_time_days;
|
let wait_time_days = data.wait_time_days;
|
||||||
|
|
||||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
|
||||||
|
err!("Emergency access wait time is out-of-bounds!")
|
||||||
|
}
|
||||||
|
|
||||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
||||||
new_type as i32
|
|
||||||
} else {
|
|
||||||
err!("Invalid emergency access type.")
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,6 +262,8 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
err!(format!("Grantee user already invited: {}", &grantee_user.email))
|
err!(format!("Grantee user already invited: {}", &grantee_user.email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let new_type = new_type as i32;
|
||||||
|
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||||
let mut new_emergency_access =
|
let mut new_emergency_access =
|
||||||
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
||||||
new_emergency_access.save(&conn).await?;
|
new_emergency_access.save(&conn).await?;
|
||||||
@@ -292,7 +296,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
|
|||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
||||||
err!("The grantee user is already accepted or confirmed to the organization");
|
err!("The grantee user is already accepted or confirmed for emergency access");
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(email) = emergency_access.email.clone() else {
|
let Some(email) = emergency_access.email.clone() else {
|
||||||
@@ -453,11 +457,24 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
|
||||||
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
@@ -490,11 +507,17 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
||||||
err!("Emergency access not valid.")
|
err!(
|
||||||
|
"Emergency access not valid.",
|
||||||
|
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
||||||
@@ -747,53 +770,72 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(conn) = pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
|
error!("Failed to get DB connection for emergency time-outs!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
if emergency_access_list.is_empty() {
|
||||||
debug!("No emergency request timeout to approve");
|
debug!("No emergency request timeout to approve");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
for mut emer in emergency_access_list {
|
for mut emer in emergency_access_list {
|
||||||
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
match emer.is_recovery_allowed(&now) {
|
||||||
let recovery_allowed_at =
|
Ok(true) => {} // Recovery is allowed
|
||||||
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
|
Ok(false) => continue,
|
||||||
if recovery_allowed_at.le(&now) {
|
Err(e) => {
|
||||||
|
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get grantor user to send Accepted email
|
||||||
|
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
||||||
|
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
||||||
|
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get grantee user to send Accepted email
|
||||||
|
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||||
|
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
// Only update the access status
|
// Only update the access status
|
||||||
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
||||||
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
|
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
|
||||||
.await
|
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
|
||||||
.expect("Unable to update emergency access status");
|
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
|
||||||
|
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
// get grantor user to send Accepted email
|
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
|
||||||
let grantor_user =
|
|
||||||
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
|
||||||
|
|
||||||
// get grantee user to send Accepted email
|
|
||||||
let grantee_user =
|
|
||||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
|
||||||
.await
|
|
||||||
.expect("Grantee user not found");
|
|
||||||
|
|
||||||
mail::send_emergency_access_recovery_timed_out(
|
|
||||||
&grantor_user.email,
|
&grantor_user.email,
|
||||||
&grantee_user.name,
|
&grantee_user.name,
|
||||||
emer.get_type_as_str(),
|
emer.get_type_as_str(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Error on sending email");
|
{
|
||||||
|
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)
|
if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
|
||||||
.await
|
{
|
||||||
.expect("Error on sending email");
|
error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
error!("Failed to get DB connection while searching emergency request timed out");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
||||||
@@ -802,55 +844,85 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(conn) = pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
|
error!("Failed to get DB connection for emergency reminders!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
if emergency_access_list.is_empty() {
|
||||||
debug!("No emergency request reminder notification to send");
|
debug!("No emergency request reminder notification to send");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
for mut emer in emergency_access_list {
|
for mut emer in emergency_access_list {
|
||||||
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
let Ok(deadline_at) = emer.deadline_at() else {
|
||||||
// Calculate the day before the recovery will become active
|
error!("Invalid deadline for emergency access '{}'", emer.uuid);
|
||||||
let final_recovery_reminder_at =
|
continue;
|
||||||
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap();
|
};
|
||||||
// Calculate if a day has passed since the previous notification, else no notification has been sent before
|
|
||||||
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
|
||||||
last_notification_at + TimeDelta::try_days(1).unwrap()
|
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only send reminders during the last 24 hours, else continue
|
||||||
|
if now < final_reminder_at || now >= deadline_at {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
|
||||||
|
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
|
||||||
|
// This is to prevent possible missed reminders, this might cause an extra reminder,
|
||||||
|
// but that is better then no reminder at all.
|
||||||
|
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
||||||
|
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
|
||||||
} else {
|
} else {
|
||||||
now
|
now
|
||||||
};
|
};
|
||||||
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
|
|
||||||
|
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
|
||||||
|
// Else we can skip the current emergency access this run.
|
||||||
|
if next_reminder_at.gt(&now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get grantor user to send Reminder email
|
||||||
|
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
||||||
|
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
||||||
|
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get grantee user to send Reminder email
|
||||||
|
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||||
|
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
// Only update the last notification date
|
// Only update the last notification date
|
||||||
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
||||||
emer.update_last_notification_date_and_save(&now, &conn)
|
if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
|
||||||
.await
|
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
|
||||||
.expect("Unable to update emergency access notification date");
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled()
|
||||||
// get grantor user to send Accepted email
|
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
|
||||||
let grantor_user =
|
|
||||||
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
|
||||||
|
|
||||||
// get grantee user to send Accepted email
|
|
||||||
let grantee_user =
|
|
||||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
|
||||||
.await
|
|
||||||
.expect("Grantee user not found");
|
|
||||||
|
|
||||||
mail::send_emergency_access_recovery_reminder(
|
|
||||||
&grantor_user.email,
|
&grantor_user.email,
|
||||||
&grantee_user.name,
|
&grantee_user.name,
|
||||||
emer.get_type_as_str(),
|
emer.get_type_as_str(),
|
||||||
"1", // This notification is only triggered one day before the activation
|
"1", // This notification is only triggered one day before the activation
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Error on sending email");
|
{
|
||||||
|
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error!("Failed to get DB connection while searching emergency notification reminder");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+10
-7
@@ -214,8 +214,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Client-side cipher events (viewed, copied, toggled, autofilled).
|
// Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth).
|
||||||
1107..=1114 | 1117 | 1119..=1132 => {
|
1107..=1114 | 1117 | 1119..=1133 => {
|
||||||
// The cipher determines the organization the event is logged to, so make sure the
|
// The cipher determines the organization the event is logged to, so make sure the
|
||||||
// user can actually access it instead of trusting the provided cipher uuid.
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(cipher_uuid) = &event.cipher_id
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
@@ -347,14 +347,17 @@ async fn log_event_impl(
|
|||||||
|
|
||||||
pub async fn event_cleanup_job(pool: DbPool) {
|
pub async fn event_cleanup_job(pool: DbPool) {
|
||||||
debug!("Start events cleanup job");
|
debug!("Start events cleanup job");
|
||||||
if CONFIG.events_days_retain().is_none() {
|
if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
|
||||||
debug!("events_days_retain is not configured, abort");
|
debug!("events_days_retain is not configured or set to 0, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(conn) = pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
Event::clean_events(&conn).await.ok();
|
|
||||||
} else {
|
|
||||||
error!("Failed to get DB connection while trying to cleanup the events table");
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = Event::clean_events(&conn).await {
|
||||||
|
error!("Failed to cleanup the events table: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +247,7 @@ fn config() -> Json<Value> {
|
|||||||
"notifications": format!("{domain}/notifications"),
|
"notifications": format!("{domain}/notifications"),
|
||||||
"sso": "",
|
"sso": "",
|
||||||
"cloudRegion": null,
|
"cloudRegion": null,
|
||||||
|
"fillAssistRules": CONFIG.fill_assist_rules_url(),
|
||||||
},
|
},
|
||||||
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
|
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
|
||||||
"push": {
|
"push": {
|
||||||
|
|||||||
+135
-38
@@ -15,10 +15,10 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
|
||||||
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
|
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
|
||||||
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
|
||||||
TwoFactorType, User, UserId,
|
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -131,13 +131,16 @@ impl FullCollectionData {
|
|||||||
let org_groups = Group::find_by_organization(org_id, conn).await;
|
let org_groups = Group::find_by_organization(org_id, conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id.escape_debug(), org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
||||||
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
||||||
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
||||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
|
err!(
|
||||||
|
"Invalid member",
|
||||||
|
format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -552,7 +555,7 @@ async fn post_bulk_access_collections(
|
|||||||
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
||||||
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
for col_id in data.collection_ids {
|
for col_id in data.collection_ids {
|
||||||
@@ -975,13 +978,16 @@ impl InviteData {
|
|||||||
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
||||||
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
|
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
|
||||||
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
err!(
|
||||||
|
"Invalid collection",
|
||||||
|
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_groups = Group::find_by_organization(org_id, conn).await;
|
let org_groups = Group::find_by_organization(org_id, conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
|
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1002,14 +1008,12 @@ 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
|
||||||
// The from_str() will convert the custom role type into a manager role type
|
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
||||||
let raw_type = &data.r#type.into_string();
|
let raw_type = &data.r#type.into_string();
|
||||||
// Membership::from_str will convert custom (4) to manager (3)
|
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
||||||
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")
|
||||||
@@ -1019,11 +1023,19 @@ 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
|
||||||
|| (raw_type.eq("4")
|
|| (new_type == MembershipType::Manager
|
||||||
|
&& 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;
|
||||||
@@ -1065,8 +1077,11 @@ 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;
|
||||||
new_member.atype = new_type;
|
if let Some(permissions) = &custom_permissions {
|
||||||
|
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?;
|
||||||
|
|
||||||
@@ -1156,7 +1171,10 @@ async fn bulk_reinvite_members(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1329,7 +1347,10 @@ async fn bulk_confirm_invite(
|
|||||||
let user_key = invite.key.unwrap_or_default();
|
let user_key = invite.key.unwrap_or_default();
|
||||||
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1496,24 +1517,32 @@ 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
|
||||||
// The from_str() will convert the custom role type into a manager role type
|
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
|
||||||
let raw_type = &data.r#type.into_string();
|
let raw_type = &data.r#type.into_string();
|
||||||
// MembershipType::from_str will convert custom (4) to manager (3)
|
let Some(requested_type) = MembershipType::from_str(raw_type) else {
|
||||||
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
|
||||||
|| (raw_type.eq("4")
|
|| (new_type == MembershipType::Manager
|
||||||
|
&& 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)));
|
||||||
|
|
||||||
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
|
// While Custom roles are disabled, a Custom member keeps their stored permissions untouched
|
||||||
err!("The specified user isn't member of the organization")
|
let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
|
||||||
|
Some(CustomPermission::requested(&data.permissions)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if new_type != member_to_edit.atype
|
if new_type != member_to_edit.atype
|
||||||
@@ -1537,8 +1566,11 @@ 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;
|
||||||
member_to_edit.atype = new_type as i32;
|
if let Some(permissions) = &custom_permissions {
|
||||||
|
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.
|
||||||
@@ -1610,7 +1642,10 @@ async fn bulk_delete_member(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to delete member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2039,6 +2074,29 @@ async fn put_policy(
|
|||||||
err!("Invalid or unsupported policy type")
|
err!("Invalid or unsupported policy type")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await;
|
||||||
|
let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled);
|
||||||
|
|
||||||
|
// Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the
|
||||||
|
// policy changes state.
|
||||||
|
if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled {
|
||||||
|
let single_org_policy_enabled =
|
||||||
|
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled);
|
||||||
|
|
||||||
|
if !single_org_policy_enabled {
|
||||||
|
err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled {
|
||||||
|
let fill_assist_policy_enabled =
|
||||||
|
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled);
|
||||||
|
|
||||||
|
if fill_assist_policy_enabled {
|
||||||
|
err!("Turn off the Activate fill assist policy because it requires the Single organization policy.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
||||||
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
||||||
// Now that groups are available we can enforce this option when wanted.
|
// Now that groups are available we can enforce this option when wanted.
|
||||||
@@ -2071,6 +2129,37 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pol_type_enum == OrgPolicyType::FillAssist {
|
||||||
|
let fill_assist_data = match data.data.as_ref() {
|
||||||
|
None | Some(Value::Null) => FillAssistPolicyData {
|
||||||
|
rules_url: None,
|
||||||
|
},
|
||||||
|
Some(data) => {
|
||||||
|
let Ok(data) = serde_json::from_value::<FillAssistPolicyData>(data.clone()) else {
|
||||||
|
err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.")
|
||||||
|
};
|
||||||
|
data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.enabled {
|
||||||
|
let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else {
|
||||||
|
err!("The RulesUrl field is required.")
|
||||||
|
};
|
||||||
|
let Ok(rules_url) = url::Url::parse(&rules_url) else {
|
||||||
|
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") {
|
||||||
|
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rules_url.scheme() != "https" {
|
||||||
|
err!("RulesUrl must use HTTPS.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
||||||
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
||||||
two_factor::enforce_2fa_policy_for_org(
|
two_factor::enforce_2fa_policy_for_org(
|
||||||
@@ -2102,7 +2191,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRemoved,
|
EventType::OrganizationUserRevokedSingleOrganizationNonCompliance,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2118,7 +2207,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
let mut policy = match current_policy {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
||||||
};
|
};
|
||||||
@@ -2222,7 +2311,10 @@ async fn bulk_revoke_members(
|
|||||||
for member_id in members {
|
for member_id in members {
|
||||||
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2325,7 +2417,10 @@ async fn bulk_restore_members(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => format!("{e:?}"),
|
Err(e) => {
|
||||||
|
warn!("Unable to restore member {}: {e:?}", member_id.escape_debug());
|
||||||
|
e.message().to_owned()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2478,13 +2573,16 @@ impl GroupRequest {
|
|||||||
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
||||||
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
|
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
|
||||||
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
err!(
|
||||||
|
"Invalid collection",
|
||||||
|
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
||||||
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
||||||
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
|
||||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
|
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2838,12 +2936,11 @@ async fn recover_account(
|
|||||||
err!("Organization user must be confirmed for password reset functionality");
|
err!("Organization user must be confirmed for password reset functionality");
|
||||||
}
|
}
|
||||||
|
|
||||||
let fallback_2fa_email =
|
let fallback_2fa_email = req.reset_two_factor
|
||||||
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
|
&& CONFIG.mail_enabled()
|
||||||
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none()
|
&& CONFIG.email_2fa_auto_fallback()
|
||||||
} else {
|
&& user.verified()
|
||||||
false
|
&& TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none();
|
||||||
};
|
|
||||||
|
|
||||||
// Check the new password before the email below, so that a rejected request doesn't tell the user
|
// Check the new password before the email below, so that a rejected request doesn't tell the user
|
||||||
// their password was reset
|
// their password was reset
|
||||||
|
|||||||
@@ -268,11 +268,12 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
|||||||
let file_id = crate::crypto::generate_send_file_id();
|
let file_id = crate::crypto::generate_send_file_id();
|
||||||
|
|
||||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||||
if let Some(o) = data_value.as_object_mut() {
|
let Some(o) = data_value.as_object_mut() else {
|
||||||
|
err!("Invalid Send file data")
|
||||||
|
};
|
||||||
o.insert(String::from("id"), Value::String(file_id.clone()));
|
o.insert(String::from("id"), Value::String(file_id.clone()));
|
||||||
o.insert(String::from("size"), Value::Number(file_length.into()));
|
o.insert(String::from("size"), Value::Number(file_length.into()));
|
||||||
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
||||||
}
|
|
||||||
send.data = serde_json::to_string(&data_value)?;
|
send.data = serde_json::to_string(&data_value)?;
|
||||||
send.save(&conn).await?;
|
send.save(&conn).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -84,18 +84,26 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if let Some(auth_request_id) = auth_request_id {
|
} else if let Some(auth_request_id) = auth_request_id {
|
||||||
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
|
||||||
err!("AuthRequest doesn't exist", "User not found")
|
|
||||||
};
|
|
||||||
let Some(code) = &data.auth_request_access_code else {
|
let Some(code) = &data.auth_request_access_code else {
|
||||||
err!("no auth request access code")
|
err!("no auth request access code")
|
||||||
};
|
};
|
||||||
|
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await
|
||||||
|
else {
|
||||||
|
err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user")
|
||||||
|
};
|
||||||
|
|
||||||
if auth_request.device_type != client_headers.device_type
|
let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5);
|
||||||
|
if !auth_request.approved.unwrap_or(false)
|
||||||
|
|| auth_request.authentication_date.is_some()
|
||||||
|
|| request_expired
|
||||||
|
|| auth_request.device_type != client_headers.device_type
|
||||||
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||||
|| !auth_request.check_access_code(code)
|
|| !auth_request.check_access_code(code)
|
||||||
{
|
{
|
||||||
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
err!(
|
||||||
|
"AuthRequest doesn't exist",
|
||||||
|
"Auth request not approved, already used or expired, or invalid device, IP or code"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if let Some(device_identifier) = &data.device_identifier {
|
} else if let Some(device_identifier) = &data.device_identifier {
|
||||||
// iOS/Android SSO logins send the email and device id but no password hash,
|
// iOS/Android SSO logins send the email and device id but no password hash,
|
||||||
@@ -294,7 +302,7 @@ pub async fn validate_email_code_str(
|
|||||||
|
|
||||||
if !crypto::ct_eq(issued_token, token) {
|
if !crypto::ct_eq(issued_token, token) {
|
||||||
email_data.add_attempt();
|
email_data.add_attempt();
|
||||||
if email_data.attempts >= CONFIG.email_attempts_limit() {
|
if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
||||||
email_data.reset_token();
|
email_data.reset_token();
|
||||||
}
|
}
|
||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
@@ -313,7 +321,7 @@ pub async fn validate_email_code_str(
|
|||||||
twofactor.save(conn).await?;
|
twofactor.save(conn).await?;
|
||||||
|
|
||||||
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
let max_time = i64::from(CONFIG.email_expiration_time());
|
||||||
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||||
err!(
|
err!(
|
||||||
"Token has expired",
|
"Token has expired",
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevoked,
|
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&member.org_uuid,
|
&member.org_uuid,
|
||||||
act_user_id,
|
act_user_id,
|
||||||
@@ -185,7 +185,15 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
member.revoke();
|
member.revoke();
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
log_event(
|
||||||
|
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
||||||
|
&member.uuid,
|
||||||
|
org_id,
|
||||||
|
act_user_id,
|
||||||
|
device_type,
|
||||||
|
ip,
|
||||||
|
conn,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +204,7 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||||
debug!("Sending notifications for incomplete 2FA logins");
|
debug!("Sending notifications for incomplete 2FA logins");
|
||||||
|
|
||||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +214,21 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
|
let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
|
||||||
let time_before = now - time_limit;
|
error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(time_before) = now.checked_sub_signed(time_limit) else {
|
||||||
|
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
||||||
for login in incomplete_logins {
|
for login in incomplete_logins {
|
||||||
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
|
let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
|
||||||
|
error!("User '{}', not found!", login.user_uuid);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
info!(
|
info!(
|
||||||
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
||||||
user.email, login.ip_address
|
user.email, login.ip_address
|
||||||
|
|||||||
@@ -128,23 +128,24 @@ pub async fn validate_protected_action_otp(
|
|||||||
.await
|
.await
|
||||||
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
||||||
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||||
pa_data.add_attempt();
|
|
||||||
pa.data = pa_data.to_json();
|
|
||||||
|
|
||||||
// Fail after x attempts if the token has been used too many times.
|
// Fail after x attempts if the token has been used too many times.
|
||||||
// Don't delete it, as we use it to keep track of attempts.
|
// Don't delete it, as we use it to keep track of attempts.
|
||||||
if pa_data.attempts >= CONFIG.email_attempts_limit() {
|
if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token has expired (Using the email 2fa expiration time)
|
// Check if the token has expired (Using the email 2fa expiration time)
|
||||||
let max_time = CONFIG.email_expiration_time().cast_signed();
|
let max_time = i64::from(CONFIG.email_expiration_time());
|
||||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||||
pa.delete(conn).await?;
|
pa.delete(conn).await?;
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !crypto::ct_eq(&pa_data.token, otp) {
|
if !crypto::ct_eq(&pa_data.token, otp) {
|
||||||
|
pa_data.add_attempt();
|
||||||
|
pa.data = pa_data.to_json();
|
||||||
|
|
||||||
pa.save(conn).await?;
|
pa.save(conn).await?;
|
||||||
err!("Token is invalid")
|
err!("Token is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-4
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
|||||||
// Generate the cookie store
|
// Generate the cookie store
|
||||||
let cookie_store = Arc::new(Jar::default());
|
let cookie_store = Arc::new(Jar::default());
|
||||||
|
|
||||||
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
|
||||||
let pool_idle_timeout = Duration::from_secs(10);
|
let pool_idle_timeout = Duration::from_secs(10);
|
||||||
// Reuse the client between requests
|
// Reuse the client between requests
|
||||||
get_reqwest_client_builder(true)
|
get_reqwest_client_builder(true)
|
||||||
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
||||||
let meta = operator.stat(path).await?;
|
let meta = operator.stat(path).await?;
|
||||||
let modified =
|
let modified =
|
||||||
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
||||||
let age = SystemTime::now().duration_since(modified.into())?;
|
let age = SystemTime::now().duration_since(modified.into())?;
|
||||||
|
|
||||||
Ok(ttl > 0 && ttl <= age.as_secs())
|
Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn icon_is_negcached(path: &str) -> bool {
|
async fn icon_is_negcached(path: &str) -> bool {
|
||||||
@@ -264,7 +264,7 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
|||||||
TAG_BASE => {
|
TAG_BASE => {
|
||||||
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
|
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
|
||||||
let href = std::str::from_utf8(href).unwrap_or_default();
|
let href = std::str::from_utf8(href).unwrap_or_default();
|
||||||
debug!("Found base href: {href}");
|
debug!("Found base href: {}", href.escape_debug());
|
||||||
match base_url.join(href) {
|
match base_url.join(href) {
|
||||||
Ok(inner_url) => inner_url,
|
Ok(inner_url) => inner_url,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@@ -663,6 +663,17 @@ impl reqwest::cookie::CookieStore for Jar {
|
|||||||
use time::Duration;
|
use time::Duration;
|
||||||
|
|
||||||
let mut cookie_store = self.0.write().unwrap();
|
let mut cookie_store = self.0.write().unwrap();
|
||||||
|
|
||||||
|
// The store only skips expired cookies when reading them, so remove them here or they are kept forever
|
||||||
|
let expired: Vec<_> = cookie_store
|
||||||
|
.iter_any()
|
||||||
|
.filter(|c| c.is_expired())
|
||||||
|
.map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned()))
|
||||||
|
.collect();
|
||||||
|
for (domain, path, name) in expired {
|
||||||
|
cookie_store.remove(&domain, &path, &name);
|
||||||
|
}
|
||||||
|
|
||||||
let cookies = cookie_headers.filter_map(|val| {
|
let cookies = cookie_headers.filter_map(|val| {
|
||||||
std::str::from_utf8(val.as_bytes())
|
std::str::from_utf8(val.as_bytes())
|
||||||
.map_err(RawCookieParseError::from)
|
.map_err(RawCookieParseError::from)
|
||||||
|
|||||||
+30
-3
@@ -46,6 +46,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
post_prelogin,
|
post_prelogin,
|
||||||
prelogin_password,
|
prelogin_password,
|
||||||
register_verification_email,
|
register_verification_email,
|
||||||
|
register_verification_email_clicked,
|
||||||
register_finish,
|
register_finish,
|
||||||
prevalidate,
|
prevalidate,
|
||||||
authorize,
|
authorize,
|
||||||
@@ -111,7 +112,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(tokens.to_json()))
|
Ok(Json(tokens.to_json()))
|
||||||
}
|
}
|
||||||
t => err!("Invalid type", t),
|
t => err!("Invalid type", t.escape_debug().to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(user_id) = user_id {
|
if let Some(user_id) = user_id {
|
||||||
@@ -447,9 +448,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
|||||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||||
if user.last_verifying_at.is_none()
|
if user.last_verifying_at.is_none()
|
||||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||||
> CONFIG.signups_verify_resend_time().cast_signed()
|
> i64::from(CONFIG.signups_verify_resend_time())
|
||||||
{
|
{
|
||||||
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
|
let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
|
||||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||||
// We want to send another email verification if we require signups to verify
|
// We want to send another email verification if we require signups to verify
|
||||||
// their email address, and we haven't sent them a reminder in a while...
|
// their email address, and we haven't sent them a reminder in a while...
|
||||||
@@ -1034,6 +1035,13 @@ struct RegisterVerificationData {
|
|||||||
// receiveMarketingEmails: bool,
|
// receiveMarketingEmails: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RegisterVerificationEmailClickedData {
|
||||||
|
email: String,
|
||||||
|
email_verification_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(rocket::Responder)]
|
#[derive(rocket::Responder)]
|
||||||
enum RegisterVerificationResponse {
|
enum RegisterVerificationResponse {
|
||||||
#[response(status = 204)]
|
#[response(status = 204)]
|
||||||
@@ -1096,6 +1104,25 @@ async fn register_verification_email(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/accounts/register/verification-email-clicked", data = "<data>")]
|
||||||
|
async fn register_verification_email_clicked(
|
||||||
|
data: Json<RegisterVerificationEmailClickedData>,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
|
let data = data.into_inner();
|
||||||
|
let token_valid = auth::decode_register_verify(&data.email_verification_token)
|
||||||
|
.is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email));
|
||||||
|
let user_exists = User::find_by_mail(&data.email, &conn)
|
||||||
|
.await
|
||||||
|
.is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some());
|
||||||
|
|
||||||
|
if !token_valid || user_exists {
|
||||||
|
err!("Expired link. Please restart registration or try logging in. You may already have an account")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/accounts/register/finish", data = "<data>")]
|
#[post("/accounts/register/finish", data = "<data>")]
|
||||||
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|||||||
+3
-3
@@ -25,12 +25,12 @@ pub use crate::api::{
|
|||||||
notifications::routes as notifications_routes,
|
notifications::routes as notifications_routes,
|
||||||
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
||||||
push::{
|
push::{
|
||||||
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
|
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
|
||||||
unregister_push_device,
|
register_push_device, unregister_push_device,
|
||||||
},
|
},
|
||||||
web::catchers as web_catchers,
|
web::catchers as web_catchers,
|
||||||
web::routes as web_routes,
|
web::routes as web_routes,
|
||||||
web::{invalidate_css_cache, static_files},
|
web::{STATIC_FILES, invalidate_css_cache},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ fn websockets_hub<'r>(
|
|||||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
yield Message::Ping(create_ping());
|
yield Message::Ping(PING.to_vec());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +295,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
yield Message::Ping(create_ping());
|
yield Message::Ping(PING.to_vec());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,6 +359,7 @@ fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
|
|||||||
|
|
||||||
const RECORD_SEPARATOR: u8 = 0x1e;
|
const RECORD_SEPARATOR: u8 = 0x1e;
|
||||||
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
||||||
|
const PING: [u8; 3] = [0x02, 0x91, 0x06];
|
||||||
|
|
||||||
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
||||||
struct InitialMessage<'a> {
|
struct InitialMessage<'a> {
|
||||||
@@ -691,10 +692,6 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
|||||||
serialize(&value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_ping() -> Vec<u8> {
|
|
||||||
serialize(&Value::Array(vec![6.into()]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
pub enum UpdateType {
|
pub enum UpdateType {
|
||||||
|
|||||||
+20
-10
@@ -139,21 +139,25 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
|
|||||||
if !CONFIG.push_enabled() || push_id.is_none() {
|
if !CONFIG.push_enabled() || push_id.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let auth_api_token = get_auth_api_token().await?;
|
let data = json!({
|
||||||
|
"id": push_id.as_ref().unwrap(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let auth_api_token = get_auth_api_token().await?;
|
||||||
let auth_header = format!("Bearer {auth_api_token}");
|
let auth_header = format!("Bearer {auth_api_token}");
|
||||||
|
|
||||||
match make_http_request(
|
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))?
|
||||||
Method::POST,
|
.header(CONTENT_TYPE, "application/json")
|
||||||
&format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()),
|
.header(ACCEPT, "application/json")
|
||||||
)?
|
|
||||||
.header(AUTHORIZATION, auth_header)
|
.header(AUTHORIZATION, auth_header)
|
||||||
|
.json(&data)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await?
|
||||||
|
.error_for_status()
|
||||||
{
|
{
|
||||||
Ok(r) => r,
|
err!(format!("An error occurred during device unregistration: {e}"));
|
||||||
Err(e) => err!(format!("An error occurred during device unregistration: {e}")),
|
}
|
||||||
};
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +194,13 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
|||||||
|
|
||||||
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
|
push_logout_to_relay(user, acting_device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends the logout without checking for push devices first.
|
||||||
|
// Needed after deleting a user, when the devices are already gone from the database.
|
||||||
|
pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
"organizationId": (),
|
"organizationId": (),
|
||||||
@@ -203,7 +214,6 @@ pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbC
|
|||||||
"clientType": null,
|
"clientType": null,
|
||||||
"installationId": null
|
"installationId": null
|
||||||
})));
|
})));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||||
|
|||||||
+64
-33
@@ -1,13 +1,14 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, RwLock},
|
sync::{Arc, LazyLock, RwLock},
|
||||||
};
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Catcher, Route,
|
Catcher, Route,
|
||||||
fs::NamedFile,
|
fs::NamedFile,
|
||||||
http::ContentType,
|
http::ContentType,
|
||||||
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
response::{Redirect, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -19,7 +20,7 @@ use crate::{
|
|||||||
crypto::sha256_hex,
|
crypto::sha256_hex,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AttachmentId, CipherId},
|
models::{AttachmentId, CipherId, custom_roles_enabled},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
util::{Cached, EtagCached},
|
util::{Cached, EtagCached},
|
||||||
@@ -79,16 +80,17 @@ pub fn invalidate_css_cache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/css/vaultwarden.css")]
|
#[get("/css/vaultwarden.css")]
|
||||||
fn vaultwarden_css() -> EtagCached<Css<String>> {
|
fn vaultwarden_css() -> EtagCached<String> {
|
||||||
// If reload_templates is false, and we already have the CSS Cached, return this
|
// If reload_templates is false, and we already have the CSS Cached, return this
|
||||||
if !CONFIG.reload_templates()
|
if !CONFIG.reload_templates()
|
||||||
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
||||||
{
|
{
|
||||||
return EtagCached::new(Css(cached.css.clone()), &cached.etag);
|
return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -147,7 +149,7 @@ fn vaultwarden_css() -> EtagCached<Css<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||||
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -256,7 +258,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
|||||||
// This endpoint/function is used during development and development only.
|
// This endpoint/function is used during development and development only.
|
||||||
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
|
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
|
||||||
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
|
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
|
||||||
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below!
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
#[get("/vw_static/<filename>", rank = 1)]
|
#[get("/vw_static/<filename>", rank = 1)]
|
||||||
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||||
@@ -276,32 +278,61 @@ pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct StaticFile {
|
||||||
|
pub content_type: ContentType,
|
||||||
|
pub bytes: &'static [u8],
|
||||||
|
pub etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static STATIC_FILES: LazyLock<HashMap<&'static str, StaticFile>> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]),
|
||||||
|
("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]),
|
||||||
|
("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]),
|
||||||
|
("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]),
|
||||||
|
("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]),
|
||||||
|
("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]),
|
||||||
|
("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]),
|
||||||
|
("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]),
|
||||||
|
("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]),
|
||||||
|
("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]),
|
||||||
|
("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]),
|
||||||
|
("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]),
|
||||||
|
(
|
||||||
|
"admin_organizations.js",
|
||||||
|
ContentType::JavaScript,
|
||||||
|
&include_bytes!("../static/scripts/admin_organizations.js")[..],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"admin_diagnostics.js",
|
||||||
|
ContentType::JavaScript,
|
||||||
|
&include_bytes!("../static/scripts/admin_diagnostics.js")[..],
|
||||||
|
),
|
||||||
|
("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]),
|
||||||
|
("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]),
|
||||||
|
("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]),
|
||||||
|
("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]),
|
||||||
|
("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, content_type, bytes)| {
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
StaticFile {
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
etag: sha256_hex(bytes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
#[get("/vw_static/<filename>", rank = 2)]
|
#[get("/vw_static/<filename>", rank = 2)]
|
||||||
pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> {
|
pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error> {
|
||||||
match filename {
|
if let Some(file) = STATIC_FILES.get(filename) {
|
||||||
"404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))),
|
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
|
||||||
"mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
|
} else {
|
||||||
"logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
|
err!(format!("Static file not found: {}", filename.escape_debug()))
|
||||||
"error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
|
|
||||||
"hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
|
|
||||||
"vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))),
|
|
||||||
"vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))),
|
|
||||||
"404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))),
|
|
||||||
"admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))),
|
|
||||||
"admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))),
|
|
||||||
"admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))),
|
|
||||||
"admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))),
|
|
||||||
"admin_organizations.js" => {
|
|
||||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js")))
|
|
||||||
}
|
|
||||||
"admin_diagnostics.js" => {
|
|
||||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js")))
|
|
||||||
}
|
|
||||||
"bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))),
|
|
||||||
"bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))),
|
|
||||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
|
||||||
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
|
||||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
|
||||||
_ => err!(format!("Static file not found: {filename}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+47
-25
@@ -314,6 +314,17 @@ impl LoginJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
|
||||||
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
|
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
|
||||||
|
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
|
||||||
|
};
|
||||||
|
let Some(exp) = now.checked_add_signed(hours_delta) else {
|
||||||
|
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
|
||||||
|
};
|
||||||
|
Ok(exp)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct InviteJwtClaims {
|
pub struct InviteJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -337,19 +348,19 @@ pub fn generate_invite_claims(
|
|||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
invited_by_email: Option<String>,
|
invited_by_email: Option<String>,
|
||||||
) -> InviteJwtClaims {
|
) -> Result<InviteJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
InviteJwtClaims {
|
Ok(InviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_INVITE_ISSUER.to_string(),
|
iss: JWT_INVITE_ISSUER.to_string(),
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
email,
|
email,
|
||||||
org_id,
|
org_id,
|
||||||
member_id,
|
member_id,
|
||||||
invited_by_email,
|
invited_by_email,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -375,19 +386,19 @@ pub fn generate_emergency_access_invite_claims(
|
|||||||
emer_id: EmergencyAccessId,
|
emer_id: EmergencyAccessId,
|
||||||
grantor_name: String,
|
grantor_name: String,
|
||||||
grantor_email: String,
|
grantor_email: String,
|
||||||
) -> EmergencyAccessInviteJwtClaims {
|
) -> Result<EmergencyAccessInviteJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
EmergencyAccessInviteJwtClaims {
|
Ok(EmergencyAccessInviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
email,
|
email,
|
||||||
emer_id,
|
emer_id,
|
||||||
grantor_name,
|
grantor_name,
|
||||||
grantor_email,
|
grantor_email,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -492,13 +503,17 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) ->
|
|||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
TwoFactorRememberClaims {
|
TwoFactorRememberClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()),
|
||||||
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
||||||
sub: device_uuid,
|
sub: device_uuid,
|
||||||
user_uuid,
|
user_uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn two_factor_remember_expiration(time_now: DateTime<Utc>, remember_days: i64) -> i64 {
|
||||||
|
(time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct BasicJwtClaims {
|
pub struct BasicJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -521,36 +536,43 @@ impl BasicJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
BasicJwtClaims {
|
Ok(BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_DELETE_ISSUER.to_string(),
|
iss: JWT_DELETE_ISSUER.to_string(),
|
||||||
sub: uuid,
|
sub: uuid,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
|
pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let exp = calculate_invite_expire_hours(&time_now)?;
|
||||||
BasicJwtClaims {
|
Ok(BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_admin_claims() -> BasicJwtClaims {
|
pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
BasicJwtClaims {
|
let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
|
||||||
|
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
|
||||||
|
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
||||||
|
};
|
||||||
|
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
|
||||||
|
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
||||||
|
};
|
||||||
|
Ok(BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
exp: exp.timestamp(),
|
||||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||||
sub: "admin_panel".to_owned(),
|
sub: "admin_panel".to_owned(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
||||||
|
|||||||
+5
-1
@@ -72,7 +72,11 @@ impl SendTokens {
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> ApiResult<SendTokens> {
|
) -> ApiResult<SendTokens> {
|
||||||
let Some(send_id) = Self::as_send_id(access_id) else {
|
let Some(send_id) = Self::as_send_id(access_id) else {
|
||||||
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
|
return Self::invalid_error(
|
||||||
|
&format!("Can't convert {}", access_id.escape_debug()),
|
||||||
|
"send_id_invalid",
|
||||||
|
false,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
|
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
|
||||||
|
|||||||
+141
-34
@@ -111,6 +111,7 @@ macro_rules! make_config {
|
|||||||
_usr: ConfigBuilder,
|
_usr: ConfigBuilder,
|
||||||
|
|
||||||
_overrides: Vec<&'static str>,
|
_overrides: Vec<&'static str>,
|
||||||
|
_config_file_status: ConfigFileStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
||||||
@@ -259,11 +260,22 @@ macro_rules! make_config {
|
|||||||
builder
|
builder
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn from_file() -> Result<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 = operator.read(&CONFIG_FILENAME).await?;
|
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
// No config file is fine, should not return any error
|
||||||
|
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
|
||||||
|
// Any other error might be an issue and should be passed back to the caller
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
|
||||||
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
||||||
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
|
Ok(Some(builder))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_non_editable(&mut self) {
|
fn clear_non_editable(&mut self) {
|
||||||
@@ -440,6 +452,8 @@ macro_rules! make_config {
|
|||||||
"domain_origin",
|
"domain_origin",
|
||||||
"domain_path",
|
"domain_path",
|
||||||
"domain",
|
"domain",
|
||||||
|
"duo_ikey",
|
||||||
|
"duo_host",
|
||||||
"helo_name",
|
"helo_name",
|
||||||
"org_creation_users",
|
"org_creation_users",
|
||||||
"signups_domains_whitelist",
|
"signups_domains_whitelist",
|
||||||
@@ -451,6 +465,8 @@ macro_rules! make_config {
|
|||||||
"sso_authority",
|
"sso_authority",
|
||||||
"sso_callback_path",
|
"sso_callback_path",
|
||||||
"sso_client_id",
|
"sso_client_id",
|
||||||
|
"yubico_client_id",
|
||||||
|
"yubico_server",
|
||||||
];
|
];
|
||||||
|
|
||||||
let cfg = {
|
let cfg = {
|
||||||
@@ -483,6 +499,10 @@ macro_rules! make_config {
|
|||||||
};
|
};
|
||||||
overrides
|
overrides
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_config_file_status(&self) -> ConfigFileStatus {
|
||||||
|
self.inner.read().unwrap()._config_file_status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -539,7 +559,7 @@ make_config! {
|
|||||||
jobs {
|
jobs {
|
||||||
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
|
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
|
||||||
/// Set to 0 to globally disable scheduled jobs.
|
/// Set to 0 to globally disable scheduled jobs.
|
||||||
job_poll_interval_ms: u64, false, def, 30_000;
|
job_poll_interval_ms: u32, false, def, 30_000;
|
||||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
||||||
/// Defaults to hourly. Set blank to disable this job.
|
/// Defaults to hourly. Set blank to disable this job.
|
||||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
||||||
@@ -587,6 +607,9 @@ make_config! {
|
|||||||
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
|
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
|
||||||
sends_allowed: bool, true, def, true;
|
sends_allowed: bool, true, def, true;
|
||||||
|
|
||||||
|
/// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory.
|
||||||
|
fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned();
|
||||||
|
|
||||||
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
||||||
hibp_api_key: Pass, true, option;
|
hibp_api_key: Pass, true, option;
|
||||||
|
|
||||||
@@ -598,16 +621,16 @@ make_config! {
|
|||||||
user_send_limit: i64, true, option;
|
user_send_limit: i64, true, option;
|
||||||
|
|
||||||
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
|
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
|
||||||
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
|
/// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
|
||||||
/// sure to inform all users of any changes to this setting.
|
/// sure to inform all users of any changes to this setting.
|
||||||
trash_auto_delete_days: i64, true, option;
|
trash_auto_delete_days: u16, true, option;
|
||||||
|
|
||||||
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
||||||
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
|
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
|
||||||
/// where the correct master password was provided but the required 2FA step was not completed,
|
/// where the correct master password was provided but the required 2FA step was not completed,
|
||||||
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
|
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
|
||||||
/// This setting applies globally to all users.
|
/// This setting applies globally to all users.
|
||||||
incomplete_2fa_time_limit: i64, true, def, 3;
|
incomplete_2fa_time_limit: u16, true, def, 3;
|
||||||
|
|
||||||
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
||||||
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
|
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
|
||||||
@@ -620,9 +643,9 @@ make_config! {
|
|||||||
/// this will prevent logins from succeeding until the address has been verified
|
/// this will prevent logins from succeeding until the address has been verified
|
||||||
signups_verify: bool, true, def, false;
|
signups_verify: bool, true, def, false;
|
||||||
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
||||||
signups_verify_resend_time: u64, true, def, 3_600;
|
signups_verify_resend_time: u32, true, def, 3_600;
|
||||||
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
||||||
signups_verify_resend_limit: u32, true, def, 6;
|
signups_verify_resend_limit: u16, true, def, 6;
|
||||||
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
||||||
signups_domains_whitelist: String, true, def, String::new();
|
signups_domains_whitelist: String, true, def, String::new();
|
||||||
/// Enable event logging |> Enables event logging for organizations.
|
/// Enable event logging |> Enables event logging for organizations.
|
||||||
@@ -634,7 +657,7 @@ make_config! {
|
|||||||
invitations_allowed: bool, true, def, true;
|
invitations_allowed: bool, true, def, true;
|
||||||
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
|
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
|
||||||
/// email verification token and deletion request token will expire (must be at least 1)
|
/// email verification token and deletion request token will expire (must be at least 1)
|
||||||
invitation_expiration_hours: u32, false, def, 120;
|
invitation_expiration_hours: u16, false, def, 120;
|
||||||
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
||||||
emergency_access_allowed: bool, true, def, true;
|
emergency_access_allowed: bool, true, def, true;
|
||||||
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
|
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
|
||||||
@@ -655,8 +678,8 @@ make_config! {
|
|||||||
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
||||||
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
||||||
|
|
||||||
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
/// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
|
||||||
events_days_retain: i64, false, option;
|
events_days_retain: u16, false, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
client {
|
client {
|
||||||
@@ -694,13 +717,13 @@ make_config! {
|
|||||||
/// Temporary redirects are useful while testing different icon services, but once a service
|
/// Temporary redirects are useful while testing different icon services, but once a service
|
||||||
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
|
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
|
||||||
/// are currently better supported by the Bitwarden clients.
|
/// are currently better supported by the Bitwarden clients.
|
||||||
icon_redirect_code: u32, true, def, 302;
|
icon_redirect_code: u16, true, def, 302;
|
||||||
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
||||||
icon_cache_ttl: u64, true, def, 2_592_000;
|
icon_cache_ttl: u32, true, def, 2_592_000;
|
||||||
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
||||||
icon_cache_negttl: u64, true, def, 259_200;
|
icon_cache_negttl: u32, true, def, 259_200;
|
||||||
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
||||||
icon_download_timeout: u64, true, def, 10;
|
icon_download_timeout: u16, true, def, 10;
|
||||||
|
|
||||||
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
||||||
icon_blacklist_regex: String, false, option;
|
icon_blacklist_regex: String, false, option;
|
||||||
@@ -717,6 +740,8 @@ make_config! {
|
|||||||
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
||||||
/// Note that the checkbox would still be present, but ignored.
|
/// Note that the checkbox would still be present, but ignored.
|
||||||
disable_2fa_remember: bool, true, def, false;
|
disable_2fa_remember: bool, true, def, false;
|
||||||
|
/// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90).
|
||||||
|
two_factor_remember_days: i64, true, def, 30;
|
||||||
|
|
||||||
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
|
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
|
||||||
/// TOTP codes of the previous and next 30 seconds will be invalid.
|
/// TOTP codes of the previous and next 30 seconds will be invalid.
|
||||||
@@ -751,11 +776,11 @@ make_config! {
|
|||||||
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
||||||
db_connection_retries: u32, false, def, 15;
|
db_connection_retries: u32, false, def, 15;
|
||||||
|
|
||||||
/// Timeout when acquiring database connection
|
/// Timeout in seconds when acquiring database connection
|
||||||
database_timeout: u64, false, def, 30;
|
database_timeout: u32, false, def, 30;
|
||||||
|
|
||||||
/// Timeout in seconds before idle connections to the database are closed
|
/// Timeout in seconds before idle connections to the database are closed
|
||||||
database_idle_timeout: u64, false, def, 600;
|
database_idle_timeout: u32, false, def, 600;
|
||||||
|
|
||||||
/// Database connection max pool size
|
/// Database connection max pool size
|
||||||
database_max_conns: u32, false, def, 10;
|
database_max_conns: u32, false, def, 10;
|
||||||
@@ -776,26 +801,30 @@ make_config! {
|
|||||||
allowed_connect_src: String, true, def, String::new();
|
allowed_connect_src: String, true, def, String::new();
|
||||||
|
|
||||||
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
||||||
login_ratelimit_seconds: u64, false, def, 60;
|
login_ratelimit_seconds: u16, false, def, 60;
|
||||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||||
login_ratelimit_max_burst: u32, false, def, 10;
|
login_ratelimit_max_burst: u16, false, def, 10;
|
||||||
|
|
||||||
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||||
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
unauthenticated_ratelimit_seconds: u16, false, def, 60;
|
||||||
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||||
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
unauthenticated_ratelimit_max_burst: u16, false, def, 50;
|
||||||
|
|
||||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||||
admin_ratelimit_seconds: u64, false, def, 300;
|
admin_ratelimit_seconds: u16, false, def, 300;
|
||||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||||
admin_ratelimit_max_burst: u32, false, def, 3;
|
admin_ratelimit_max_burst: u16, false, def, 3;
|
||||||
|
|
||||||
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
||||||
admin_session_lifetime: i64, true, def, 20;
|
admin_session_lifetime: u32, true, def, 20;
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -844,8 +873,8 @@ make_config! {
|
|||||||
sso_master_password_policy: String, true, option;
|
sso_master_password_policy: String, true, option;
|
||||||
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
||||||
sso_auth_only_not_session: bool, true, def, false;
|
sso_auth_only_not_session: bool, true, def, false;
|
||||||
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
/// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
||||||
sso_client_cache_expiration: u64, true, def, 0;
|
sso_client_cache_expiration: u32, true, def, 0;
|
||||||
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
||||||
sso_debug_tokens: bool, true, def, false;
|
sso_debug_tokens: bool, true, def, false;
|
||||||
},
|
},
|
||||||
@@ -903,7 +932,7 @@ make_config! {
|
|||||||
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
||||||
smtp_auth_mechanism: String, true, option;
|
smtp_auth_mechanism: String, true, option;
|
||||||
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
||||||
smtp_timeout: u64, true, def, 15;
|
smtp_timeout: u16, true, def, 15;
|
||||||
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
||||||
helo_name: String, true, option;
|
helo_name: String, true, option;
|
||||||
/// Embed images as email attachments.
|
/// Embed images as email attachments.
|
||||||
@@ -925,9 +954,9 @@ make_config! {
|
|||||||
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
||||||
email_token_size: u8, true, def, 6;
|
email_token_size: u8, true, def, 6;
|
||||||
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
|
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
|
||||||
email_expiration_time: u64, true, def, 600;
|
email_expiration_time: u32, true, def, 600;
|
||||||
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
||||||
email_attempts_limit: u64, true, def, 3;
|
email_attempts_limit: u8, true, def, 3;
|
||||||
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
||||||
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
||||||
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
||||||
@@ -983,7 +1012,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.database_min_conns > cfg.database_max_conns {
|
if cfg.database_min_conns > cfg.database_max_conns {
|
||||||
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
|
err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.database_timeout < 1 {
|
||||||
|
err!("`DATABASE_TIMEOUT` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.database_idle_timeout < 1 {
|
||||||
|
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(log_file) = &cfg.log_file
|
if let Some(log_file) = &cfg.log_file
|
||||||
@@ -999,6 +1036,21 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| {
|
||||||
|
url.scheme() == "https"
|
||||||
|
&& url.has_host()
|
||||||
|
&& url.username().is_empty()
|
||||||
|
&& url.password().is_none()
|
||||||
|
&& url.query().is_none()
|
||||||
|
&& url.fragment().is_none()
|
||||||
|
&& cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url
|
||||||
|
});
|
||||||
|
if !valid_fill_assist_rules_url {
|
||||||
|
err!(
|
||||||
|
"`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
||||||
for url in connect_src.split_whitespace() {
|
for url in connect_src.split_whitespace() {
|
||||||
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
||||||
@@ -1233,6 +1285,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
|
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !(1..=90).contains(&cfg.two_factor_remember_days) {
|
||||||
|
err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days")
|
||||||
|
}
|
||||||
|
|
||||||
// Validate schedule crontab format
|
// Validate schedule crontab format
|
||||||
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
|
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
|
||||||
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
|
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
|
||||||
@@ -1274,6 +1330,30 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
|
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.login_ratelimit_seconds < 1 {
|
||||||
|
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.login_ratelimit_max_burst < 1 {
|
||||||
|
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.admin_ratelimit_seconds < 1 {
|
||||||
|
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.admin_ratelimit_max_burst < 1 {
|
||||||
|
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.unauthenticated_ratelimit_seconds < 1 {
|
||||||
|
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.unauthenticated_ratelimit_max_burst < 1 {
|
||||||
|
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
if !cfg.disable_admin_token {
|
if !cfg.disable_admin_token {
|
||||||
match cfg.admin_token.as_ref() {
|
match cfg.admin_token.as_ref() {
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
@@ -1429,6 +1509,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
// Autofill Team
|
// Autofill Team
|
||||||
"undetermined-cipher-scenario-logic",
|
"undetermined-cipher-scenario-logic",
|
||||||
"enable-basic-auth-response",
|
"enable-basic-auth-response",
|
||||||
|
"fill-assist-targeting-rules",
|
||||||
"ssh-agent-v2",
|
"ssh-agent-v2",
|
||||||
"windows-desktop-autotype",
|
"windows-desktop-autotype",
|
||||||
"windows-desktop-autotype-ga",
|
"windows-desktop-autotype-ga",
|
||||||
@@ -1443,11 +1524,34 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
"pm-32009-new-item-types",
|
"pm-32009-new-item-types",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Serialize)]
|
||||||
|
pub enum ConfigFileStatus {
|
||||||
|
NotFound,
|
||||||
|
Loaded,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load() -> Result<Self, Error> {
|
pub async fn load() -> Result<Self, Error> {
|
||||||
// Loading from env and file
|
// Loading from env and file
|
||||||
let env = ConfigBuilder::from_env();
|
let env = ConfigBuilder::from_env();
|
||||||
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
let (usr, config_file_status) = match ConfigBuilder::from_file().await {
|
||||||
|
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
|
||||||
|
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
|
||||||
|
Err(e) => {
|
||||||
|
println!(
|
||||||
|
"[ERROR] Failed to load configuration from `{}`\n\
|
||||||
|
#####################################################################################\n\
|
||||||
|
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
|
||||||
|
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
|
||||||
|
# This file is not used until those errors are resolved. #\n\
|
||||||
|
#####################################################################################\n\
|
||||||
|
{e:?}\n",
|
||||||
|
*CONFIG_FILE
|
||||||
|
);
|
||||||
|
(ConfigBuilder::default(), ConfigFileStatus::Error)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Create merged config, config file overwrites env
|
// Create merged config, config file overwrites env
|
||||||
let mut overrides = Vec::new();
|
let mut overrides = Vec::new();
|
||||||
@@ -1467,6 +1571,7 @@ impl Config {
|
|||||||
_env: env,
|
_env: env,
|
||||||
_usr: usr,
|
_usr: usr,
|
||||||
_overrides: overrides,
|
_overrides: overrides,
|
||||||
|
_config_file_status: config_file_status,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1500,6 +1605,7 @@ impl Config {
|
|||||||
writer.config = config;
|
writer.config = config;
|
||||||
writer._usr = builder;
|
writer._usr = builder;
|
||||||
writer._overrides = overrides;
|
writer._overrides = overrides;
|
||||||
|
writer._config_file_status = ConfigFileStatus::Loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Save to file
|
//Save to file
|
||||||
@@ -1518,7 +1624,7 @@ impl Config {
|
|||||||
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
||||||
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||||
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
||||||
warn!("Failed to parse email address '{email}'");
|
warn!("Failed to parse email address '{}'", email.escape_debug());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let email_domain = e[0].to_lowercase();
|
let email_domain = e[0].to_lowercase();
|
||||||
@@ -1591,6 +1697,7 @@ impl Config {
|
|||||||
writer.config = config;
|
writer.config = config;
|
||||||
writer._usr = usr;
|
writer._usr = usr;
|
||||||
writer._overrides = Vec::new();
|
writer._overrides = Vec::new();
|
||||||
|
writer._config_file_status = ConfigFileStatus::NotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||||
|
|||||||
+74
-3
@@ -212,8 +212,8 @@ impl DbPool {
|
|||||||
let pool = Pool::builder()
|
let pool = Pool::builder()
|
||||||
.max_size(max_conns)
|
.max_size(max_conns)
|
||||||
.min_idle(Some(CONFIG.database_min_conns()))
|
.min_idle(Some(CONFIG.database_min_conns()))
|
||||||
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout())))
|
.idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
|
||||||
.connection_timeout(Duration::from_secs(CONFIG.database_timeout()))
|
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
|
||||||
.connection_customizer(Box::new(DbConnOptions {
|
.connection_customizer(Box::new(DbConnOptions {
|
||||||
init_stmts: conn_type.get_init_stmts(),
|
init_stmts: conn_type.get_init_stmts(),
|
||||||
}))
|
}))
|
||||||
@@ -233,7 +233,7 @@ impl DbPool {
|
|||||||
|
|
||||||
// Get a connection from the pool
|
// Get a connection from the pool
|
||||||
pub async fn get(&self) -> Result<DbConn, Error> {
|
pub async fn get(&self) -> Result<DbConn, Error> {
|
||||||
let duration = Duration::from_secs(CONFIG.database_timeout());
|
let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
|
||||||
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
||||||
Ok(p) => p.expect("Semaphore should be open"),
|
Ok(p) => p.expect("Semaphore should be open"),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -534,3 +534,74 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -197,7 +197,7 @@ impl AuthRequest {
|
|||||||
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
||||||
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
||||||
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
||||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
|
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
|
||||||
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
||||||
auth_request.delete(conn).await.ok();
|
auth_request.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-9
@@ -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, MembershipType, OrganizationId, User, UserId,
|
MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -494,14 +494,24 @@ impl Cipher {
|
|||||||
|
|
||||||
/// Purge all ciphers that are old enough to be auto-deleted.
|
/// Purge all ciphers that are old enough to be auto-deleted.
|
||||||
pub async fn purge_trash(conn: &DbConn) {
|
pub async fn purge_trash(conn: &DbConn) {
|
||||||
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
|
let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
|
||||||
|
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
|
||||||
|
error!("Unable to calculate auto delete days!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap();
|
let Some(dt) = now.checked_sub_signed(days) else {
|
||||||
|
error!("Unable to calculate trash purge date!");
|
||||||
|
return;
|
||||||
|
};
|
||||||
for cipher in Self::find_deleted_before(&dt, conn).await {
|
for cipher in Self::find_deleted_before(&dt, conn).await {
|
||||||
cipher.delete(conn).await.ok();
|
cipher.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn move_to_folder(
|
pub async fn move_to_folder(
|
||||||
&self,
|
&self,
|
||||||
@@ -510,7 +520,16 @@ impl Cipher {
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
|
self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as `move_to_folder`, but the caller is responsible for updating the user's revision.
|
||||||
|
pub async fn move_to_folder_without_revision_update(
|
||||||
|
&self,
|
||||||
|
folder_uuid: Option<FolderId>,
|
||||||
|
user_uuid: &UserId,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
||||||
// No changes
|
// No changes
|
||||||
(None, None) => Ok(()),
|
(None, None) => Ok(()),
|
||||||
@@ -853,7 +872,7 @@ impl Cipher {
|
|||||||
|
|
||||||
if !visible_only {
|
if !visible_only {
|
||||||
query = query.or_filter(
|
query = query.or_filter(
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +907,7 @@ impl Cipher {
|
|||||||
|
|
||||||
if !visible_only {
|
if !visible_only {
|
||||||
query = query.or_filter(
|
query = query.or_filter(
|
||||||
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,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.le(MembershipType::Admin as i32)), // User is admin or owner
|
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
|
||||||
)
|
)
|
||||||
.select(ciphers_collections::collection_uuid)
|
.select(ciphers_collections::collection_uuid)
|
||||||
.load::<CollectionId>(conn)
|
.load::<CollectionId>(conn)
|
||||||
@@ -1121,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.le(MembershipType::Admin as i32)), // User is admin or owner
|
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
|
||||||
)
|
)
|
||||||
.select(ciphers_collections::collection_uuid)
|
.select(ciphers_collections::collection_uuid)
|
||||||
.load::<CollectionId>(conn)
|
.load::<CollectionId>(conn)
|
||||||
@@ -1163,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.le(MembershipType::Admin as i32)) // User is admin or owner
|
.or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // 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,
|
User, UserId, organization::ORG_ADMIN_ATYPES,
|
||||||
};
|
};
|
||||||
|
|
||||||
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
|
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
|
||||||
@@ -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.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // 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.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // 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
|
||||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
.eq_any(ORG_ADMIN_ATYPES) // 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
|
||||||
.le(MembershipType::Admin as i32) // Org admin or owner
|
.eq_any(ORG_ADMIN_ATYPES) // 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.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // 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.le(MembershipType::Admin as i32), // Org admin or owner
|
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.or(
|
.or(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -112,8 +112,26 @@ impl EmergencyAccess {
|
|||||||
"object": "emergencyAccessGranteeDetails",
|
"object": "emergencyAccessGranteeDetails",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deadline_at(&self) -> Result<NaiveDateTime, crate::Error> {
|
||||||
|
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) {
|
||||||
|
err!("Emergency access wait time is out-of-bounds!")
|
||||||
|
}
|
||||||
|
|
||||||
|
let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?;
|
||||||
|
|
||||||
|
TimeDelta::try_days(i64::from(self.wait_time_days))
|
||||||
|
.and_then(|w| initiated_at.checked_add_signed(w))
|
||||||
|
.map_res("Emergency access deadline is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result<bool, crate::Error> {
|
||||||
|
Ok(self.deadline_at()?.le(now))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive<i32> = 1..=90;
|
||||||
|
|
||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone)]
|
||||||
pub enum EmergencyAccessType {
|
pub enum EmergencyAccessType {
|
||||||
View = 0,
|
View = 0,
|
||||||
@@ -169,27 +187,42 @@ impl EmergencyAccess {
|
|||||||
|
|
||||||
pub async fn update_access_status_and_save(
|
pub async fn update_access_status_and_save(
|
||||||
&mut self,
|
&mut self,
|
||||||
status: i32,
|
from_status: i32,
|
||||||
|
to_status: i32,
|
||||||
date: &NaiveDateTime,
|
date: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
// Update the grantee so that it will refresh it's status.
|
let uuid = self.uuid.clone();
|
||||||
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
let updated_at = *date;
|
||||||
self.status = status;
|
let res = conn
|
||||||
date.clone_into(&mut self.updated_at);
|
.run(move |conn| {
|
||||||
|
|
||||||
conn.run(move |conn| {
|
|
||||||
crate::util::retry(
|
crate::util::retry(
|
||||||
|| {
|
|| {
|
||||||
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
diesel::update(
|
||||||
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
emergency_access::table
|
||||||
|
.filter(emergency_access::uuid.eq(&uuid))
|
||||||
|
.filter(emergency_access::status.eq(from_status)),
|
||||||
|
)
|
||||||
|
.set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
},
|
},
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
.map_res("Error updating emergency access status")
|
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(1) => {} // One record updated, which is valid
|
||||||
|
Ok(_) => err!("Emergency access status was changed already"),
|
||||||
|
Err(e) => err!("Error updating emergency access status", format!("{e}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.status = to_status;
|
||||||
|
self.updated_at = updated_at;
|
||||||
|
|
||||||
|
// Update the grantee so that it will refresh it's status.
|
||||||
|
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
||||||
|
|||||||
+14
-7
@@ -79,6 +79,7 @@ pub enum EventType {
|
|||||||
CipherSoftDeleted = 1115,
|
CipherSoftDeleted = 1115,
|
||||||
CipherRestored = 1116,
|
CipherRestored = 1116,
|
||||||
CipherClientToggledCardNumberVisible = 1117,
|
CipherClientToggledCardNumberVisible = 1117,
|
||||||
|
CipherClientHttpAuthReleased = 1133,
|
||||||
|
|
||||||
// Collection
|
// Collection
|
||||||
CollectionCreated = 1300,
|
CollectionCreated = 1300,
|
||||||
@@ -111,8 +112,8 @@ pub enum EventType {
|
|||||||
// OrganizationUserAutomaticallyConfirmed = 1517,
|
// OrganizationUserAutomaticallyConfirmed = 1517,
|
||||||
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
||||||
OrganizationUserAdminResetTwoFactor = 1519,
|
OrganizationUserAdminResetTwoFactor = 1519,
|
||||||
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
|
OrganizationUserRevokedTwoFactorNonCompliance = 1520,
|
||||||
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
|
OrganizationUserRevokedSingleOrganizationNonCompliance = 1521,
|
||||||
OrganizationUserNotificationBannerActionClicked = 1522,
|
OrganizationUserNotificationBannerActionClicked = 1522,
|
||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
@@ -347,17 +348,23 @@ impl Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
||||||
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
|
||||||
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
|
||||||
|
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
|
||||||
|
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
||||||
|
};
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error cleaning old events")
|
.map_res("Error cleaning old events")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,15 +137,15 @@ impl CollectionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json_details_for_group(&self) -> Value {
|
pub fn to_json_details_for_group(&self) -> Value {
|
||||||
// If both read_only and hide_passwords are false, then manage should be true
|
// Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group
|
||||||
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
// permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage"
|
||||||
// Or an entry with everything to false
|
// in GET /organizations/<org>/collections/details, while GET /groups/<id>/details reported the stored
|
||||||
// For backwards compatibility and migration proposes we keep checking read_only and hide_password
|
// value. Saving the collection access dialog then persisted manage=true.
|
||||||
json!({
|
json!({
|
||||||
"id": self.groups_uuid,
|
"id": self.groups_uuid,
|
||||||
"readOnly": self.read_only,
|
"readOnly": self.read_only,
|
||||||
"hidePasswords": self.hide_passwords,
|
"hidePasswords": self.hide_passwords,
|
||||||
"manage": self.manage || (!self.read_only && !self.hide_passwords),
|
"manage": self.manage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,15 +24,17 @@ pub use self::auth_request::{AuthRequest, AuthRequestId};
|
|||||||
pub use self::cipher::{Cipher, CipherId, RepromptType};
|
pub use self::cipher::{Cipher, CipherId, RepromptType};
|
||||||
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
||||||
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
||||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
pub use self::emergency_access::{
|
||||||
|
EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType,
|
||||||
|
};
|
||||||
pub use self::event::{Event, EventType};
|
pub use self::event::{Event, EventType};
|
||||||
pub use self::favorite::Favorite;
|
pub use self::favorite::Favorite;
|
||||||
pub use self::folder::{Folder, FolderCipher, FolderId};
|
pub use self::folder::{Folder, FolderCipher, FolderId};
|
||||||
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
||||||
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
|
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
|
||||||
pub use self::organization::{
|
pub use self::organization::{
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
|
||||||
OrganizationId,
|
OrganizationApiKey, OrganizationId, custom_roles_enabled,
|
||||||
};
|
};
|
||||||
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};
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ pub enum OrgPolicyType {
|
|||||||
// AutoConfirm = 18, // Not supported (not implemented yet)
|
// AutoConfirm = 18, // Not supported (not implemented yet)
|
||||||
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
||||||
OrganizationUserNotification = 20,
|
OrganizationUserNotification = 20,
|
||||||
|
// SendControls = 21, // Not supported yet
|
||||||
|
FillAssist = 22,
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
||||||
@@ -71,6 +73,14 @@ pub struct ResetPasswordDataModel {
|
|||||||
pub auto_enroll_enabled: bool,
|
pub auto_enroll_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FillAssistPolicyData {
|
||||||
|
#[serde(alias = "RulesUrl")]
|
||||||
|
pub rules_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl OrgPolicy {
|
impl OrgPolicy {
|
||||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
||||||
|
|||||||
+321
-25
@@ -1,4 +1,7 @@
|
|||||||
use std::cmp::Ordering;
|
use std::{
|
||||||
|
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};
|
||||||
@@ -21,8 +24,8 @@ use crate::{
|
|||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
|
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
|
||||||
TwoFactor, User, UserId,
|
OrgPolicyType, TwoFactor, User, UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -41,6 +44,7 @@ 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,
|
||||||
@@ -54,6 +58,17 @@ 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)]
|
||||||
@@ -89,42 +104,68 @@ impl MembershipStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
|
#[derive(Copy, Clone, Debug, 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),
|
||||||
// HACK: We convert the custom role to a manager role
|
"4" | "Custom" => Some(MembershipType::Custom),
|
||||||
"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 {
|
||||||
// For easy comparison, map each variant to an access level (where 0 is lowest).
|
self.rank().cmp(&other.rank())
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +221,91 @@ 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 {
|
||||||
@@ -271,9 +397,92 @@ 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;
|
||||||
@@ -523,7 +732,8 @@ impl Membership {
|
|||||||
|
|
||||||
// These are per user
|
// These are per user
|
||||||
"userId": self.user_uuid,
|
"userId": self.user_uuid,
|
||||||
"key": self.akey,
|
// Accepted (not yet confirmed) members have no key yet; send null like upstream, not ""
|
||||||
|
"key": (!self.akey.is_empty()).then_some(&self.akey),
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"type": membership_type,
|
"type": membership_type,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -711,6 +921,12 @@ 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:
|
||||||
@@ -1227,13 +1443,93 @@ 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]
|
||||||
#[allow(non_snake_case)]
|
fn membership_type_authority() {
|
||||||
fn partial_cmp_MembershipType() {
|
assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User);
|
||||||
assert!(MembershipType::Owner > MembershipType::Admin);
|
assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin);
|
||||||
assert!(MembershipType::Admin > MembershipType::Manager);
|
for raw in [99, -1] {
|
||||||
assert!(MembershipType::Manager > MembershipType::User);
|
assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true));
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +334,6 @@ impl Send {
|
|||||||
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
|
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct FileData {
|
struct FileData {
|
||||||
#[serde(rename = "size", alias = "Size")]
|
|
||||||
size: NumberOrString,
|
size: NumberOrString,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ impl TwoFactorIncomplete {
|
|||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ impl TwoFactorIncomplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
||||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||||
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||||
if member.atype == MembershipType::Owner
|
if member.atype == MembershipType::Owner
|
||||||
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
||||||
@@ -385,7 +385,7 @@ impl User {
|
|||||||
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
||||||
|
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user")
|
diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -425,12 +425,12 @@ macro_rules! err_json {
|
|||||||
macro_rules! err_handler {
|
macro_rules! err_handler {
|
||||||
($expr:expr) => {{
|
($expr:expr) => {{
|
||||||
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
||||||
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
|
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, $expr));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr) => {{
|
($usr_msg:expr, $log_value:expr) => {{
|
||||||
let usr_msg = $usr_msg;
|
let usr_msg = $usr_msg;
|
||||||
let log_value = $log_value;
|
let log_value = $log_value;
|
||||||
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
||||||
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg));
|
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, usr_msg));
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
+9
-9
@@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
|
|||||||
|
|
||||||
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
|
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
|
||||||
.port(CONFIG.smtp_port())
|
.port(CONFIG.smtp_port())
|
||||||
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout())));
|
.timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout()))));
|
||||||
|
|
||||||
// Determine security
|
// Determine security
|
||||||
let smtp_client = if CONFIG.smtp_security() == *"off" {
|
let smtp_client = if CONFIG.smtp_security() == *"off" {
|
||||||
@@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option<String>) -> EmptyRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_delete_claims(user_id.to_string());
|
let claims = generate_delete_claims(user_id.to_string())?;
|
||||||
let delete_token = encode_jwt(&claims);
|
let delete_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_verify_email_claims(user_id);
|
let claims = generate_verify_email_claims(user_id)?;
|
||||||
let verify_email_token = encode_jwt(&claims);
|
let verify_email_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -206,7 +206,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
|||||||
|
|
||||||
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
query.query_pairs_mut().append_pair("email", email).append_pair("token", token);
|
query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true");
|
||||||
let Some(query_string) = query.query() else {
|
let Some(query_string) = query.query() else {
|
||||||
err!("Failed to build verify URL query parameters")
|
err!("Failed to build verify URL query parameters")
|
||||||
};
|
};
|
||||||
@@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_verify_email_claims(user_id);
|
let claims = generate_verify_email_claims(user_id)?;
|
||||||
let verify_email_token = encode_jwt(&claims);
|
let verify_email_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -292,7 +292,7 @@ pub async fn send_invite(
|
|||||||
org_id.clone(),
|
org_id.clone(),
|
||||||
member_id.clone(),
|
member_id.clone(),
|
||||||
invited_by_email,
|
invited_by_email,
|
||||||
);
|
)?;
|
||||||
let invite_token = encode_jwt(&claims);
|
let invite_token = encode_jwt(&claims);
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
{
|
{
|
||||||
@@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite(
|
|||||||
emer_id.clone(),
|
emer_id.clone(),
|
||||||
String::from(grantor_name),
|
String::from(grantor_name),
|
||||||
String::from(grantor_email),
|
String::from(grantor_email),
|
||||||
);
|
)?;
|
||||||
|
|
||||||
// Build the query here to ensure proper escaping
|
// Build the query here to ensure proper escaping
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
@@ -738,8 +738,8 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text:
|
|||||||
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
||||||
|
|
||||||
let body = if CONFIG.smtp_embed_images() {
|
let body = if CONFIG.smtp_embed_images() {
|
||||||
let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec());
|
let logo_gray_body = Body::new(crate::api::STATIC_FILES["logo-gray.png"].bytes.to_vec());
|
||||||
let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec());
|
let mail_github_body = Body::new(crate::api::STATIC_FILES["mail-github.png"].bytes.to_vec());
|
||||||
MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart(
|
MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart(
|
||||||
MultiPart::related()
|
MultiPart::related()
|
||||||
.singlepart(SinglePart::html(body_html))
|
.singlepart(SinglePart::html(body_html))
|
||||||
|
|||||||
+5
-3
@@ -595,7 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
|||||||
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
|
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
|
||||||
.attach(util::AppHeaders())
|
.attach(util::AppHeaders())
|
||||||
.attach(util::Cors())
|
.attach(util::Cors())
|
||||||
.attach(util::BetterLogging(extra_debug))
|
.attach(util::BetterLogging::new(extra_debug, basepath))
|
||||||
.ignite()
|
.ignite()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) {
|
|||||||
// Cleanup the event table of records x days old.
|
// Cleanup the event table of records x days old.
|
||||||
if CONFIG.org_events_enabled()
|
if CONFIG.org_events_enabled()
|
||||||
&& !CONFIG.event_cleanup_schedule().is_empty()
|
&& !CONFIG.event_cleanup_schedule().is_empty()
|
||||||
&& CONFIG.events_days_retain().is_some()
|
&& CONFIG.events_days_retain().is_some_and(|d| d != 0)
|
||||||
{
|
{
|
||||||
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
|
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
|
||||||
runtime.spawn(api::event_cleanup_job(pool.clone()));
|
runtime.spawn(api::event_cleanup_job(pool.clone()));
|
||||||
@@ -754,7 +754,9 @@ fn schedule_jobs(pool: db::DbPool) {
|
|||||||
// tick, the one that was added earlier will run first.
|
// tick, the one that was added earlier will run first.
|
||||||
loop {
|
loop {
|
||||||
sched.tick();
|
sched.tick();
|
||||||
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms())));
|
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(
|
||||||
|
CONFIG.job_poll_interval_ms(),
|
||||||
|
))));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("Error spawning job scheduler thread");
|
.expect("Error spawning job scheduler thread");
|
||||||
|
|||||||
+6
-6
@@ -7,20 +7,20 @@ use crate::{CONFIG, Error};
|
|||||||
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
||||||
|
|
||||||
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
|
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
|
let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds()));
|
||||||
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
|
let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
||||||
});
|
});
|
||||||
|
|
||||||
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
|
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
|
let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds()));
|
||||||
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
|
let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
||||||
});
|
});
|
||||||
|
|
||||||
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
|
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
|
let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds()));
|
||||||
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
|
let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst()))
|
||||||
.expect("Non-zero unauthenticated ratelimit burst");
|
.expect("Non-zero unauthenticated ratelimit burst");
|
||||||
RateLimiter::keyed(
|
RateLimiter::keyed(
|
||||||
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
|
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
|
||||||
|
|||||||
+6
-2
@@ -266,8 +266,8 @@ pub async fn exchange_code(
|
|||||||
(_, Some(re)) => {
|
(_, Some(re)) => {
|
||||||
let error_msg = format!(
|
let error_msg = format!(
|
||||||
"SSO authorization failed: {}, {}",
|
"SSO authorization failed: {}, {}",
|
||||||
re.error,
|
re.error.escape_debug(),
|
||||||
re.error_description.as_ref().unwrap_or(&String::new())
|
re.error_description.as_deref().unwrap_or_default().escape_debug()
|
||||||
);
|
);
|
||||||
sso_auth.delete(conn).await?;
|
sso_auth.delete(conn).await?;
|
||||||
err!(error_msg);
|
err!(error_msg);
|
||||||
@@ -309,7 +309,11 @@ pub async fn exchange_code(
|
|||||||
user_name: user_name.clone(),
|
user_name: user_name.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if CONFIG.sso_debug_tokens() {
|
||||||
debug!("Authenticated user {authenticated_user:?}");
|
debug!("Authenticated user {authenticated_user:?}");
|
||||||
|
} else {
|
||||||
|
debug!("Authenticated user {identifier}");
|
||||||
|
}
|
||||||
sso_auth.auth_response = Some(authenticated_user.clone());
|
sso_auth.auth_response = Some(authenticated_user.clone());
|
||||||
sso_auth.updated_at = Utc::now().naive_utc();
|
sso_auth.updated_at = Utc::now().naive_utc();
|
||||||
sso_auth.save(conn).await?;
|
sso_auth.save(conn).await?;
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_own
|
|||||||
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
|
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
|
||||||
moka::sync::Cache::builder()
|
moka::sync::Cache::builder()
|
||||||
.max_capacity(1)
|
.max_capacity(1)
|
||||||
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration()))
|
.time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration())))
|
||||||
.build()
|
.build()
|
||||||
});
|
});
|
||||||
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
|
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
|
||||||
@@ -260,7 +260,7 @@ impl Client {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.sso_debug_tokens() {
|
if CONFIG.sso_debug_tokens() {
|
||||||
debug!("Id token: {}", id_token.to_string());
|
debug!("Id token: {id_token}");
|
||||||
debug!("Access token: {}", token_response.access_token().secret());
|
debug!("Access token: {}", token_response.access_token().secret());
|
||||||
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
|
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
|
||||||
debug!("Expiration time: {:?}", token_response.expires_in());
|
debug!("Expiration time: {:?}", token_response.expires_in());
|
||||||
|
|||||||
+9
-2
@@ -93,7 +93,9 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
|
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
|
||||||
supportString += `* Database type: ${dj.db_type}\n`;
|
supportString += `* Database type: ${dj.db_type}\n`;
|
||||||
supportString += `* Database version: ${dj.db_version}\n`;
|
supportString += `* Database version: ${dj.db_version}\n`;
|
||||||
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
|
supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`;
|
||||||
|
supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n";
|
||||||
|
supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`;
|
||||||
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
|
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
|
||||||
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
|
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
|
||||||
if (dj.ip_header_exists) {
|
if (dj.ip_header_exists) {
|
||||||
@@ -116,7 +118,7 @@ async function generateSupportString(event, dj) {
|
|||||||
}
|
}
|
||||||
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
|
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
|
||||||
if (dj.invalid_feature_flags != "") {
|
if (dj.invalid_feature_flags != "") {
|
||||||
supportString += "* Invalid feature flags: true\n";
|
supportString += "* Invalid feature flags: true :x:\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
||||||
@@ -132,6 +134,11 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
|
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
|
||||||
supportString += "<details><summary>Show Config & Details</summary>\n";
|
supportString += "<details><summary>Show Config & Details</summary>\n";
|
||||||
|
|
||||||
|
// Show invalid environment variables if they exists
|
||||||
|
if (dj.invalid_env_vars != "") {
|
||||||
|
supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
// Add overrides if they exists
|
// Add overrides if they exists
|
||||||
if (dj.overrides != "") {
|
if (dj.overrides != "") {
|
||||||
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
||||||
|
|||||||
Vendored
+12
-4
@@ -173,10 +173,14 @@ const ORG_TYPES = {
|
|||||||
"name": "User",
|
"name": "User",
|
||||||
"bg": "blue"
|
"bg": "blue"
|
||||||
},
|
},
|
||||||
"4": {
|
"3": {
|
||||||
"name": "Manager",
|
"name": "Manager",
|
||||||
"bg": "green"
|
"bg": "green"
|
||||||
},
|
},
|
||||||
|
"4": {
|
||||||
|
"name": "Custom",
|
||||||
|
"bg": "teal"
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
|
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
|
||||||
@@ -187,15 +191,19 @@ 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;
|
||||||
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true;
|
// The Custom role can only be selected while it is enabled
|
||||||
|
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.
|
||||||
@@ -222,7 +230,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];
|
const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" };
|
||||||
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;
|
||||||
|
|||||||
@@ -69,12 +69,26 @@
|
|||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-5">Uses config.json</dt>
|
<dt class="col-sm-5">Uses config.json</dt>
|
||||||
<dd class="col-sm-7">
|
<dd class="col-sm-7">
|
||||||
{{#if page_data.overrides}}
|
{{#if (eq page_data.config_file_status "Loaded")}}
|
||||||
<span class="d-inline"><b>Yes</b></span>
|
<span class="d-inline"><b>Yes</b></span>
|
||||||
|
{{#if page_data.overrides}}
|
||||||
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.
{{page_data.overrides}}">Details</span>
|
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.
{{page_data.overrides}}">Details</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#unless page_data.overrides}}
|
{{~else if (eq page_data.config_file_status "Error")}}
|
||||||
<span class="d-block"><b>No</b></span>
|
<span class="d-inline"><b>Errors</b></span>
|
||||||
|
<span class="badge bg-danger abbr-badge" title="Config file could not be loaded and was ignored. Check server logs for details.">Details</span>
|
||||||
|
{{~else}}
|
||||||
|
<span class="d-inline"><b>No</b></span>
|
||||||
|
{{/if}}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-sm-5">Environment variables are valid</dt>
|
||||||
|
<dd class="col-sm-7">
|
||||||
|
{{#if page_data.invalid_env_vars}}
|
||||||
|
<span class="d-inline"><b>No</b></span>
|
||||||
|
<span class="badge bg-danger abbr-badge" title="Invalid environment variables are found and defaults are used instead.
{{page_data.invalid_env_vars}}">Details</span>
|
||||||
|
{{/if}}
|
||||||
|
{{#unless page_data.invalid_env_vars}}
|
||||||
|
<span class="d-block"><b>Yes</b></span>
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-5">Uses custom templates</dt>
|
<dt class="col-sm-5">Uses custom templates</dt>
|
||||||
|
|||||||
@@ -135,6 +135,11 @@
|
|||||||
<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,10 +98,17 @@ 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 {
|
||||||
|
|||||||
+117
-32
@@ -210,7 +210,7 @@ impl Fairing for Cors {
|
|||||||
pub struct Cached<R> {
|
pub struct Cached<R> {
|
||||||
response: R,
|
response: R,
|
||||||
is_immutable: bool,
|
is_immutable: bool,
|
||||||
ttl: u64,
|
ttl: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> Cached<R> {
|
impl<R> Cached<R> {
|
||||||
@@ -230,7 +230,7 @@ impl<R> Cached<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached<R> {
|
pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached<R> {
|
||||||
Self {
|
Self {
|
||||||
response,
|
response,
|
||||||
is_immutable,
|
is_immutable,
|
||||||
@@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
|
|||||||
res.set_raw_header("Cache-Control", cache_control_header);
|
res.set_raw_header("Cache-Control", cache_control_header);
|
||||||
|
|
||||||
let time_now = Local::now();
|
let time_now = Local::now();
|
||||||
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap();
|
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap();
|
||||||
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
|
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
@@ -259,14 +259,16 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
|
|||||||
|
|
||||||
pub struct EtagCached<R> {
|
pub struct EtagCached<R> {
|
||||||
response: R,
|
response: R,
|
||||||
|
content_type: ContentType,
|
||||||
etag: String,
|
etag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> EtagCached<R> {
|
impl<R> EtagCached<R> {
|
||||||
/// An `etag` response should always be quoted
|
/// An `etag` response should always be quoted
|
||||||
pub fn new(response: R, etag: &str) -> Self {
|
pub fn new(response: R, content_type: ContentType, etag: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
response,
|
response,
|
||||||
|
content_type,
|
||||||
etag: format!("\"{etag}\""),
|
etag: format!("\"{etag}\""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,7 +290,8 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
|||||||
self.response.respond_to(request)?
|
self.response.respond_to(request)?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control
|
// Both 200 (OK) and 304 (Not Modified) need to return the Content-Type Etag and Cache-Control
|
||||||
|
res.set_header(self.content_type);
|
||||||
res.set_raw_header("Etag", self.etag);
|
res.set_raw_header("Etag", self.etag);
|
||||||
res.set_raw_header("Cache-Control", "public, no-cache");
|
res.set_raw_header("Cache-Control", "public, no-cache");
|
||||||
Ok(res)
|
Ok(res)
|
||||||
@@ -299,19 +302,69 @@ 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"];
|
||||||
|
|
||||||
// Boolean is extra debug, when true, we ignore the whitelist above and also print the mounts
|
const MAX_LOGGED_QUERY_BYTES: usize = 100;
|
||||||
pub struct BetterLogging(pub bool);
|
|
||||||
|
// 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
|
||||||
|
pub struct BetterLogging {
|
||||||
|
extra: bool,
|
||||||
|
log_requests: bool,
|
||||||
|
log_response: bool,
|
||||||
|
domain_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BetterLogging {
|
||||||
|
pub fn new(extra: bool, domain_path: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
extra,
|
||||||
|
log_requests: log_enabled!(target: "request", log::Level::Info),
|
||||||
|
log_response: log_enabled!(target: "response", log::Level::Info),
|
||||||
|
domain_path: rocket::http::RawStr::new(&domain_path).percent_decode_lossy().into_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_path<'p>(&self, method: Method, path: &'p rocket::http::RawStr) -> Option<std::borrow::Cow<'p, str>> {
|
||||||
|
if !self.extra && method == Method::Options {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = path.percent_decode_lossy();
|
||||||
|
if self.extra {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
let subpath = path.strip_prefix(&self.domain_path).unwrap_or(&path);
|
||||||
|
LOGGED_ROUTES.iter().any(|r| subpath.starts_with(r)).then_some(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl Fairing for BetterLogging {
|
impl Fairing for BetterLogging {
|
||||||
fn info(&self) -> Info {
|
fn info(&self) -> Info {
|
||||||
|
let mut kind = Kind::Liftoff;
|
||||||
|
if self.log_requests {
|
||||||
|
kind = kind | Kind::Request;
|
||||||
|
}
|
||||||
|
if self.log_response {
|
||||||
|
kind = kind | Kind::Response;
|
||||||
|
}
|
||||||
Info {
|
Info {
|
||||||
name: "Better Logging",
|
name: "Better Logging",
|
||||||
kind: Kind::Liftoff | Kind::Request | Kind::Response,
|
kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_liftoff(&self, rocket: &Rocket<Orbit>) {
|
async fn on_liftoff(&self, rocket: &Rocket<Orbit>) {
|
||||||
if self.0 {
|
if self.extra {
|
||||||
info!(target: "routes", "Routes loaded:");
|
info!(target: "routes", "Routes loaded:");
|
||||||
let mut routes: Vec<_> = rocket.routes().collect();
|
let mut routes: Vec<_> = rocket.routes().collect();
|
||||||
routes.sort_by_key(|r| r.uri.path());
|
routes.sort_by_key(|r| r.uri.path());
|
||||||
@@ -335,36 +388,29 @@ impl Fairing for BetterLogging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) {
|
async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) {
|
||||||
let method = request.method();
|
|
||||||
if !self.0 && method == Method::Options {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let uri = request.uri();
|
let uri = request.uri();
|
||||||
let uri_path = uri.path();
|
let method = request.method();
|
||||||
let uri_path_str = uri_path.url_decode_lossy();
|
let Some(path) = self.log_path(method, uri.path().raw()) else {
|
||||||
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
return;
|
||||||
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
};
|
||||||
match uri.query() {
|
match uri.query() {
|
||||||
Some(q) => info!(target: "request", "{method} {uri_path_str}?{}", &q[..q.len().min(30)]),
|
Some(q) => {
|
||||||
None => info!(target: "request", "{method} {uri_path_str}"),
|
let query = redact_query_values(q.as_str());
|
||||||
|
let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)];
|
||||||
|
info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug());
|
||||||
}
|
}
|
||||||
|
None => info!(target: "request", "{method} {}", path.escape_debug()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
|
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
|
||||||
if !self.0 && request.method() == Method::Options {
|
if self.log_path(request.method(), request.uri().path().raw()).is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let uri_path = request.uri().path();
|
|
||||||
let uri_path_str = uri_path.url_decode_lossy();
|
|
||||||
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
|
||||||
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if let Some(route) = request.route() {
|
match request.route() {
|
||||||
info!(target: "response", "{route} => {status}");
|
Some(r) => info!(target: "response", "{r} => {status}"),
|
||||||
} else {
|
None => info!(target: "response", "{status}"),
|
||||||
info!(target: "response", "{status}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,20 +493,34 @@ pub fn get_env_str_value(key: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_env<V>(key: &str) -> Option<V>
|
pub static INVALID_ENV_VARS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
pub fn get_env<V>(key: &'static str) -> Option<V>
|
||||||
where
|
where
|
||||||
V: FromStr,
|
V: FromStr,
|
||||||
{
|
{
|
||||||
try_parse_string(get_env_str_value(key))
|
let value = get_env_str_value(key);
|
||||||
|
let value_is_some = value.is_some();
|
||||||
|
let parsed: Option<V> = try_parse_string(value);
|
||||||
|
if parsed.is_none() && value_is_some {
|
||||||
|
println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead.");
|
||||||
|
INVALID_ENV_VARS.lock().unwrap().push(key);
|
||||||
|
}
|
||||||
|
parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_env_bool(key: &str) -> Option<bool> {
|
pub fn get_env_bool(key: &'static str) -> Option<bool> {
|
||||||
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
|
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
|
||||||
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
|
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
|
||||||
|
|
||||||
match get_env_str_value(key) {
|
match get_env_str_value(key) {
|
||||||
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
|
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
|
||||||
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
|
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
|
||||||
|
Some(_) => {
|
||||||
|
println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead.");
|
||||||
|
INVALID_ENV_VARS.lock().unwrap().push(key);
|
||||||
|
None
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -982,3 +1042,28 @@ 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