mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-05-31 00:00:14 +03:00
Compare commits
21 Commits
235cf88231
...
1.35.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cf0c5d67e | |||
| b04ed75f9f | |||
| 0ed8ab68f7 | |||
| dfebee57ec | |||
| bfe420a018 | |||
| e7e4b9a86d | |||
| bb549986e6 | |||
| 39954af96a | |||
| a6b43651ca | |||
| 3f28b583db | |||
| d4f67429d6 | |||
| fc43737868 | |||
| 43df0fb7f4 | |||
| d29cd29f55 | |||
| 2811df2953 | |||
| 8f0e99b875 | |||
| f07a91141a | |||
| 787822854c | |||
| f62a7a66c8 | |||
| 3a1378f469 | |||
| dde63e209e |
@@ -1,3 +1,2 @@
|
||||
# Ignore vendored scripts in GitHub stats
|
||||
src/static/scripts/* linguist-vendored
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ jobs:
|
||||
docker-build:
|
||||
name: Build Vaultwarden containers
|
||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||
environment: release
|
||||
environment:
|
||||
name: release
|
||||
deployment: false
|
||||
permissions:
|
||||
packages: write # Needed to upload packages and artifacts
|
||||
contents: read
|
||||
@@ -104,7 +106,7 @@ jobs:
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -119,7 +121,7 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -135,7 +137,7 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -183,7 +185,7 @@ jobs:
|
||||
|
||||
- name: Bake ${{ matrix.base_image }} containers
|
||||
id: bake_vw
|
||||
uses: docker/bake-action@82490499d2e5613fcead7e128237ef0b0ea210f7 # v7.0.0
|
||||
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
|
||||
env:
|
||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||
@@ -220,7 +222,7 @@ jobs:
|
||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -235,12 +237,12 @@ jobs:
|
||||
|
||||
# Upload artifacts to Github Actions and Attest the binaries
|
||||
- name: Attest binaries
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
|
||||
- name: Upload binaries as artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
@@ -249,7 +251,9 @@ jobs:
|
||||
name: Merge manifests
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker-build
|
||||
environment: release
|
||||
environment:
|
||||
name: release
|
||||
deployment: false
|
||||
permissions:
|
||||
packages: write # Needed to upload packages and artifacts
|
||||
attestations: write # Needed to generate an artifact attestation for a build
|
||||
@@ -268,7 +272,7 @@ jobs:
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -283,7 +287,7 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -299,7 +303,7 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -361,7 +365,7 @@ jobs:
|
||||
# Attest container images
|
||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
@@ -369,7 +373,7 @@ jobs:
|
||||
|
||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ vars.GHCR_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
@@ -377,7 +381,7 @@ jobs:
|
||||
|
||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ vars.QUAY_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
||||
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
||||
@@ -50,6 +50,6 @@ jobs:
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
|
||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
|
||||
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
with:
|
||||
# intentionally not scanning the entire repository,
|
||||
# since it contains integration tests.
|
||||
|
||||
+55
-53
@@ -1,58 +1,60 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: mixed-line-ending
|
||||
args: ["--fix=no"]
|
||||
- id: end-of-file-fixer
|
||||
exclude: "(.*js$|.*css$)"
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: check-symlinks
|
||||
- id: forbid-submodules
|
||||
- repo: local
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: mixed-line-ending
|
||||
args: [ "--fix=no" ]
|
||||
- id: end-of-file-fixer
|
||||
exclude: "(.*js$|.*css$)"
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: check-symlinks
|
||||
- id: forbid-submodules
|
||||
|
||||
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
|
||||
hooks:
|
||||
- id: fmt
|
||||
name: fmt
|
||||
description: Format files with cargo fmt.
|
||||
entry: cargo fmt
|
||||
language: system
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
args: ["--", "--check"]
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
description: Test the package for errors.
|
||||
entry: cargo test
|
||||
language: system
|
||||
args: ["--features", "sqlite,mysql,postgresql", "--"]
|
||||
types_or: [rust, file]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
description: Lint Rust sources
|
||||
entry: cargo clippy
|
||||
language: system
|
||||
args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"]
|
||||
types_or: [rust, file]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
description: Check if the Docker templates are updated
|
||||
language: system
|
||||
entry: sh
|
||||
args:
|
||||
- "-c"
|
||||
- "cd docker && make"
|
||||
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: 631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
|
||||
hooks:
|
||||
- id: typos
|
||||
- id: typos
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: fmt
|
||||
name: fmt
|
||||
description: Format files with cargo fmt.
|
||||
entry: cargo fmt
|
||||
language: system
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
args: [ "--", "--check" ]
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
description: Test the package for errors.
|
||||
entry: cargo test
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||
types_or: [ rust, file ]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
description: Lint Rust sources
|
||||
entry: cargo clippy
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||
types_or: [ rust, file ]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
description: Check if the Docker templates are updated
|
||||
language: system
|
||||
entry: sh
|
||||
args:
|
||||
- "-c"
|
||||
- "cd docker && make"
|
||||
|
||||
Generated
+481
-344
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -1,6 +1,6 @@
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.92.0"
|
||||
rust-version = "1.93.0"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||
publish = false
|
||||
@@ -79,7 +79,7 @@ dashmap = "6.1.0"
|
||||
|
||||
# Async futures
|
||||
futures = "0.3.32"
|
||||
tokio = { version = "1.50.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||
tokio = { version = "1.52.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||
tokio-util = { version = "0.7.18", features = ["compat"]}
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
@@ -98,12 +98,12 @@ diesel-derive-newtype = "2.1.2"
|
||||
libsqlite3-sys = { version = "0.36.0", features = ["bundled"], optional = true }
|
||||
|
||||
# Crypto-related libraries
|
||||
rand = "0.10.0"
|
||||
rand = "0.10.1"
|
||||
ring = "0.17.14"
|
||||
subtle = "2.6.1"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.22.0", features = ["v4"] }
|
||||
uuid = { version = "1.23.1", features = ["v4"] }
|
||||
|
||||
# Date and time libraries
|
||||
chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false }
|
||||
@@ -136,7 +136,7 @@ webauthn-rs-core = "0.5.4"
|
||||
url = "2.5.8"
|
||||
|
||||
# Email libraries
|
||||
lettre = { version = "0.11.19", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
||||
lettre = { version = "0.11.21", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
||||
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
|
||||
email_address = "0.2.9"
|
||||
|
||||
@@ -145,7 +145,7 @@ handlebars = { version = "6.4.0", features = ["dir_source"] }
|
||||
|
||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||
reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
||||
hickory-resolver = "0.25.2"
|
||||
hickory-resolver = "0.26.0"
|
||||
|
||||
# Favicon extraction libraries
|
||||
html5gum = "0.8.3"
|
||||
@@ -162,7 +162,7 @@ cookie = "0.18.1"
|
||||
cookie_store = "0.22.1"
|
||||
|
||||
# Used by U2F, JWT and PostgreSQL
|
||||
openssl = "0.10.76"
|
||||
openssl = "0.10.78"
|
||||
|
||||
# CLI argument parsing
|
||||
pico-args = "0.5.0"
|
||||
@@ -176,11 +176,11 @@ openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] }
|
||||
moka = { version = "0.12.15", features = ["future"] }
|
||||
|
||||
# Check client versions for specific features.
|
||||
semver = "1.0.27"
|
||||
semver = "1.0.28"
|
||||
|
||||
# Allow overriding the default memory allocator
|
||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||
mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true }
|
||||
mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true }
|
||||
|
||||
which = "8.0.2"
|
||||
|
||||
@@ -198,9 +198,9 @@ opendal = { version = "0.55.0", features = ["services-fs"], default-features = f
|
||||
|
||||
# For retrieving AWS credentials, including temporary SSO credentials
|
||||
anyhow = { version = "1.0.102", optional = true }
|
||||
aws-config = { version = "1.8.15", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
||||
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
||||
aws-credential-types = { version = "1.2.14", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.11.6", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
|
||||
http = { version = "1.4.0", optional = true }
|
||||
reqsign = { version = "0.16.5", optional = true }
|
||||
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
# see diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/db/schema.rs"
|
||||
file = "src/db/schema.rs"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
vault_version: "v2026.2.0"
|
||||
vault_image_digest: "sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447"
|
||||
vault_version: "v2026.3.1"
|
||||
vault_image_digest: "sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767"
|
||||
# Cross Compile Docker Helper Scripts v1.9.0
|
||||
# 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
|
||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||
rust_version: 1.94.0 # Rust version to be used
|
||||
rust_version: 1.95.0 # Rust version to be used
|
||||
debian_version: trixie # Debian release name to be used
|
||||
alpine_version: "3.23" # Alpine version to be used
|
||||
# For which platforms/architectures will we try to build images
|
||||
|
||||
+10
-10
@@ -19,23 +19,23 @@
|
||||
# - 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.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447
|
||||
# [docker.io/vaultwarden/web-vault:v2026.2.0]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767
|
||||
# [docker.io/vaultwarden/web-vault:v2026.3.1]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault
|
||||
|
||||
########################## ALPINE BUILD IMAGES ##########################
|
||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.94.0 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.94.0 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.94.0 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.94.0 AS build_armv6
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
# - 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.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.3.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.3.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447
|
||||
# [docker.io/vaultwarden/web-vault:v2026.2.0]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767
|
||||
# [docker.io/vaultwarden/web-vault:v2026.3.1]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:c1b1f212333f95bff4ef8d00e8e3589c4ae8eda018691f28f8bddc7e971dd767 AS vault
|
||||
|
||||
########################## Cross Compile Docker Helper Scripts ##########################
|
||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||
@@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.94.0-slim-trixie AS build
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
|
||||
COPY --from=xx / /
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.94.0"
|
||||
channel = "1.95.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
|
||||
+15
-5
@@ -30,6 +30,7 @@ use crate::{
|
||||
error::{Error, MapResult},
|
||||
http_client::make_http_request,
|
||||
mail,
|
||||
sso::FAKE_SSO_IDENTIFIER,
|
||||
util::{
|
||||
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
|
||||
is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
|
||||
@@ -315,7 +316,11 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
|
||||
|
||||
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
if CONFIG.mail_enabled() {
|
||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||
FAKE_SSO_IDENTIFIER.into()
|
||||
} else {
|
||||
FAKE_ADMIN_UUID.into()
|
||||
};
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
@@ -472,7 +477,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
||||
}
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
|
||||
user.save(&conn).await
|
||||
}
|
||||
@@ -480,14 +485,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
||||
#[post("/users/<user_id>/disable", format = "application/json")]
|
||||
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
user.enabled = false;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
|
||||
save_result
|
||||
}
|
||||
|
||||
@@ -517,7 +523,11 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||
FAKE_SSO_IDENTIFIER.into()
|
||||
} else {
|
||||
FAKE_ADMIN_UUID.into()
|
||||
};
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
|
||||
+30
-24
@@ -22,7 +22,7 @@ use crate::{
|
||||
DbConn,
|
||||
},
|
||||
mail,
|
||||
util::{format_date, NumberOrString},
|
||||
util::{deser_opt_nonempty_str, format_date, NumberOrString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -106,7 +106,6 @@ pub struct RegisterData {
|
||||
|
||||
name: Option<String>,
|
||||
|
||||
#[allow(dead_code)]
|
||||
organization_user_id: Option<MembershipId>,
|
||||
|
||||
// Used only from the register/finish endpoint
|
||||
@@ -296,7 +295,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
||||
|
||||
set_kdf_data(&mut user, &data.kdf)?;
|
||||
|
||||
user.set_password(&data.master_password_hash, Some(data.key), true, None);
|
||||
user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
user.password_hint = password_hint;
|
||||
|
||||
// Add extra fields if present
|
||||
@@ -364,7 +363,9 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
Some(data.key),
|
||||
false,
|
||||
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
user.password_hint = password_hint;
|
||||
|
||||
if let Some(keys) = data.keys {
|
||||
@@ -373,15 +374,13 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
}
|
||||
|
||||
if let Some(identifier) = data.org_identifier {
|
||||
if identifier != crate::sso::FAKE_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
|
||||
let org = match Organization::find_by_uuid(&identifier.into(), &conn).await {
|
||||
None => err!("Failed to retrieve the associated organization"),
|
||||
Some(org) => org,
|
||||
if identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
|
||||
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
||||
err!("Failed to retrieve the associated organization")
|
||||
};
|
||||
|
||||
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await {
|
||||
None => err!("Failed to retrieve the invitation"),
|
||||
Some(org) => org,
|
||||
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
||||
err!("Failed to retrieve the invitation")
|
||||
};
|
||||
|
||||
accept_org_invite(&user, membership, None, &conn).await?;
|
||||
@@ -532,14 +531,16 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
||||
String::from("get_public_keys"),
|
||||
String::from("get_api_webauthn"),
|
||||
]),
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
// Prevent logging out the client where the user requested this endpoint from.
|
||||
// If you do logout the user it will causes issues at the client side.
|
||||
// Adding the device uuid will prevent this.
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -579,7 +580,6 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthenticationData {
|
||||
@@ -588,7 +588,6 @@ struct AuthenticationData {
|
||||
master_password_authentication_hash: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UnlockData {
|
||||
@@ -597,11 +596,12 @@ struct UnlockData {
|
||||
master_key_wrapped_user_key: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChangeKdfData {
|
||||
#[allow(dead_code)]
|
||||
new_master_password_hash: String,
|
||||
#[allow(dead_code)]
|
||||
key: String,
|
||||
authentication_data: AuthenticationData,
|
||||
unlock_data: UnlockData,
|
||||
@@ -633,10 +633,12 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
|
||||
Some(data.unlock_data.master_key_wrapped_user_key),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -647,6 +649,7 @@ struct UpdateFolderData {
|
||||
// There is a bug in 2024.3.x which adds a `null` item.
|
||||
// To bypass this we allow a Option here, but skip it during the updates
|
||||
// See: https://github.com/bitwarden/clients/issues/8453
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
id: Option<FolderId>,
|
||||
name: String,
|
||||
}
|
||||
@@ -900,14 +903,16 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
// Prevent logging out the client where the user requested this endpoint from.
|
||||
// If you do logout the user it will causes issues at the client side.
|
||||
// Adding the device uuid will prevent this.
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -919,12 +924,13 @@ async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
||||
|
||||
data.validate(&user, true, &conn).await?;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
|
||||
save_result
|
||||
}
|
||||
|
||||
@@ -1042,7 +1048,7 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
||||
user.email_new = None;
|
||||
user.email_new_token = None;
|
||||
|
||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
|
||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
@@ -1254,7 +1260,7 @@ struct SecretVerificationRequest {
|
||||
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> {
|
||||
if user.password_iterations < CONFIG.password_iterations() {
|
||||
user.password_iterations = CONFIG.password_iterations();
|
||||
user.set_password(pwd_hash, None, false, None);
|
||||
user.set_password(pwd_hash, None, false, None, conn).await?;
|
||||
|
||||
if let Err(e) = user.save(conn).await {
|
||||
error!("Error updating user: {e:#?}");
|
||||
|
||||
+68
-53
@@ -11,10 +11,10 @@ use rocket::{
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::ClientVersion;
|
||||
use crate::util::{save_temp_file, NumberOrString};
|
||||
use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString};
|
||||
use crate::{
|
||||
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
||||
auth::Headers,
|
||||
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||
config::PathType,
|
||||
crypto,
|
||||
db::{
|
||||
@@ -86,7 +86,8 @@ pub fn routes() -> Vec<Route> {
|
||||
restore_cipher_put_admin,
|
||||
restore_cipher_selected,
|
||||
restore_cipher_selected_admin,
|
||||
delete_all,
|
||||
purge_org_vault,
|
||||
purge_personal_vault,
|
||||
move_cipher_selected,
|
||||
move_cipher_selected_put,
|
||||
put_collections2_update,
|
||||
@@ -247,6 +248,7 @@ pub struct CipherData {
|
||||
// Id is optional as it is included only in bulk share
|
||||
pub id: Option<CipherId>,
|
||||
// Folder id is not included in import
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
pub folder_id: Option<FolderId>,
|
||||
// TODO: Some of these might appear all the time, no need for Option
|
||||
#[serde(alias = "organizationID")]
|
||||
@@ -296,6 +298,7 @@ pub struct CipherData {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PartialCipherData {
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
folder_id: Option<FolderId>,
|
||||
favorite: bool,
|
||||
}
|
||||
@@ -425,7 +428,7 @@ pub async fn update_cipher_from_data(
|
||||
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
|
||||
|
||||
if let Some(org_id) = data.organization_id {
|
||||
match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
||||
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
||||
None => err!("You don't have permission to add item to organization"),
|
||||
Some(member) => {
|
||||
if shared_to_collections.is_some()
|
||||
@@ -1568,6 +1571,7 @@ async fn restore_cipher_selected(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MoveCipherData {
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
folder_id: Option<FolderId>,
|
||||
ids: Vec<CipherId>,
|
||||
}
|
||||
@@ -1642,9 +1646,51 @@ struct OrganizationIdData {
|
||||
org_id: OrganizationId,
|
||||
}
|
||||
|
||||
// Use the OrgIdGuard here, to ensure there an organization id present.
|
||||
// If there is no organization id present, it should be forwarded to purge_personal_vault.
|
||||
// This guard needs to be the first argument, else OwnerHeaders will be triggered which will logout the user.
|
||||
#[post("/ciphers/purge?<organization..>", data = "<data>")]
|
||||
async fn delete_all(
|
||||
organization: Option<OrganizationIdData>,
|
||||
async fn purge_org_vault(
|
||||
_org_id_guard: OrgIdGuard,
|
||||
organization: OrganizationIdData,
|
||||
data: Json<PasswordOrOtpData>,
|
||||
headers: OwnerHeaders,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
if organization.org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
data.validate(&user, true, &conn).await?;
|
||||
|
||||
match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await {
|
||||
Some(member) if member.atype == MembershipType::Owner => {
|
||||
Cipher::delete_all_by_organization(&organization.org_id, &conn).await?;
|
||||
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationPurgedVault as i32,
|
||||
&organization.org_id,
|
||||
&organization.org_id,
|
||||
&user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
_ => err!("You don't have permission to purge the organization vault"),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/ciphers/purge", data = "<data>")]
|
||||
async fn purge_personal_vault(
|
||||
data: Json<PasswordOrOtpData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
@@ -1655,52 +1701,18 @@ async fn delete_all(
|
||||
|
||||
data.validate(&user, true, &conn).await?;
|
||||
|
||||
match organization {
|
||||
Some(org_data) => {
|
||||
// Organization ID in query params, purging organization vault
|
||||
match Membership::find_by_user_and_org(&user.uuid, &org_data.org_id, &conn).await {
|
||||
None => err!("You don't have permission to purge the organization vault"),
|
||||
Some(member) => {
|
||||
if member.atype == MembershipType::Owner {
|
||||
Cipher::delete_all_by_organization(&org_data.org_id, &conn).await?;
|
||||
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationPurgedVault as i32,
|
||||
&org_data.org_id,
|
||||
&org_data.org_id,
|
||||
&user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
err!("You don't have permission to purge the organization vault");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// No organization ID in query params, purging user vault
|
||||
// Delete ciphers and their attachments
|
||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
|
||||
cipher.delete(&conn).await?;
|
||||
}
|
||||
|
||||
// Delete folders
|
||||
for f in Folder::find_by_user(&user.uuid, &conn).await {
|
||||
f.delete(&conn).await?;
|
||||
}
|
||||
|
||||
user.update_revision(&conn).await?;
|
||||
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
|
||||
cipher.delete(&conn).await?;
|
||||
}
|
||||
|
||||
for f in Folder::find_by_user(&user.uuid, &conn).await {
|
||||
f.delete(&conn).await?;
|
||||
}
|
||||
|
||||
user.update_revision(&conn).await?;
|
||||
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
@@ -1980,8 +1992,11 @@ impl CipherSyncData {
|
||||
}
|
||||
|
||||
// Generate a HashMap with the Organization UUID as key and the Membership record
|
||||
let members: HashMap<OrganizationId, Membership> =
|
||||
Membership::find_by_user(user_id, conn).await.into_iter().map(|m| (m.org_uuid.clone(), m)).collect();
|
||||
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|m| (m.org_uuid.clone(), m))
|
||||
.collect();
|
||||
|
||||
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
||||
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
||||
|
||||
@@ -653,7 +653,7 @@ async fn password_emergency_access(
|
||||
};
|
||||
|
||||
// change grantor_user password
|
||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None);
|
||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
grantor_user.save(&conn).await?;
|
||||
|
||||
// Disable TwoFactor providers since they will otherwise block logins
|
||||
|
||||
@@ -240,7 +240,7 @@ async fn _log_user_event(
|
||||
ip: &IpAddr,
|
||||
conn: &DbConn,
|
||||
) {
|
||||
let memberships = Membership::find_by_user(user_id, conn).await;
|
||||
let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
|
||||
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
||||
|
||||
// Upstream saves the event also without any org_id.
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
models::{Folder, FolderId},
|
||||
DbConn,
|
||||
},
|
||||
util::deser_opt_nonempty_str,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
@@ -38,6 +39,7 @@ async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> Json
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FolderData {
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
pub id: Option<FolderId>,
|
||||
}
|
||||
|
||||
|
||||
+165
-72
@@ -20,7 +20,8 @@ use crate::{
|
||||
DbConn,
|
||||
},
|
||||
mail,
|
||||
util::{convert_json_key_lcase_first, get_uuid, NumberOrString},
|
||||
sso::FAKE_SSO_IDENTIFIER,
|
||||
util::{convert_json_key_lcase_first, NumberOrString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -64,6 +65,7 @@ pub fn routes() -> Vec<Route> {
|
||||
post_org_import,
|
||||
list_policies,
|
||||
list_policies_token,
|
||||
get_dummy_master_password_policy,
|
||||
get_master_password_policy,
|
||||
get_policy,
|
||||
put_policy,
|
||||
@@ -99,6 +101,7 @@ pub fn routes() -> Vec<Route> {
|
||||
get_billing_metadata,
|
||||
get_billing_warnings,
|
||||
get_auto_enroll_status,
|
||||
get_self_host_billing_metadata,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -131,6 +134,24 @@ struct FullCollectionData {
|
||||
external_id: Option<String>,
|
||||
}
|
||||
|
||||
impl FullCollectionData {
|
||||
pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
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();
|
||||
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))
|
||||
}
|
||||
|
||||
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();
|
||||
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))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CollectionGroupData {
|
||||
@@ -233,30 +254,30 @@ async fn post_delete_organization(
|
||||
}
|
||||
|
||||
#[post("/organizations/<org_id>/leave")]
|
||||
async fn leave_organization(org_id: OrganizationId, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await {
|
||||
None => err!("User not part of organization"),
|
||||
Some(member) => {
|
||||
if member.atype == MembershipType::Owner
|
||||
&& Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
|
||||
{
|
||||
err!("The last owner can't leave")
|
||||
}
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserLeft as i32,
|
||||
&member.uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
member.delete(&conn).await
|
||||
}
|
||||
async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult {
|
||||
if headers.membership.status != MembershipStatus::Confirmed as i32 {
|
||||
err!("You need to be a Member of the Organization to call this endpoint")
|
||||
}
|
||||
let membership = headers.membership;
|
||||
|
||||
if membership.atype == MembershipType::Owner
|
||||
&& Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
|
||||
{
|
||||
err!("The last owner can't leave")
|
||||
}
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserLeft as i32,
|
||||
&membership.uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
membership.delete(&conn).await
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>")]
|
||||
@@ -335,7 +356,7 @@ async fn get_user_collections(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
|
||||
#[get("/organizations/<identifier>/auto-enroll-status")]
|
||||
async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let org = if identifier == crate::sso::FAKE_IDENTIFIER {
|
||||
let org = if identifier == FAKE_SSO_IDENTIFIER {
|
||||
match Membership::find_main_user_org(&headers.user.uuid, &conn).await {
|
||||
Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await,
|
||||
None => None,
|
||||
@@ -345,7 +366,7 @@ async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn
|
||||
};
|
||||
|
||||
let (id, identifier, rp_auto_enroll) = match org {
|
||||
None => (get_uuid(), identifier.to_string(), false),
|
||||
None => (identifier.to_string(), identifier.to_string(), false),
|
||||
Some(org) => (
|
||||
org.uuid.to_string(),
|
||||
org.uuid.to_string(),
|
||||
@@ -480,12 +501,13 @@ async fn post_organization_collections(
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
let data: FullCollectionData = data.into_inner();
|
||||
data.validate(&org_id, &conn).await?;
|
||||
|
||||
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||
err!("Can't find organization details")
|
||||
};
|
||||
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all {
|
||||
err!("You don't have permission to create collections")
|
||||
}
|
||||
|
||||
let collection = Collection::new(org.uuid, data.name, data.external_id);
|
||||
let collection = Collection::new(org_id.clone(), data.name, data.external_id);
|
||||
collection.save(&conn).await?;
|
||||
|
||||
log_event(
|
||||
@@ -501,7 +523,7 @@ async fn post_organization_collections(
|
||||
|
||||
for group in data.groups {
|
||||
CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage)
|
||||
.save(&conn)
|
||||
.save(&org_id, &conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -525,10 +547,6 @@ async fn post_organization_collections(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all {
|
||||
CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, false, &conn).await?;
|
||||
}
|
||||
|
||||
Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await))
|
||||
}
|
||||
|
||||
@@ -579,10 +597,10 @@ async fn post_bulk_access_collections(
|
||||
)
|
||||
.await;
|
||||
|
||||
CollectionGroup::delete_all_by_collection(&col_id, &conn).await?;
|
||||
CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?;
|
||||
for group in &data.groups {
|
||||
CollectionGroup::new(col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage)
|
||||
.save(&conn)
|
||||
.save(&org_id, &conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -627,6 +645,7 @@ async fn post_organization_collection_update(
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
let data: FullCollectionData = data.into_inner();
|
||||
data.validate(&org_id, &conn).await?;
|
||||
|
||||
if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
|
||||
err!("Can't find organization details")
|
||||
@@ -655,11 +674,11 @@ async fn post_organization_collection_update(
|
||||
)
|
||||
.await;
|
||||
|
||||
CollectionGroup::delete_all_by_collection(&col_id, &conn).await?;
|
||||
CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?;
|
||||
|
||||
for group in data.groups {
|
||||
CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage)
|
||||
.save(&conn)
|
||||
.save(&org_id, &conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -908,7 +927,7 @@ async fn get_org_domain_sso_verified(data: Json<OrgDomainDetails>, conn: DbConn)
|
||||
.collect::<Vec<(String, String)>>()
|
||||
{
|
||||
v if !v.is_empty() => v,
|
||||
_ => vec![(crate::sso::FAKE_IDENTIFIER.to_string(), crate::sso::FAKE_IDENTIFIER.to_string())],
|
||||
_ => vec![(FAKE_SSO_IDENTIFIER.to_string(), FAKE_SSO_IDENTIFIER.to_string())],
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -1003,6 +1022,24 @@ struct InviteData {
|
||||
permissions: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl InviteData {
|
||||
async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
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();
|
||||
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))
|
||||
}
|
||||
|
||||
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();
|
||||
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))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/organizations/<org_id>/users/invite", data = "<data>")]
|
||||
async fn send_invite(
|
||||
org_id: OrganizationId,
|
||||
@@ -1014,6 +1051,7 @@ async fn send_invite(
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
let data: InviteData = data.into_inner();
|
||||
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
|
||||
// The from_str() will convert the custom role type into a manager role type
|
||||
@@ -1273,20 +1311,20 @@ async fn accept_invite(
|
||||
|
||||
// skip invitation logic when we were invited via the /admin panel
|
||||
if **member_id != FAKE_ADMIN_UUID {
|
||||
let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else {
|
||||
let Some(mut membership) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else {
|
||||
err!("Error accepting the invitation")
|
||||
};
|
||||
|
||||
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &conn).await {
|
||||
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&membership.org_uuid, &conn).await {
|
||||
true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."),
|
||||
true => data.reset_password_key,
|
||||
false => None,
|
||||
};
|
||||
|
||||
// In case the user was invited before the mail was saved in db.
|
||||
member.invited_by_email = member.invited_by_email.or(claims.invited_by_email);
|
||||
membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email);
|
||||
|
||||
accept_org_invite(&headers.user, member, reset_password_key, &conn).await?;
|
||||
accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?;
|
||||
} else if CONFIG.mail_enabled() {
|
||||
// User was invited from /admin, so they are automatically confirmed
|
||||
let org_name = CONFIG.invitation_org_name();
|
||||
@@ -1520,9 +1558,8 @@ async fn edit_member(
|
||||
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
||||
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
||||
|
||||
let mut member_to_edit = match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await {
|
||||
Some(member) => member,
|
||||
None => err!("The specified user isn't member of the organization"),
|
||||
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")
|
||||
};
|
||||
|
||||
if new_type != member_to_edit.atype
|
||||
@@ -1839,7 +1876,6 @@ async fn post_org_import(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
struct BulkCollectionsData {
|
||||
organization_id: OrganizationId,
|
||||
cipher_ids: Vec<CipherId>,
|
||||
@@ -1853,6 +1889,10 @@ struct BulkCollectionsData {
|
||||
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let data: BulkCollectionsData = data.into_inner();
|
||||
|
||||
if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() {
|
||||
err!("You need to be a Member of the Organization to call this endpoint")
|
||||
}
|
||||
|
||||
// Get all the collection available to the user in one query
|
||||
// Also filter based upon the provided collections
|
||||
let user_collections: HashMap<CollectionId, Collection> =
|
||||
@@ -1868,7 +1908,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Verify if all the collections requested exists and are writeable for the user, else abort
|
||||
// Verify if all the collections requested exists and are writable for the user, else abort
|
||||
for collection_uuid in &data.collection_ids {
|
||||
match user_collections.get(collection_uuid) {
|
||||
Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (),
|
||||
@@ -1938,10 +1978,20 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn)
|
||||
})))
|
||||
}
|
||||
|
||||
// Called during the SSO enrollment.
|
||||
// Return the org policy if it exists, otherwise use the default one.
|
||||
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
|
||||
async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, conn: DbConn) -> JsonResult {
|
||||
// Called during the SSO enrollment return the default policy
|
||||
#[get("/organizations/vaultwarden-dummy-oidc-identifier/policies/master-password", rank = 1)]
|
||||
fn get_dummy_master_password_policy() -> JsonResult {
|
||||
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
|
||||
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
|
||||
_ => (false, "null".to_string()),
|
||||
};
|
||||
let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data);
|
||||
Ok(Json(policy.to_json()))
|
||||
}
|
||||
|
||||
// Called during the SSO enrollment return the org policy if it exists
|
||||
#[get("/organizations/<org_id>/policies/master-password", rank = 2)]
|
||||
async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
|
||||
let policy =
|
||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| {
|
||||
let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
|
||||
@@ -1955,7 +2005,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, c
|
||||
Ok(Json(policy.to_json()))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 2)]
|
||||
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 3)]
|
||||
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
@@ -2149,13 +2199,13 @@ fn get_plans() -> Json<Value> {
|
||||
}
|
||||
|
||||
#[get("/organizations/<_org_id>/billing/metadata")]
|
||||
fn get_billing_metadata(_org_id: OrganizationId, _headers: Headers) -> Json<Value> {
|
||||
fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
|
||||
// Prevent a 404 error, which also causes Javascript errors.
|
||||
Json(_empty_data_json())
|
||||
}
|
||||
|
||||
#[get("/organizations/<_org_id>/billing/vnext/warnings")]
|
||||
fn get_billing_warnings(_org_id: OrganizationId, _headers: Headers) -> Json<Value> {
|
||||
fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
|
||||
Json(json!({
|
||||
"freeTrial":null,
|
||||
"inactiveSubscription":null,
|
||||
@@ -2164,6 +2214,15 @@ fn get_billing_warnings(_org_id: OrganizationId, _headers: Headers) -> Json<Valu
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/organizations/<_org_id>/billing/vnext/self-host/metadata")]
|
||||
fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
|
||||
// Prevent a 404 error, which also causes Javascript errors.
|
||||
Json(json!({
|
||||
"isOnSecretsManagerStandalone": false, // Secrets Manager is not supported by Vaultwarden
|
||||
"organizationOccupiedSeats": 0 // Vaultwarden does not count seats
|
||||
}))
|
||||
}
|
||||
|
||||
fn _empty_data_json() -> Value {
|
||||
json!({
|
||||
"object": "list",
|
||||
@@ -2427,6 +2486,23 @@ impl GroupRequest {
|
||||
|
||||
group
|
||||
}
|
||||
|
||||
/// Validate if all the collections and members belong to the provided organization
|
||||
pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
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();
|
||||
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))
|
||||
}
|
||||
|
||||
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();
|
||||
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))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
@@ -2470,6 +2546,8 @@ async fn post_groups(
|
||||
}
|
||||
|
||||
let group_request = data.into_inner();
|
||||
group_request.validate(&org_id, &conn).await?;
|
||||
|
||||
let group = group_request.to_group(&org_id);
|
||||
|
||||
log_event(
|
||||
@@ -2506,10 +2584,12 @@ async fn put_group(
|
||||
};
|
||||
|
||||
let group_request = data.into_inner();
|
||||
group_request.validate(&org_id, &conn).await?;
|
||||
|
||||
let updated_group = group_request.update_group(group);
|
||||
|
||||
CollectionGroup::delete_all_by_group(&group_id, &conn).await?;
|
||||
GroupUser::delete_all_by_group(&group_id, &conn).await?;
|
||||
CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?;
|
||||
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
|
||||
|
||||
log_event(
|
||||
EventType::GroupUpdated as i32,
|
||||
@@ -2537,7 +2617,7 @@ async fn add_update_group(
|
||||
|
||||
for col_selection in collections {
|
||||
let mut collection_group = col_selection.to_collection_group(group.uuid.clone());
|
||||
collection_group.save(conn).await?;
|
||||
collection_group.save(&org_id, conn).await?;
|
||||
}
|
||||
|
||||
for assigned_member in members {
|
||||
@@ -2630,7 +2710,7 @@ async fn _delete_group(
|
||||
)
|
||||
.await;
|
||||
|
||||
group.delete(conn).await
|
||||
group.delete(org_id, conn).await
|
||||
}
|
||||
|
||||
#[delete("/organizations/<org_id>/groups", data = "<data>")]
|
||||
@@ -2689,7 +2769,7 @@ async fn get_group_members(
|
||||
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
|
||||
};
|
||||
|
||||
let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &conn)
|
||||
let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &org_id, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|entry| entry.users_organizations_uuid.clone())
|
||||
@@ -2717,9 +2797,15 @@ async fn put_group_members(
|
||||
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
|
||||
};
|
||||
|
||||
GroupUser::delete_all_by_group(&group_id, &conn).await?;
|
||||
|
||||
let assigned_members = data.into_inner();
|
||||
|
||||
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();
|
||||
if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) {
|
||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
|
||||
}
|
||||
|
||||
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
|
||||
for assigned_member in assigned_members {
|
||||
let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone());
|
||||
user_entry.save(&conn).await?;
|
||||
@@ -2858,7 +2944,8 @@ async fn put_reset_password(
|
||||
let reset_request = data.into_inner();
|
||||
|
||||
let mut user = user;
|
||||
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None);
|
||||
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
|
||||
.await?;
|
||||
user.save(&conn).await?;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
@@ -2950,15 +3037,20 @@ async fn check_reset_password_applicable(org_id: &OrganizationId, conn: &DbConn)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[put("/organizations/<org_id>/users/<member_id>/reset-password-enrollment", data = "<data>")]
|
||||
#[put("/organizations/<org_id>/users/<user_id>/reset-password-enrollment", data = "<data>")]
|
||||
async fn put_reset_password_enrollment(
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
headers: Headers,
|
||||
user_id: UserId,
|
||||
headers: OrgMemberHeaders,
|
||||
data: Json<OrganizationUserResetPasswordEnrollmentRequest>,
|
||||
conn: DbConn,
|
||||
) -> EmptyResult {
|
||||
let Some(mut member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else {
|
||||
if user_id != headers.user.uuid {
|
||||
err!("User to enroll isn't member of required organization", "The user_id and acting user do not match");
|
||||
}
|
||||
|
||||
let Some(mut membership) = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, &conn).await
|
||||
else {
|
||||
err!("User to enroll isn't member of required organization")
|
||||
};
|
||||
|
||||
@@ -2985,16 +3077,17 @@ async fn put_reset_password_enrollment(
|
||||
.await?;
|
||||
}
|
||||
|
||||
member.reset_password_key = reset_password_key;
|
||||
member.save(&conn).await?;
|
||||
membership.reset_password_key = reset_password_key;
|
||||
membership.save(&conn).await?;
|
||||
|
||||
let log_id = if member.reset_password_key.is_some() {
|
||||
let event_type = if membership.reset_password_key.is_some() {
|
||||
EventType::OrganizationUserResetPasswordEnroll as i32
|
||||
} else {
|
||||
EventType::OrganizationUserResetPasswordWithdraw as i32
|
||||
};
|
||||
|
||||
log_event(log_id, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
}
|
||||
};
|
||||
|
||||
GroupUser::delete_all_by_group(&group_uuid, &conn).await?;
|
||||
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||
|
||||
for ext_id in &group_data.member_external_ids {
|
||||
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use data_encoding::BASE32;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
@@ -14,7 +16,7 @@ use crate::{
|
||||
db::{
|
||||
models::{
|
||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
||||
TwoFactorIncomplete, User, UserId,
|
||||
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||
},
|
||||
DbConn, DbPool,
|
||||
},
|
||||
@@ -31,6 +33,43 @@ pub mod protected_actions;
|
||||
pub mod webauthn;
|
||||
pub mod yubikey;
|
||||
|
||||
fn has_global_duo_credentials() -> bool {
|
||||
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
||||
}
|
||||
|
||||
pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool {
|
||||
#[derive(Deserialize)]
|
||||
struct DuoProviderData {
|
||||
host: String,
|
||||
ik: String,
|
||||
sk: String,
|
||||
}
|
||||
|
||||
match provider_type {
|
||||
TwoFactorType::Authenticator => true,
|
||||
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
||||
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
||||
provider_data
|
||||
.and_then(|raw| serde_json::from_str::<DuoProviderData>(raw).ok())
|
||||
.is_some_and(|duo| !duo.host.is_empty() && !duo.ik.is_empty() && !duo.sk.is_empty())
|
||||
|| has_global_duo_credentials()
|
||||
}
|
||||
TwoFactorType::YubiKey => {
|
||||
CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some()
|
||||
}
|
||||
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
||||
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
||||
TwoFactorType::RecoveryCode => true,
|
||||
TwoFactorType::U2f
|
||||
| TwoFactorType::U2fRegisterChallenge
|
||||
| TwoFactorType::U2fLoginChallenge
|
||||
| TwoFactorType::EmailVerificationChallenge
|
||||
| TwoFactorType::WebauthnRegisterChallenge
|
||||
| TwoFactorType::WebauthnLoginChallenge
|
||||
| TwoFactorType::ProtectedActions => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut routes = routes![
|
||||
get_twofactor,
|
||||
@@ -53,7 +92,13 @@ pub fn routes() -> Vec<Route> {
|
||||
#[get("/two-factor")]
|
||||
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
||||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
|
||||
let twofactors_json: Vec<Value> = twofactors
|
||||
.iter()
|
||||
.filter_map(|tf| {
|
||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(json!({
|
||||
"data": twofactors_json,
|
||||
|
||||
@@ -108,8 +108,8 @@ impl WebauthnRegistration {
|
||||
|
||||
#[post("/two-factor/get-webauthn", data = "<data>")]
|
||||
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
if !CONFIG.domain_set() {
|
||||
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
|
||||
if !CONFIG.is_webauthn_2fa_supported() {
|
||||
err!("Configured `DOMAIN` is not compatible with Webauthn")
|
||||
}
|
||||
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
|
||||
+37
-11
@@ -2,7 +2,6 @@ use chrono::Utc;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::{
|
||||
form::{Form, FromForm},
|
||||
http::Status,
|
||||
response::Redirect,
|
||||
serde::json::Json,
|
||||
Route,
|
||||
@@ -12,9 +11,12 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
api::{
|
||||
core::{
|
||||
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
|
||||
accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData},
|
||||
log_user_event,
|
||||
two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey},
|
||||
two_factor::{
|
||||
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
|
||||
yubikey,
|
||||
},
|
||||
},
|
||||
master_password_policy,
|
||||
push::register_push_device,
|
||||
@@ -128,12 +130,14 @@ async fn login(
|
||||
login_result
|
||||
}
|
||||
|
||||
// Return Status::Unauthorized to trigger logout
|
||||
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Extract token
|
||||
let refresh_token = match data.refresh_token {
|
||||
Some(token) => token,
|
||||
None => err_code!("Missing refresh_token", Status::Unauthorized.code),
|
||||
// When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
|
||||
// It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
|
||||
// See the link below for details
|
||||
// https://github.com/bitwarden/clients/blob/2ee158e720a5e7dbe3641caf80b569e97a1dd91b/libs/common/src/services/api.service.ts#L1786-L1797
|
||||
|
||||
let Some(refresh_token) = data.refresh_token else {
|
||||
err_json!(json!({"error": "invalid_grant"}), "Missing refresh_token")
|
||||
};
|
||||
|
||||
// ---
|
||||
@@ -144,7 +148,10 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
|
||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
|
||||
Err(err) => {
|
||||
err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
|
||||
err_json!(
|
||||
json!({"error": "invalid_grant"}),
|
||||
format!("Unable to refresh login credentials: {}", err.message())
|
||||
)
|
||||
}
|
||||
Ok((mut device, auth_tokens)) => {
|
||||
// Save to update `device.updated_at` to track usage and toggle new status
|
||||
@@ -739,8 +746,27 @@ async fn twofactor_auth(
|
||||
|
||||
TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?;
|
||||
|
||||
let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect();
|
||||
let twofactor_ids: Vec<_> = twofactors
|
||||
.iter()
|
||||
.filter_map(|tf| {
|
||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||
(tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype)
|
||||
})
|
||||
.collect();
|
||||
if twofactor_ids.is_empty() {
|
||||
err!("No enabled and usable two factor providers are available for this account")
|
||||
}
|
||||
|
||||
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
||||
// Ignore Remember and RecoveryCode Types during this check, these are special
|
||||
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
|
||||
&& !twofactor_ids.contains(&selected_id)
|
||||
{
|
||||
err_json!(
|
||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||
"Invalid two factor provider"
|
||||
)
|
||||
}
|
||||
|
||||
let twofactor_code = match data.two_factor_token {
|
||||
Some(ref code) => code,
|
||||
@@ -871,7 +897,7 @@ async fn _json_err_twofactor(
|
||||
match TwoFactorType::from_i32(*provider) {
|
||||
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
||||
|
||||
Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => {
|
||||
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
|
||||
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
||||
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
||||
}
|
||||
|
||||
@@ -358,15 +358,16 @@ impl WebSocketUsers {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
|
||||
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
if *NOTIFICATIONS_DISABLED {
|
||||
return;
|
||||
}
|
||||
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
||||
let data = create_update(
|
||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||
UpdateType::LogOut,
|
||||
acting_device_id.clone(),
|
||||
acting_device_id,
|
||||
);
|
||||
|
||||
if CONFIG.enable_websocket() {
|
||||
@@ -374,7 +375,7 @@ impl WebSocketUsers {
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
push_logout(user, acting_device_id.clone(), conn).await;
|
||||
push_logout(user, acting_device, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-6
@@ -13,7 +13,7 @@ use tokio::sync::RwLock;
|
||||
use crate::{
|
||||
api::{ApiResult, EmptyResult, UpdateType},
|
||||
db::{
|
||||
models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId},
|
||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||
DbConn,
|
||||
},
|
||||
http_client::make_http_request,
|
||||
@@ -188,15 +188,13 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) {
|
||||
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
|
||||
|
||||
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": user.uuid,
|
||||
"organizationId": (),
|
||||
"deviceId": acting_device_id,
|
||||
"identifier": acting_device_id,
|
||||
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
||||
"identifier": acting_device.map(|d| &d.uuid),
|
||||
"type": UpdateType::LogOut as i32,
|
||||
"payload": {
|
||||
"userId": user.uuid,
|
||||
|
||||
+35
-16
@@ -704,10 +704,9 @@ pub struct OrgHeaders {
|
||||
|
||||
impl OrgHeaders {
|
||||
fn is_member(&self) -> bool {
|
||||
// NOTE: we don't care about MembershipStatus at the moment because this is only used
|
||||
// where an invited, accepted or confirmed user is expected if this ever changes or
|
||||
// if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly
|
||||
self.membership_type >= MembershipType::User
|
||||
// Only allow not revoked members, we can not use the Confirmed status here
|
||||
// as some endpoints can be triggered by invited users during joining
|
||||
self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User
|
||||
}
|
||||
fn is_confirmed_and_admin(&self) -> bool {
|
||||
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
|
||||
@@ -720,6 +719,36 @@ impl OrgHeaders {
|
||||
}
|
||||
}
|
||||
|
||||
// org_id is usually the second path param ("/organizations/<org_id>"),
|
||||
// but there are cases where it is a query value.
|
||||
// First check the path, if this is not a valid uuid, try the query values.
|
||||
fn get_org_id(request: &Request<'_>) -> Option<OrganizationId> {
|
||||
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
||||
Some(org_id)
|
||||
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
||||
Some(org_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Special Guard to ensure that there is an organization id present
|
||||
// If there is no org id trigger the Outcome::Forward.
|
||||
// This is useful for endpoints which work for both organization and personal vaults, like purge.
|
||||
pub struct OrgIdGuard;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for OrgIdGuard {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
match get_org_id(request) {
|
||||
Some(_) => Outcome::Success(OrgIdGuard),
|
||||
None => Outcome::Forward(rocket::http::Status::NotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for OrgHeaders {
|
||||
type Error = &'static str;
|
||||
@@ -727,18 +756,8 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(Headers::from_request(request).await);
|
||||
|
||||
// org_id is usually the second path param ("/organizations/<org_id>"),
|
||||
// but there are cases where it is a query value.
|
||||
// First check the path, if this is not a valid uuid, try the query values.
|
||||
let url_org_id: Option<OrganizationId> = {
|
||||
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
||||
Some(org_id)
|
||||
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
||||
Some(org_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
// Extract the org_id from the request
|
||||
let url_org_id = get_org_id(request);
|
||||
|
||||
match url_org_id {
|
||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||
|
||||
+7
-11
@@ -387,7 +387,6 @@ pub mod models;
|
||||
#[cfg(sqlite)]
|
||||
pub fn backup_sqlite() -> Result<String, Error> {
|
||||
use diesel::Connection;
|
||||
use std::{fs::File, io::Write};
|
||||
|
||||
let db_url = CONFIG.database_url();
|
||||
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
|
||||
@@ -401,16 +400,13 @@ pub fn backup_sqlite() -> Result<String, Error> {
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
match File::create(backup_file.clone()) {
|
||||
Ok(mut f) => {
|
||||
let serialized_db = conn.serialize_database_to_buffer();
|
||||
f.write_all(serialized_db.as_slice()).expect("Error writing SQLite backup");
|
||||
Ok(backup_file)
|
||||
}
|
||||
Err(e) => {
|
||||
err_silent!(format!("Unable to save SQLite backup: {e:?}"))
|
||||
}
|
||||
}
|
||||
diesel::sql_query("VACUUM INTO ?")
|
||||
.bind::<diesel::sql_types::Text, _>(&backup_file)
|
||||
.execute(&mut conn)
|
||||
.map(|_| ())
|
||||
.map_res("VACUUM INTO failed")?;
|
||||
|
||||
Ok(backup_file)
|
||||
} else {
|
||||
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
|
||||
}
|
||||
|
||||
+30
-21
@@ -559,7 +559,7 @@ impl Cipher {
|
||||
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
||||
return cached_member.has_full_access();
|
||||
}
|
||||
} else if let Some(member) = Membership::find_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||
return member.has_full_access();
|
||||
}
|
||||
}
|
||||
@@ -668,10 +668,12 @@ impl Cipher {
|
||||
ciphers::table
|
||||
.filter(ciphers::uuid.eq(&self.uuid))
|
||||
.inner_join(ciphers_collections::table.on(
|
||||
ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
|
||||
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
|
||||
))
|
||||
.inner_join(users_collections::table.on(
|
||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))))
|
||||
.and(users_collections::user_uuid.eq(user_uuid))
|
||||
))
|
||||
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
||||
.load::<(bool, bool, bool)>(conn)
|
||||
.expect("Error getting user access restrictions")
|
||||
@@ -697,6 +699,9 @@ impl Cipher {
|
||||
.inner_join(users_organizations::table.on(
|
||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||
))
|
||||
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
|
||||
.load::<(bool, bool, bool)>(conn)
|
||||
@@ -795,28 +800,28 @@ impl Cipher {
|
||||
let mut query = ciphers::table
|
||||
.left_join(ciphers_collections::table.on(
|
||||
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
|
||||
))
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
|
||||
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))
|
||||
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||
))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||
// Ensure that users_collections::user_uuid is NULL for unconfirmed users.
|
||||
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))
|
||||
))
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
// Ensure that group and membership belong to the same org
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
|
||||
collections_groups::groups_uuid.eq(groups::uuid)
|
||||
)
|
||||
))
|
||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
||||
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||
))
|
||||
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
|
||||
.or_filter(users_organizations::access_all.eq(true)) // access_all in org
|
||||
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
|
||||
@@ -986,7 +991,9 @@ impl Cipher {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
||||
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||
@@ -1047,7 +1054,9 @@ impl Cipher {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
|
||||
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||
@@ -1115,8 +1124,8 @@ impl Cipher {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
|
||||
|
||||
+11
-11
@@ -191,7 +191,7 @@ impl Collection {
|
||||
self.update_users_revision(conn).await;
|
||||
CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?;
|
||||
CollectionUser::delete_all_by_collection(&self.uuid, conn).await?;
|
||||
CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?;
|
||||
CollectionGroup::delete_all_by_collection(&self.uuid, &self.org_uuid, conn).await?;
|
||||
|
||||
db_run! { conn: {
|
||||
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
|
||||
@@ -239,8 +239,8 @@ impl Collection {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
@@ -355,8 +355,8 @@ impl Collection {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
@@ -422,8 +422,8 @@ impl Collection {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||
@@ -484,8 +484,8 @@ impl Collection {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
@@ -531,8 +531,8 @@ impl Collection {
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
|
||||
+17
-1
@@ -49,11 +49,16 @@ impl Device {
|
||||
|
||||
push_uuid: Some(PushId(get_uuid())),
|
||||
push_token: None,
|
||||
refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL),
|
||||
refresh_token: Device::generate_refresh_token(),
|
||||
twofactor_remember: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn generate_refresh_token() -> String {
|
||||
crypto::encode_random_bytes::<64>(&BASE64URL)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"id": self.uuid,
|
||||
@@ -260,6 +265,17 @@ impl Device {
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
// Generate a new token per device.
|
||||
// We cannot do a single UPDATE with one value because each device needs a unique token.
|
||||
let devices = Self::find_by_user(user_uuid, conn).await;
|
||||
for mut device in devices {
|
||||
device.refresh_token = Device::generate_refresh_token();
|
||||
device.save(false, conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Display)]
|
||||
|
||||
+62
-20
@@ -1,6 +1,6 @@
|
||||
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
|
||||
use crate::api::EmptyResult;
|
||||
use crate::db::schema::{collections_groups, groups, groups_users, users_organizations};
|
||||
use crate::db::schema::{collections, collections_groups, groups, groups_users, users_organizations};
|
||||
use crate::db::DbConn;
|
||||
use crate::error::MapResult;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
@@ -81,7 +81,7 @@ impl Group {
|
||||
// If both read_only and hide_passwords are false, then manage should be true
|
||||
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
||||
// Or an entry with everything to false
|
||||
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, conn)
|
||||
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
@@ -191,7 +191,7 @@ impl Group {
|
||||
|
||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
for group in Self::find_by_organization(org_uuid, conn).await {
|
||||
group.delete(conn).await?;
|
||||
group.delete(org_uuid, conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -246,8 +246,8 @@ impl Group {
|
||||
.inner_join(users_organizations::table.on(
|
||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||
))
|
||||
.inner_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
.inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.filter(groups::access_all.eq(true))
|
||||
@@ -276,9 +276,9 @@ impl Group {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||
CollectionGroup::delete_all_by_group(&self.uuid, conn).await?;
|
||||
GroupUser::delete_all_by_group(&self.uuid, conn).await?;
|
||||
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
||||
GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
|
||||
|
||||
db_run! { conn: {
|
||||
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
|
||||
@@ -306,8 +306,8 @@ impl Group {
|
||||
}
|
||||
|
||||
impl CollectionGroup {
|
||||
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
|
||||
pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
|
||||
for group_user in group_users {
|
||||
group_user.update_user_revision(conn).await;
|
||||
}
|
||||
@@ -365,10 +365,19 @@ impl CollectionGroup {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
collections_groups::table
|
||||
.inner_join(groups::table.on(
|
||||
groups::uuid.eq(collections_groups::groups_uuid)
|
||||
))
|
||||
.inner_join(collections::table.on(
|
||||
collections::uuid.eq(collections_groups::collections_uuid)
|
||||
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
||||
))
|
||||
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
.select(collections_groups::all_columns)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading collection groups")
|
||||
}}
|
||||
@@ -383,6 +392,13 @@ impl CollectionGroup {
|
||||
.inner_join(users_organizations::table.on(
|
||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||
))
|
||||
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.inner_join(collections::table.on(
|
||||
collections::uuid.eq(collections_groups::collections_uuid)
|
||||
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
||||
))
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.select(collections_groups::all_columns)
|
||||
.load::<Self>(conn)
|
||||
@@ -394,14 +410,20 @@ impl CollectionGroup {
|
||||
db_run! { conn: {
|
||||
collections_groups::table
|
||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||
.inner_join(collections::table.on(
|
||||
collections::uuid.eq(collections_groups::collections_uuid)
|
||||
))
|
||||
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(collections::org_uuid))
|
||||
))
|
||||
.select(collections_groups::all_columns)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading collection groups")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
|
||||
pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
|
||||
for group_user in group_users {
|
||||
group_user.update_user_revision(conn).await;
|
||||
}
|
||||
@@ -415,8 +437,8 @@ impl CollectionGroup {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
|
||||
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
|
||||
for group_user in group_users {
|
||||
group_user.update_user_revision(conn).await;
|
||||
}
|
||||
@@ -429,10 +451,14 @@ impl CollectionGroup {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_collection(
|
||||
collection_uuid: &CollectionId,
|
||||
org_uuid: &OrganizationId,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await;
|
||||
for collection_assigned_to_group in collection_assigned_to_groups {
|
||||
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await;
|
||||
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, org_uuid, conn).await;
|
||||
for group_user in group_users {
|
||||
group_user.update_user_revision(conn).await;
|
||||
}
|
||||
@@ -494,10 +520,19 @@ impl GroupUser {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
groups_users::table
|
||||
.inner_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.inner_join(users_organizations::table.on(
|
||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))
|
||||
))
|
||||
.filter(groups_users::groups_uuid.eq(group_uuid))
|
||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||
.select(groups_users::all_columns)
|
||||
.load::<Self>(conn)
|
||||
.expect("Error loading group users")
|
||||
}}
|
||||
@@ -522,6 +557,13 @@ impl GroupUser {
|
||||
.inner_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.inner_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.inner_join(collections::table.on(
|
||||
collections::uuid.eq(collections_groups::collections_uuid)
|
||||
.and(collections::org_uuid.eq(groups::organizations_uuid))
|
||||
))
|
||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||
.count()
|
||||
@@ -575,8 +617,8 @@ impl GroupUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
|
||||
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
|
||||
for group_user in group_users {
|
||||
group_user.update_user_revision(conn).await;
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ impl OrgPolicy {
|
||||
for policy in
|
||||
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
|
||||
{
|
||||
if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||
if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||
if user.atype < MembershipType::Admin {
|
||||
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
|
||||
Ok(opts) => {
|
||||
|
||||
@@ -514,7 +514,8 @@ impl Membership {
|
||||
"familySponsorshipValidUntil": null,
|
||||
"familySponsorshipToDelete": null,
|
||||
"accessSecretsManager": false,
|
||||
"limitCollectionCreation": self.atype < MembershipType::Manager, // If less then a manager return true, to limit collection creations
|
||||
// limit collection creation to managers with access_all permission to prevent issues
|
||||
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all,
|
||||
"limitCollectionDeletion": true,
|
||||
"limitItemDeletion": false,
|
||||
"allowAdminAccessToAllCollectionItems": true,
|
||||
@@ -1073,7 +1074,9 @@ impl Membership {
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)))
|
||||
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
|
||||
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
|
||||
))
|
||||
.left_join(ciphers_collections::table.on(
|
||||
ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ pub enum SendType {
|
||||
File = 1,
|
||||
}
|
||||
|
||||
enum SendAuthType {
|
||||
#[allow(dead_code)]
|
||||
// Send requires email OTP verification
|
||||
Email = 0, // Not yet supported by Vaultwarden
|
||||
// Send requires a password
|
||||
Password = 1,
|
||||
// Send requires no auth
|
||||
None = 2,
|
||||
}
|
||||
|
||||
impl Send {
|
||||
pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
@@ -145,6 +155,7 @@ impl Send {
|
||||
"maxAccessCount": self.max_access_count,
|
||||
"accessCount": self.access_count,
|
||||
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
|
||||
"authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 },
|
||||
"disabled": self.disabled,
|
||||
"hideEmail": self.hide_email,
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ pub struct TwoFactor {
|
||||
pub last_used: i64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(num_derive::FromPrimitive)]
|
||||
pub enum TwoFactorType {
|
||||
Authenticator = 0,
|
||||
|
||||
@@ -185,13 +185,14 @@ impl User {
|
||||
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
||||
/// After these 2 minutes this stamp will expire.
|
||||
///
|
||||
pub fn set_password(
|
||||
pub async fn set_password(
|
||||
&mut self,
|
||||
password: &str,
|
||||
new_key: Option<String>,
|
||||
reset_security_stamp: bool,
|
||||
allow_next_route: Option<Vec<String>>,
|
||||
) {
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32);
|
||||
|
||||
if let Some(route) = allow_next_route {
|
||||
@@ -203,12 +204,15 @@ impl User {
|
||||
}
|
||||
|
||||
if reset_security_stamp {
|
||||
self.reset_security_stamp()
|
||||
self.reset_security_stamp(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reset_security_stamp(&mut self) {
|
||||
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
self.security_stamp = get_uuid();
|
||||
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
||||
|
||||
+24
-21
@@ -6,7 +6,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver};
|
||||
use hickory_resolver::{net::runtime::TokioRuntimeProvider, TokioResolver};
|
||||
use regex::Regex;
|
||||
use reqwest::{
|
||||
dns::{Name, Resolve, Resolving},
|
||||
@@ -184,35 +184,35 @@ impl CustomDnsResolver {
|
||||
}
|
||||
|
||||
fn new() -> Arc<Self> {
|
||||
match TokioResolver::builder(TokioConnectionProvider::default()) {
|
||||
Ok(mut builder) => {
|
||||
if CONFIG.dns_prefer_ipv6() {
|
||||
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4;
|
||||
TokioResolver::builder(TokioRuntimeProvider::default())
|
||||
.and_then(|mut builder| {
|
||||
// Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first
|
||||
// This might cause issues on IPv4 only systems or containers
|
||||
// Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default
|
||||
if !CONFIG.dns_prefer_ipv6() {
|
||||
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
|
||||
}
|
||||
let resolver = builder.build();
|
||||
Arc::new(Self::Hickory(Arc::new(resolver)))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error creating Hickory resolver, falling back to default: {e:?}");
|
||||
Arc::new(Self::Default())
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
})
|
||||
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
|
||||
.map(|resolver| Arc::new(Self::Hickory(Arc::new(resolver))))
|
||||
.unwrap_or_else(|_| Arc::new(Self::Default()))
|
||||
}
|
||||
|
||||
// Note that we get an iterator of addresses, but we only grab the first one for convenience
|
||||
async fn resolve_domain(&self, name: &str) -> Result<Option<SocketAddr>, BoxError> {
|
||||
async fn resolve_domain(&self, name: &str) -> Result<Vec<SocketAddr>, BoxError> {
|
||||
pre_resolve(name)?;
|
||||
|
||||
let result = match self {
|
||||
Self::Default() => tokio::net::lookup_host(name).await?.next(),
|
||||
Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)),
|
||||
let results: Vec<SocketAddr> = match self {
|
||||
Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(),
|
||||
Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(),
|
||||
};
|
||||
|
||||
if let Some(addr) = &result {
|
||||
for addr in &results {
|
||||
post_resolve(name, addr.ip())?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,8 +242,11 @@ impl Resolve for CustomDnsResolver {
|
||||
let this = self.clone();
|
||||
Box::pin(async move {
|
||||
let name = name.as_str();
|
||||
let result = this.resolve_domain(name).await?;
|
||||
Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter()))
|
||||
let results = this.resolve_domain(name).await?;
|
||||
if results.is_empty() {
|
||||
warn!("Unable to resolve {name} to any valid IP address");
|
||||
}
|
||||
Ok::<reqwest::dns::Addrs, _>(Box::new(results.into_iter()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+36
-5
@@ -558,6 +558,12 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
||||
let basepath = &CONFIG.domain_path();
|
||||
|
||||
let mut config = rocket::Config::from(rocket::Config::figment());
|
||||
|
||||
// We install our own signal handlers below; disable Rocket's built-in handlers
|
||||
config.shutdown.ctrlc = false;
|
||||
#[cfg(unix)]
|
||||
config.shutdown.signals.clear();
|
||||
|
||||
config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into();
|
||||
config.cli_colors = false; // Make sure Rocket does not color any values for logging.
|
||||
config.limits = Limits::new()
|
||||
@@ -589,11 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
||||
|
||||
CONFIG.set_rocket_shutdown_handle(instance.shutdown());
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
|
||||
info!("Exiting Vaultwarden!");
|
||||
CONFIG.shutdown();
|
||||
});
|
||||
spawn_shutdown_signal_handler();
|
||||
|
||||
#[cfg(all(unix, sqlite))]
|
||||
{
|
||||
@@ -621,6 +623,35 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_shutdown_signal_handler() {
|
||||
tokio::spawn(async move {
|
||||
use tokio::signal::unix::signal;
|
||||
|
||||
let mut sigint = signal(SignalKind::interrupt()).expect("Error setting SIGINT handler");
|
||||
let mut sigterm = signal(SignalKind::terminate()).expect("Error setting SIGTERM handler");
|
||||
let mut sigquit = signal(SignalKind::quit()).expect("Error setting SIGQUIT handler");
|
||||
|
||||
let signal_name = tokio::select! {
|
||||
_ = sigint.recv() => "SIGINT",
|
||||
_ = sigterm.recv() => "SIGTERM",
|
||||
_ = sigquit.recv() => "SIGQUIT",
|
||||
};
|
||||
|
||||
info!("Received {signal_name}, initiating graceful shutdown");
|
||||
CONFIG.shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn spawn_shutdown_signal_handler() {
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
|
||||
info!("Received Ctrl-C, initiating graceful shutdown");
|
||||
CONFIG.shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
fn schedule_jobs(pool: db::DbPool) {
|
||||
if CONFIG.job_poll_interval_ms() == 0 {
|
||||
info!("Job scheduler disabled.");
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ use crate::{
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC";
|
||||
pub static FAKE_SSO_IDENTIFIER: &str = "vaultwarden-dummy-oidc-identifier";
|
||||
|
||||
static SSO_JWT_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin()));
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
"microsoftstore.com",
|
||||
"xbox.com",
|
||||
"azure.com",
|
||||
"windowsazure.com"
|
||||
"windowsazure.com",
|
||||
"cloud.microsoft"
|
||||
],
|
||||
"excluded": false
|
||||
},
|
||||
@@ -971,5 +972,13 @@
|
||||
"pinterest.se"
|
||||
],
|
||||
"excluded": false
|
||||
},
|
||||
{
|
||||
"type": 91,
|
||||
"domains": [
|
||||
"twitter.com",
|
||||
"x.com"
|
||||
],
|
||||
"excluded": false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -137,6 +137,14 @@ bit-nav-logo bit-nav-item .bwi-shield {
|
||||
app-user-layout app-danger-zone button:nth-child(1) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
|
||||
/* Hide unsupported Forwarding email alias options */
|
||||
ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="Firefox Relay"]) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="DuckDuckGo"]) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
/**** END Static Vaultwarden Changes ****/
|
||||
/**** START Dynamic Vaultwarden Changes ****/
|
||||
{{#if signup_disabled}}
|
||||
|
||||
+15
@@ -634,6 +634,21 @@ fn _process_key(key: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deser_opt_nonempty_str<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: From<String>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
Ok(Option::<String>::deserialize(deserializer)?.and_then(|s| {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(T::from(s))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum NumberOrString {
|
||||
|
||||
@@ -79,3 +79,4 @@ for name, domain_list in domain_lists.items():
|
||||
# Write out the global domains JSON file.
|
||||
with open(file=OUTPUT_FILE, mode='w', encoding='utf-8') as f:
|
||||
json.dump(global_domains, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
Reference in New Issue
Block a user