Merge remote-tracking branch 'origin/main' into quenting/schemars-0.9

This commit is contained in:
Quentin Gliech
2025-11-06 17:34:43 +01:00
890 changed files with 27640 additions and 10097 deletions

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
comment: false
flag_management:

View File

@@ -1,2 +1,7 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[profile.default]
retries = 1

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
target/
crates/*/target
crates/*/node_modules

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
root = true
[*]

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
*.wasm binary

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @element-hq/mas-maintainers

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Build the frontend assets
description: Installs Node.js and builds the frontend assets from the frontend directory
@@ -7,7 +12,7 @@ runs:
- name: Install Node
uses: actions/setup-node@v4.2.0
with:
node-version: '22'
node-version: "22"
- name: Install dependencies
run: npm ci

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Build the Open Policy Agent policies
description: Installs OPA and builds the policies
@@ -7,7 +12,8 @@ runs:
- name: Install Open Policy Agent
uses: open-policy-agent/setup-opa@v2.2.0
with:
version: 1.1.0
# Keep in sync with the Dockerfile and policies/Makefile
version: 1.8.0
- name: Build the policies
run: make

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
version: 2
updates:
- package-ecosystem: "cargo"

5
.github/release.yml vendored
View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
changelog:
categories:
- title: Bug Fixes

View File

@@ -1,2 +1,7 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
node_modules/
package-lock.json

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// @ts-check

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// @ts-check

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// @ts-check

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// @ts-check

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// @ts-check

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// @ts-check

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// @ts-check

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Build
on:
@@ -41,7 +46,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
with:
# Need a full clone so that `git describe` reports the right version
fetch-depth: 0
@@ -62,7 +67,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- uses: ./.github/actions/build-frontend
- uses: ./.github/actions/build-policies
@@ -79,7 +84,7 @@ jobs:
chmod -R u=rwX,go=rX assets-dist/
- name: Upload assets
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v5.0.0
with:
name: assets
path: assets-dist
@@ -107,7 +112,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -138,7 +143,7 @@ jobs:
-p mas-cli
- name: Upload binary artifact
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v5.0.0
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/mas-cli
@@ -157,19 +162,19 @@ jobs:
steps:
- name: Download assets
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: assets
path: assets-dist
- name: Download binary x86_64
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: binary-x86_64-unknown-linux-gnu
path: binary-x86_64
- name: Download binary aarch64
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: binary-aarch64-unknown-linux-gnu
path: binary-aarch64
@@ -187,13 +192,13 @@ jobs:
done
- name: Upload aarch64 archive
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v5.0.0
with:
name: mas-cli-aarch64-linux
path: mas-cli-aarch64-linux.tar.gz
- name: Upload x86_64 archive
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v5.0.0
with:
name: mas-cli-x86_64-linux
path: mas-cli-x86_64-linux.tar.gz
@@ -221,7 +226,7 @@ jobs:
steps:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5.7.0
uses: docker/metadata-action@v5.9.0
with:
images: "${{ env.IMAGE }}"
bake-target: docker-metadata-action
@@ -237,7 +242,7 @@ jobs:
- name: Docker meta (debug variant)
id: meta-debug
uses: docker/metadata-action@v5.7.0
uses: docker/metadata-action@v5.9.0
with:
images: "${{ env.IMAGE }}"
bake-target: docker-metadata-action-debug
@@ -253,17 +258,17 @@ jobs:
type=sha
- name: Setup Cosign
uses: sigstore/cosign-installer@v3.8.2
uses: sigstore/cosign-installer@v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v3.11.1
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.4.0
uses: docker/login-action@v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -271,7 +276,7 @@ jobs:
- name: Build and push
id: bake
uses: docker/bake-action@v6.8.0
uses: docker/bake-action@v6.9.0
with:
files: |
./docker-bake.hcl
@@ -315,14 +320,14 @@ jobs:
- build-image
steps:
- name: Download the artifacts from the previous job
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
pattern: mas-cli-*
path: artifacts
merge-multiple: true
- name: Prepare a release
uses: softprops/action-gh-release@v2.3.2
uses: softprops/action-gh-release@v2.4.1
with:
generate_release_notes: true
body: |
@@ -371,27 +376,27 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
with:
sparse-checkout: |
.github/scripts
- name: Download the artifacts from the previous job
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
pattern: mas-cli-*
path: artifacts
merge-multiple: true
- name: Update unstable git tag
uses: actions/github-script@v7.0.1
uses: actions/github-script@v8.0.0
with:
script: |
const script = require('./.github/scripts/update-unstable-tag.cjs');
await script({ core, github, context });
- name: Update unstable release
uses: softprops/action-gh-release@v2.3.2
uses: softprops/action-gh-release@v2.4.1
with:
name: "Unstable build"
tag_name: unstable
@@ -449,13 +454,13 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
with:
sparse-checkout: |
.github/scripts
- name: Remove label and comment
uses: actions/github-script@v7.0.1
uses: actions/github-script@v8.0.0
env:
BUILD_IMAGE_MANIFEST: ${{ needs.build-image.outputs.metadata }}
with:

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: CI
on:
@@ -29,14 +34,15 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- uses: ./.github/actions/build-policies
- name: Setup Regal
uses: StyraInc/setup-regal@v1
with:
version: 0.29.2
# Keep in sync with policies/Makefile
version: 0.36.1
- name: Lint policies
working-directory: ./policies
@@ -55,10 +61,10 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.0.0
with:
node-version: 22
@@ -79,10 +85,10 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.0.0
with:
node-version: 22
@@ -103,10 +109,10 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.0.0
with:
node-version: 20
@@ -127,7 +133,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly
@@ -150,10 +156,10 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Run `cargo-deny`
uses: EmbarkStudios/cargo-deny-action@v2.0.11
uses: EmbarkStudios/cargo-deny-action@v2.0.13
with:
rust-version: stable
@@ -166,7 +172,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
run: |
@@ -207,10 +213,10 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.86.0
uses: dtolnay/rust-toolchain@1.89.0
with:
components: clippy
@@ -232,7 +238,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -251,7 +257,7 @@ jobs:
SQLX_OFFLINE: "1"
- name: Upload archive to workflow
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v5.0.0
with:
name: nextest-archive
path: nextest-archive.tar.zst
@@ -285,7 +291,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -299,7 +305,7 @@ jobs:
- uses: ./.github/actions/build-policies
- name: Download archive
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
name: nextest-archive

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Coverage
on:
@@ -24,7 +29,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- uses: ./.github/actions/build-policies
@@ -33,7 +38,7 @@ jobs:
run: make coverage
- name: Upload to codecov.io
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.5.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: policies/coverage.json
@@ -49,7 +54,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- uses: ./.github/actions/build-frontend
env:
@@ -60,7 +65,7 @@ jobs:
run: npm run coverage
- name: Upload to codecov.io
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.5.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: frontend/coverage/
@@ -94,7 +99,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -127,7 +132,7 @@ jobs:
grcov . --binary-path ./target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/tests.lcov
- name: Upload to codecov.io
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.5.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: target/coverage/*.lcov

View File

@@ -1,10 +1,15 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Build and deploy the documentation
on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -20,7 +25,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -34,7 +39,7 @@ jobs:
tool: mdbook
- name: Install Node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.0.0
with:
node-version: 22
@@ -48,7 +53,7 @@ jobs:
done
- name: Upload GitHub Pages artifacts
uses: actions/upload-pages-artifact@v3.0.1
uses: actions/upload-pages-artifact@v4.0.0
with:
path: target/book/

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Merge back a reference to main
on:
workflow_call:
@@ -19,13 +24,13 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
sparse-checkout: |
.github/scripts
- name: Push branch and open a PR
uses: actions/github-script@v7.0.1
uses: actions/github-script@v8.0.0
env:
SHA: ${{ inputs.sha }}
with:

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Create a new release branch
on:
workflow_dispatch:
@@ -29,7 +34,7 @@ jobs:
run: exit 1
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -56,10 +61,10 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.0.0
with:
node-version: 22
@@ -101,13 +106,13 @@ jobs:
needs: [tag, compute-version, localazy]
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
with:
sparse-checkout: |
.github/scripts
- name: Create a new release branch
uses: actions/github-script@v7.0.1
uses: actions/github-script@v8.0.0
env:
BRANCH: release/v${{ needs.compute-version.outputs.short }}
SHA: ${{ needs.tag.outputs.sha }}

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Bump the version on a release branch
on:
workflow_dispatch:
@@ -28,7 +33,7 @@ jobs:
run: exit 1
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -71,13 +76,13 @@ jobs:
needs: [tag, compute-version]
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
with:
sparse-checkout: |
.github/scripts
- name: Update the release branch
uses: actions/github-script@v7.0.1
uses: actions/github-script@v8.0.0
env:
BRANCH: "${{ github.ref_name }}"
SHA: ${{ needs.tag.outputs.sha }}

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Tag a new version
on:
workflow_call:
@@ -25,7 +30,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -41,7 +46,7 @@ jobs:
run: cargo metadata --format-version 1
- name: Commit and tag using the GitHub API
uses: actions/github-script@v7.0.1
uses: actions/github-script@v8.0.0
id: commit
env:
VERSION: ${{ inputs.version }}
@@ -53,7 +58,7 @@ jobs:
return await script({ core, github, context });
- name: Update the refs
uses: actions/github-script@v7.0.1
uses: actions/github-script@v8.0.0
env:
VERSION: ${{ inputs.version }}
TAG_SHA: ${{ fromJSON(steps.commit.outputs.result).tag }}

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Download translation files from Localazy
on:
workflow_dispatch:
@@ -14,10 +19,10 @@ jobs:
run: exit 1
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.0.0
with:
node-version: 22

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
name: Upload translation files to Localazy
on:
push:
@@ -13,10 +18,10 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@v6.0.0
with:
node-version: 22

7
.gitignore vendored
View File

@@ -1,5 +1,10 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
# Rust
target/
target
# Editors
.idea

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
max_width = 100
comment_width = 80
wrap_comments = true

2075
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,16 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[workspace]
default-members = ["crates/cli"]
members = ["crates/*"]
resolver = "2"
# Updated in the CI with a `sed` command
package.version = "0.17.0-rc.0"
package.license = "AGPL-3.0-only"
package.version = "1.6.0-rc.0"
package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial"
package.authors = ["Element Backend Team"]
package.edition = "2024"
package.homepage = "https://element-hq.github.io/matrix-authentication-service/"
@@ -21,6 +26,7 @@ all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
str_to_string = "deny"
too_many_lines = "allow"
[workspace.lints.rustdoc]
broken_intra_doc_links = "deny"
@@ -28,40 +34,40 @@ broken_intra_doc_links = "deny"
[workspace.dependencies]
# Workspace crates
mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.17.0-rc.0" }
mas-cli = { path = "./crates/cli/", version = "=0.17.0-rc.0" }
mas-config = { path = "./crates/config/", version = "=0.17.0-rc.0" }
mas-context = { path = "./crates/context/", version = "=0.17.0-rc.0" }
mas-data-model = { path = "./crates/data-model/", version = "=0.17.0-rc.0" }
mas-email = { path = "./crates/email/", version = "=0.17.0-rc.0" }
mas-graphql = { path = "./crates/graphql/", version = "=0.17.0-rc.0" }
mas-handlers = { path = "./crates/handlers/", version = "=0.17.0-rc.0" }
mas-http = { path = "./crates/http/", version = "=0.17.0-rc.0" }
mas-i18n = { path = "./crates/i18n/", version = "=0.17.0-rc.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.17.0-rc.0" }
mas-iana = { path = "./crates/iana/", version = "=0.17.0-rc.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.17.0-rc.0" }
mas-jose = { path = "./crates/jose/", version = "=0.17.0-rc.0" }
mas-keystore = { path = "./crates/keystore/", version = "=0.17.0-rc.0" }
mas-listener = { path = "./crates/listener/", version = "=0.17.0-rc.0" }
mas-matrix = { path = "./crates/matrix/", version = "=0.17.0-rc.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.17.0-rc.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.17.0-rc.0" }
mas-policy = { path = "./crates/policy/", version = "=0.17.0-rc.0" }
mas-router = { path = "./crates/router/", version = "=0.17.0-rc.0" }
mas-spa = { path = "./crates/spa/", version = "=0.17.0-rc.0" }
mas-storage = { path = "./crates/storage/", version = "=0.17.0-rc.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.17.0-rc.0" }
mas-tasks = { path = "./crates/tasks/", version = "=0.17.0-rc.0" }
mas-templates = { path = "./crates/templates/", version = "=0.17.0-rc.0" }
mas-tower = { path = "./crates/tower/", version = "=0.17.0-rc.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=0.17.0-rc.0" }
syn2mas = { path = "./crates/syn2mas", version = "=0.17.0-rc.0" }
mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.6.0-rc.0" }
mas-cli = { path = "./crates/cli/", version = "=1.6.0-rc.0" }
mas-config = { path = "./crates/config/", version = "=1.6.0-rc.0" }
mas-context = { path = "./crates/context/", version = "=1.6.0-rc.0" }
mas-data-model = { path = "./crates/data-model/", version = "=1.6.0-rc.0" }
mas-email = { path = "./crates/email/", version = "=1.6.0-rc.0" }
mas-graphql = { path = "./crates/graphql/", version = "=1.6.0-rc.0" }
mas-handlers = { path = "./crates/handlers/", version = "=1.6.0-rc.0" }
mas-http = { path = "./crates/http/", version = "=1.6.0-rc.0" }
mas-i18n = { path = "./crates/i18n/", version = "=1.6.0-rc.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.6.0-rc.0" }
mas-iana = { path = "./crates/iana/", version = "=1.6.0-rc.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.6.0-rc.0" }
mas-jose = { path = "./crates/jose/", version = "=1.6.0-rc.0" }
mas-keystore = { path = "./crates/keystore/", version = "=1.6.0-rc.0" }
mas-listener = { path = "./crates/listener/", version = "=1.6.0-rc.0" }
mas-matrix = { path = "./crates/matrix/", version = "=1.6.0-rc.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.6.0-rc.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.6.0-rc.0" }
mas-policy = { path = "./crates/policy/", version = "=1.6.0-rc.0" }
mas-router = { path = "./crates/router/", version = "=1.6.0-rc.0" }
mas-spa = { path = "./crates/spa/", version = "=1.6.0-rc.0" }
mas-storage = { path = "./crates/storage/", version = "=1.6.0-rc.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.6.0-rc.0" }
mas-tasks = { path = "./crates/tasks/", version = "=1.6.0-rc.0" }
mas-templates = { path = "./crates/templates/", version = "=1.6.0-rc.0" }
mas-tower = { path = "./crates/tower/", version = "=1.6.0-rc.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=1.6.0-rc.0" }
syn2mas = { path = "./crates/syn2mas", version = "=1.6.0-rc.0" }
# OpenAPI schema generation and validation
[workspace.dependencies.aide]
version = "0.15.0"
features = ["axum", "axum-extra", "axum-json", "axum-query", "macros"]
features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"]
# An `Arc` that can be atomically updated
[workspace.dependencies.arc-swap]
@@ -78,11 +84,11 @@ version = "0.3.6"
# Utility to write and implement async traits
[workspace.dependencies.async-trait]
version = "0.1.88"
version = "0.1.89"
# High-level error handling
[workspace.dependencies.anyhow]
version = "1.0.98"
version = "1.0.100"
# Assert that a value matches a pattern
[workspace.dependencies.assert_matches]
@@ -90,12 +96,12 @@ version = "1.5.0"
# HTTP router
[workspace.dependencies.axum]
version = "0.8.4"
version = "0.8.6"
# Extra utilities for Axum
[workspace.dependencies.axum-extra]
version = "0.10.1"
features = ["cookie-private", "cookie-key-expansion", "typed-header"]
version = "0.10.3"
features = ["cookie-private", "cookie-key-expansion", "typed-header", "query"]
# Axum macros
[workspace.dependencies.axum-macros]
@@ -118,12 +124,12 @@ features = ["std"]
# Bcrypt password hashing
[workspace.dependencies.bcrypt]
version = "0.17.0"
version = "0.17.1"
default-features = true
# Packed bitfields
[workspace.dependencies.bitflags]
version = "2.9.1"
version = "2.9.4"
# Bytes
[workspace.dependencies.bytes]
@@ -131,7 +137,7 @@ version = "1.10.1"
# UTF-8 paths
[workspace.dependencies.camino]
version = "1.1.10"
version = "1.2.1"
features = ["serde1"]
# ChaCha20Poly1305 AEAD
@@ -149,19 +155,19 @@ version = "0.15.11"
# Cookie store
[workspace.dependencies.cookie_store]
version = "0.21.1"
version = "0.22.0"
default-features = false
features = ["serde_json"]
# Time utilities
[workspace.dependencies.chrono]
version = "0.4.41"
version = "0.4.42"
default-features = false
features = ["serde", "clock"]
# CLI argument parsing
[workspace.dependencies.clap]
version = "4.5.40"
version = "4.5.50"
features = ["derive"]
# Object Identifiers (OIDs) as constants
@@ -221,7 +227,7 @@ features = ["env", "yaml", "test"]
# URL form encoding
[workspace.dependencies.form_urlencoded]
version = "1.2.1"
version = "1.2.2"
# Utilities for dealing with futures
[workspace.dependencies.futures-util]
@@ -233,7 +239,7 @@ version = "0.14.7"
# Rate-limiting
[workspace.dependencies.governor]
version = "0.10.0"
version = "0.10.1"
default-features = false
features = ["std", "dashmap", "quanta"]
@@ -263,12 +269,12 @@ version = "0.1.3"
# HTTP client and server
[workspace.dependencies.hyper]
version = "1.6.0"
version = "1.7.0"
features = ["client", "server", "http1", "http2"]
# Additional Hyper utilties
[workspace.dependencies.hyper-util]
version = "0.1.14"
version = "0.1.17"
features = [
"client",
"server",
@@ -315,7 +321,7 @@ features = ["std"]
# HashMap which preserves insertion order
[workspace.dependencies.indexmap]
version = "2.9.0"
version = "2.11.4"
features = ["serde"]
# Indented string literals
@@ -324,7 +330,7 @@ version = "2.0.6"
# Snapshot testing
[workspace.dependencies.insta]
version = "1.43.1"
version = "1.43.2"
features = ["yaml", "json"]
# IP network address types
@@ -348,10 +354,12 @@ features = ["serde"]
# Email sending
[workspace.dependencies.lettre]
version = "0.11.15"
version = "0.11.19"
default-features = false
features = [
"tokio1-rustls-tls",
"tokio1-rustls",
"rustls-platform-verifier",
"aws-lc-rs",
"hostname",
"builder",
"tracing",
@@ -370,12 +378,12 @@ version = "0.3.17"
# Templates
[workspace.dependencies.minijinja]
version = "2.10.2"
features = ["loader", "json", "speedups", "unstable_machinery"]
version = "2.12.0"
features = ["urlencode", "loader", "json", "speedups", "unstable_machinery"]
# Additional filters for minijinja
[workspace.dependencies.minijinja-contrib]
version = "2.10.2"
version = "2.12.0"
features = ["pycompat"]
# Utilities to deal with non-zero values
@@ -384,40 +392,40 @@ version = "0.3.0"
# Open Policy Agent support through WASM
[workspace.dependencies.opa-wasm]
version = "0.1.5"
version = "0.1.7"
# OpenTelemetry
[workspace.dependencies.opentelemetry]
version = "0.29.1"
version = "0.31.0"
features = ["trace", "metrics"]
[workspace.dependencies.opentelemetry-http]
version = "0.29.0"
version = "0.31.0"
features = ["reqwest"]
[workspace.dependencies.opentelemetry-jaeger-propagator]
version = "0.29.0"
version = "0.31.0"
[workspace.dependencies.opentelemetry-otlp]
version = "0.29.0"
version = "0.31.0"
default-features = false
features = ["trace", "metrics", "http-proto"]
[workspace.dependencies.opentelemetry-prometheus]
version = "0.29.1"
[workspace.dependencies.opentelemetry-prometheus-text-exporter]
version = "0.2.1"
[workspace.dependencies.opentelemetry-resource-detectors]
version = "0.8.0"
version = "0.10.0"
[workspace.dependencies.opentelemetry-semantic-conventions]
version = "0.29.0"
version = "0.31.0"
features = ["semconv_experimental"]
[workspace.dependencies.opentelemetry-stdout]
version = "0.29.0"
version = "0.31.0"
features = ["trace", "metrics"]
[workspace.dependencies.opentelemetry_sdk]
version = "0.29.0"
version = "0.31.0"
features = [
"experimental_trace_batch_span_processor_with_async_runtime",
"experimental_metrics_periodicreader_with_async_runtime",
"rt-tokio",
]
[workspace.dependencies.tracing-opentelemetry]
version = "0.30.0"
version = "0.32.0"
default-features = false
# P256 elliptic curve
@@ -446,11 +454,11 @@ features = ["std"]
# Parser generator
[workspace.dependencies.pest]
version = "2.8.0"
version = "2.8.3"
# Pest derive macros
[workspace.dependencies.pest_derive]
version = "2.8.0"
version = "2.8.3"
# Pin projection
[workspace.dependencies.pin-project-lite]
@@ -468,11 +476,7 @@ features = ["std", "pkcs5", "encryption"]
# Public Suffix List
[workspace.dependencies.psl]
version = "2.1.120"
# Prometheus metrics
[workspace.dependencies.prometheus]
version = "0.14.0"
version = "2.1.148"
# High-precision clock
[workspace.dependencies.quanta]
@@ -488,13 +492,19 @@ version = "0.6.4"
# Regular expressions
[workspace.dependencies.regex]
version = "1.11.1"
version = "1.12.2"
# High-level HTTP client
[workspace.dependencies.reqwest]
version = "0.12.20"
version = "0.12.24"
default-features = false
features = ["http2", "rustls-tls-manual-roots", "charset", "json", "socks"]
features = [
"http2",
"rustls-tls-manual-roots-no-provider",
"charset",
"json",
"socks",
]
# RSA cryptography
[workspace.dependencies.rsa]
@@ -507,11 +517,11 @@ version = "2.1.1"
# Matrix-related types
[workspace.dependencies.ruma-common]
version = "0.15.2"
version = "0.16.0"
# TLS stack
[workspace.dependencies.rustls]
version = "0.23.27"
version = "0.23.34"
# PEM parsing for rustls
[workspace.dependencies.rustls-pemfile]
@@ -523,7 +533,7 @@ version = "1.12.0"
# Use platform-specific verifier for TLS
[workspace.dependencies.rustls-platform-verifier]
version = "0.5.3"
version = "0.6.1"
# systemd service status notification
[workspace.dependencies.sd-notify]
@@ -541,7 +551,7 @@ features = ["std"]
# Query builder
[workspace.dependencies.sea-query]
version = "0.32.6"
version = "0.32.7"
features = ["derive", "attr", "with-uuid", "with-chrono", "postgres-array"]
# Query builder
@@ -557,27 +567,27 @@ features = [
# Sentry error tracking
[workspace.dependencies.sentry]
version = "0.37.0"
version = "0.45.0"
default-features = false
features = ["backtrace", "contexts", "panic", "tower", "reqwest"]
# Sentry tower layer
[workspace.dependencies.sentry-tower]
version = "0.37.0"
version = "0.45.0"
features = ["http", "axum-matched-path"]
# Sentry tracing integration
[workspace.dependencies.sentry-tracing]
version = "0.37.0"
version = "0.45.0"
# Serialization and deserialization
[workspace.dependencies.serde]
version = "1.0.219"
version = "1.0.228"
features = ["derive"] # Most of the time, if we need serde, we need derive
# JSON serialization and deserialization
[workspace.dependencies.serde_json]
version = "1.0.140"
version = "1.0.145"
features = ["preserve_order"]
# URL encoded form serialization
@@ -586,7 +596,7 @@ version = "0.7.1"
# Custom serialization helpers
[workspace.dependencies.serde_with]
version = "3.12.0"
version = "3.14.0"
features = ["hex", "chrono"]
# YAML serialization
@@ -604,7 +614,7 @@ version = "2.2.0"
# Low-level socket manipulation
[workspace.dependencies.socket2]
version = "0.5.10"
version = "0.6.1"
# Subject Public Key Info
[workspace.dependencies.spki]
@@ -627,14 +637,14 @@ features = [
# Custom error types
[workspace.dependencies.thiserror]
version = "2.0.12"
version = "2.0.17"
[workspace.dependencies.thiserror-ext]
version = "0.2.1"
version = "0.3.0"
# Async runtime
[workspace.dependencies.tokio]
version = "1.45.1"
version = "1.48.0"
features = ["full"]
[workspace.dependencies.tokio-stream]
@@ -642,7 +652,7 @@ version = "0.1.17"
# Tokio rustls integration
[workspace.dependencies.tokio-rustls]
version = "0.26.2"
version = "0.26.4"
# Tokio test utilities
[workspace.dependencies.tokio-test]
@@ -650,7 +660,7 @@ version = "0.4.4"
# Useful async utilities
[workspace.dependencies.tokio-util]
version = "0.7.15"
version = "0.7.16"
features = ["rt"]
# Tower services
@@ -675,14 +685,14 @@ features = ["cors", "fs", "add-extension", "set-header"]
[workspace.dependencies.tracing]
version = "0.1.41"
[workspace.dependencies.tracing-subscriber]
version = "0.3.19"
version = "0.3.20"
features = ["env-filter"]
[workspace.dependencies.tracing-appender]
version = "0.2.3"
# URL manipulation
[workspace.dependencies.url]
version = "2.5.4"
version = "2.5.7"
features = ["serde"]
# URL encoding
@@ -696,7 +706,7 @@ features = ["serde", "uuid"]
# UUID support
[workspace.dependencies.uuid]
version = "1.17.0"
version = "1.18.1"
# HTML escaping
[workspace.dependencies.v_htmlescape]
@@ -713,7 +723,7 @@ version = "2.5.0"
# HTTP mock server
[workspace.dependencies.wiremock]
version = "0.6.3"
version = "0.6.5"
# User-agent parser
[workspace.dependencies.woothee]
@@ -725,7 +735,7 @@ version = "0.5.5"
# Zero memory after use
[workspace.dependencies.zeroize]
version = "1.8.1"
version = "1.8.2"
# Password strength estimation
[workspace.dependencies.zxcvbn]

View File

@@ -1,4 +1,8 @@
# syntax = docker/dockerfile:1.7.1
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
# Builds a minimal image with the binary only. It is multi-arch capable,
# cross-building to aarch64 and x86_64. When cross-compiling, Docker sets two
@@ -8,10 +12,11 @@
# The Debian version and version name must be in sync
ARG DEBIAN_VERSION=12
ARG DEBIAN_VERSION_NAME=bookworm
ARG RUSTC_VERSION=1.86.0
ARG NODEJS_VERSION=20.15.0
ARG OPA_VERSION=1.1.0
ARG CARGO_AUDITABLE_VERSION=0.6.6
ARG RUSTC_VERSION=1.89.0
ARG NODEJS_VERSION=22.19.0
# Keep in sync with .github/actions/build-policies/action.yml and policies/Makefile
ARG OPA_VERSION=1.8.0
ARG CARGO_AUDITABLE_VERSION=0.7.0
##########################################
## Build stage that builds the frontend ##
@@ -20,7 +25,7 @@ FROM --platform=${BUILDPLATFORM} docker.io/library/node:${NODEJS_VERSION}-${DEBI
WORKDIR /app/frontend
COPY ./frontend/package.json ./frontend/package-lock.json /app/frontend/
COPY ./frontend/.npmrc ./frontend/package.json ./frontend/package-lock.json /app/frontend/
# Network access: to fetch dependencies
RUN --network=default \
npm ci

6
LICENSE-COMMERCIAL Normal file
View File

@@ -0,0 +1,6 @@
Licensees holding a valid commercial license with Element may use this
software in accordance with the terms contained in a written agreement
between you and Element.
To purchase a commercial license please contact our sales team at
licensing@element.io

View File

@@ -27,3 +27,15 @@ Anyone can contribute to translations through [Localazy](https://localazy.com/el
## 🏗️ Contributing
See the [contribution guidelines](https://element-hq.github.io/matrix-authentication-service/development/contributing.html) for information on how to contribute to this project.
## ⚖️ Copyright & License
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021-2024 The Matrix.org Foundation C.I.C.
This software is dual-licensed by New Vector Ltd (Element). It can be used either:
(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR
(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to).
Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses.

View File

@@ -1,28 +1,27 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignore": [
".devcontainer/**",
"docs/**",
"translations/**",
"policies/**",
"crates/**",
"frontend/package.json",
"frontend/src/gql/**",
"frontend/src/routeTree.gen.ts",
"frontend/.storybook/locales.ts",
"frontend/.storybook/public/mockServiceWorker.js",
"frontend/locales/*.json",
"**/coverage/**",
"**/dist/**"
"includes": [
"**",
"!**/.devcontainer/**",
"!**/docs/**",
"!**/translations/**",
"!**/policies/**",
"!**/crates/**",
"!**/frontend/package.json",
"!**/frontend/src/gql/**",
"!**/frontend/src/routeTree.gen.ts",
"!**/frontend/.storybook/locales.ts",
"!**/frontend/.storybook/public/mockServiceWorker.js",
"!**/frontend/locales/**/*.json",
"!**/coverage/**",
"!**/dist/**"
]
},
"formatter": {
@@ -33,9 +32,28 @@
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noImportantStyles": "off"
},
"suspicious": {
"noUnknownAtRules": "off"
},
"correctness": {
"noUnusedImports": "warn",
"noUnusedVariables": "warn"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error",
"noDescendingSpecificity": "off"
}
}
}

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
# Documentation for possible options in this file is at
# https://rust-lang.github.io/mdBook/format/config.html
[book]

View File

@@ -1,4 +1,9 @@
doc-valid-idents = ["OpenID", "OAuth", "..", "PostgreSQL", "SQLite"]
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
doc-valid-idents = ["OpenID", "OAuth", "UserInfo", "..", "PostgreSQL", "SQLite"]
disallowed-methods = [
{ path = "rand::thread_rng", reason = "do not create rngs on the fly, pass them as parameters" },
@@ -10,7 +15,8 @@ disallowed-methods = [
]
disallowed-types = [
"rand::OsRng",
{ path = "std::path::PathBuf", reason = "use camino::Utf8PathBuf instead" },
{ path = "std::path::Path", reason = "use camino::Utf8Path instead" },
{ path = "axum::extract::Query", reason = "use axum_extra::extract::Query instead. The built-in version doesn't deserialise lists."},
{ path = "axum::extract::rejection::QueryRejection", reason = "use axum_extra::extract::QueryRejection instead"}
]

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[package]
name = "mas-axum-utils"
version.workspace = true

View File

@@ -1,21 +1,20 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::collections::HashMap;
use axum::{
BoxError, Json,
extract::{
Form, FromRequest, FromRequestParts,
Form, FromRequest,
rejection::{FailedToDeserializeForm, FormRejection},
},
response::IntoResponse,
};
use axum_extra::typed_header::{TypedHeader, TypedHeaderRejectionReason};
use headers::{Authorization, authorization::Basic};
use headers::authorization::{Basic, Bearer, Credentials as _};
use http::{Request, StatusCode};
use mas_data_model::{Client, JwksOrJwksUri};
use mas_http::RequestBuilderExt;
@@ -60,17 +59,30 @@ pub enum Credentials {
client_id: String,
jwt: Box<Jwt<'static, HashMap<String, serde_json::Value>>>,
},
BearerToken {
token: String,
},
}
impl Credentials {
/// Get the `client_id` of the credentials
#[must_use]
pub fn client_id(&self) -> &str {
pub fn client_id(&self) -> Option<&str> {
match self {
Credentials::None { client_id }
| Credentials::ClientSecretBasic { client_id, .. }
| Credentials::ClientSecretPost { client_id, .. }
| Credentials::ClientAssertionJwtBearer { client_id, .. } => client_id,
| Credentials::ClientAssertionJwtBearer { client_id, .. } => Some(client_id),
Credentials::BearerToken { .. } => None,
}
}
/// Get the bearer token from the credentials.
#[must_use]
pub fn bearer_token(&self) -> Option<&str> {
match self {
Credentials::BearerToken { token } => Some(token),
_ => None,
}
}
@@ -89,6 +101,7 @@ impl Credentials {
| Credentials::ClientSecretBasic { client_id, .. }
| Credentials::ClientSecretPost { client_id, .. }
| Credentials::ClientAssertionJwtBearer { client_id, .. } => client_id,
Credentials::BearerToken { .. } => return Ok(None),
};
repo.oauth2_client().find_by_client_id(client_id).await
@@ -239,7 +252,7 @@ pub struct ClientAuthorization<F = ()> {
impl<F> ClientAuthorization<F> {
/// Get the `client_id` from the credentials.
#[must_use]
pub fn client_id(&self) -> &str {
pub fn client_id(&self) -> Option<&str> {
self.credentials.client_id()
}
}
@@ -355,31 +368,41 @@ where
{
type Rejection = ClientAuthorizationError;
#[allow(clippy::too_many_lines)]
async fn from_request(
req: Request<axum::body::Body>,
state: &S,
) -> Result<Self, Self::Rejection> {
// Split the request into parts so we can extract some headers
let (mut parts, body) = req.into_parts();
enum Authorization {
Basic(String, String),
Bearer(String),
}
let header =
TypedHeader::<Authorization<Basic>>::from_request_parts(&mut parts, state).await;
// Sadly, the typed-header 'Authorization' doesn't let us check for both
// Basic and Bearer at the same time, so we need to parse them manually
let authorization = if let Some(header) = req.headers().get(http::header::AUTHORIZATION) {
let bytes = header.as_bytes();
if bytes.len() >= 6 && bytes[..6].eq_ignore_ascii_case(b"Basic ") {
let Some(decoded) = Basic::decode(header) else {
return Err(ClientAuthorizationError::InvalidHeader);
};
// Take the Authorization header
let credentials_from_header = match header {
Ok(header) => Some((header.username().to_owned(), header.password().to_owned())),
Err(err) => match err.reason() {
// If it's missing it is fine
TypedHeaderRejectionReason::Missing => None,
// If the header could not be parsed, return the error
_ => return Err(ClientAuthorizationError::InvalidHeader),
},
Some(Authorization::Basic(
decoded.username().to_owned(),
decoded.password().to_owned(),
))
} else if bytes.len() >= 7 && bytes[..7].eq_ignore_ascii_case(b"Bearer ") {
let Some(decoded) = Bearer::decode(header) else {
return Err(ClientAuthorizationError::InvalidHeader);
};
Some(Authorization::Bearer(decoded.token().to_owned()))
} else {
return Err(ClientAuthorizationError::InvalidHeader);
}
} else {
None
};
// Reconstruct the request from the parts
let req = Request::from_parts(parts, body);
// Take the form value
let (
client_id_from_form,
@@ -407,13 +430,19 @@ where
// And now, figure out the actual auth method
let credentials = match (
credentials_from_header,
authorization,
client_id_from_form,
client_secret_from_form,
client_assertion_type,
client_assertion,
) {
(Some((client_id, client_secret)), client_id_from_form, None, None, None) => {
(
Some(Authorization::Basic(client_id, client_secret)),
client_id_from_form,
None,
None,
None,
) => {
if let Some(client_id_from_form) = client_id_from_form {
// If the client_id was in the body, verify it matches with the header
if client_id != client_id_from_form {
@@ -483,6 +512,11 @@ where
});
}
(Some(Authorization::Bearer(token)), None, None, None, None) => {
// Got a bearer token
Credentials::BearerToken { token }
}
(None, None, None, None, None) => {
// Special case when there are no credentials anywhere
return Err(ClientAuthorizationError::MissingCredentials);
@@ -677,4 +711,29 @@ mod tests {
jwt.verify_with_shared_secret(b"client-secret".to_vec())
.unwrap();
}
#[tokio::test]
async fn bearer_token_test() {
let req = Request::builder()
.method(Method::POST)
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.header(http::header::AUTHORIZATION, "Bearer token")
.body(Body::new("foo=bar".to_owned()))
.unwrap();
assert_eq!(
ClientAuthorization::<serde_json::Value>::from_request(req, &())
.await
.unwrap(),
ClientAuthorization {
credentials: Credentials::BearerToken {
token: "token".to_owned(),
},
form: Some(serde_json::json!({"foo": "bar"})),
}
);
}
}

View File

@@ -1,8 +1,8 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Private (encrypted) cookie jar, based on axum-extra's cookie jar

View File

@@ -1,12 +1,12 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use base64ct::{Base64UrlUnpadded, Encoding};
use chrono::{DateTime, Duration, Utc};
use mas_storage::Clock;
use mas_data_model::Clock;
use rand::{Rng, RngCore, distributions::Standard, prelude::Distribution as _};
use serde::{Deserialize, Serialize};
use serde_with::{TimestampSeconds, serde_as};

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use axum::response::{IntoResponse, Response};

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use axum::{
Extension,

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use axum::response::{IntoResponse, Response};
use axum_extra::typed_header::TypedHeader;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::cmp::Reverse;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
#![deny(clippy::future_not_send)]
#![allow(clippy::module_name_repetitions)]

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::convert::Infallible;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use mas_data_model::BrowserSession;
use mas_storage::RepositoryAccess;

View File

@@ -1,8 +1,8 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{collections::HashMap, error::Error};
@@ -16,9 +16,9 @@ use axum::{
use axum_extra::typed_header::{TypedHeader, TypedHeaderRejectionReason};
use headers::{Authorization, Header, HeaderMapExt, HeaderName, authorization::Bearer};
use http::{HeaderMap, HeaderValue, Request, StatusCode, header::WWW_AUTHENTICATE};
use mas_data_model::Session;
use mas_data_model::{Clock, Session};
use mas_storage::{
Clock, RepositoryAccess,
RepositoryAccess,
oauth2::{OAuth2AccessTokenRepository, OAuth2SessionRepository},
};
use serde::{Deserialize, de::DeserializeOwned};

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[package]
name = "mas-cli"
version.workspace = true
@@ -54,12 +59,11 @@ opentelemetry.workspace = true
opentelemetry-http.workspace = true
opentelemetry-jaeger-propagator.workspace = true
opentelemetry-otlp.workspace = true
opentelemetry-prometheus.workspace = true
opentelemetry-prometheus-text-exporter.workspace = true
opentelemetry-resource-detectors.workspace = true
opentelemetry-semantic-conventions.workspace = true
opentelemetry-stdout.workspace = true
opentelemetry_sdk.workspace = true
prometheus.workspace = true
sentry.workspace = true
sentry-tracing.workspace = true
sentry-tower.workspace = true

View File

@@ -1,7 +1,7 @@
// Copyright 2024, 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use vergen_gitcl::{Emitter, GitclBuilder, RustcBuilder};
@@ -12,13 +12,13 @@ fn main() -> anyhow::Result<()> {
// At build time, we override the version through the environment variable
// VERGEN_GIT_DESCRIBE. In some contexts, it means this variable is set but
// empty, so we unset it here.
if let Ok(ver) = std::env::var("VERGEN_GIT_DESCRIBE") {
if ver.is_empty() {
#[allow(unsafe_code)]
// SAFETY: This is safe because the build script is running a single thread
unsafe {
std::env::remove_var("VERGEN_GIT_DESCRIBE");
}
if let Ok(ver) = std::env::var("VERGEN_GIT_DESCRIBE")
&& ver.is_empty()
{
#[allow(unsafe_code)]
// SAFETY: This is safe because the build script is running a single thread
unsafe {
std::env::remove_var("VERGEN_GIT_DESCRIBE");
}
}

View File

@@ -1,15 +1,15 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{convert::Infallible, net::IpAddr, sync::Arc};
use axum::extract::{FromRef, FromRequestParts};
use ipnetwork::IpNetwork;
use mas_context::LogContext;
use mas_data_model::SiteConfig;
use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, SystemClock};
use mas_handlers::{
ActivityTracker, BoundActivityTracker, CookieManager, ErrorWrapper, GraphQLSchema, Limiter,
MetadataCache, RequesterFingerprint, passwords::PasswordManager,
@@ -19,9 +19,7 @@ use mas_keystore::{Encrypter, Keystore};
use mas_matrix::HomeserverConnection;
use mas_policy::{Policy, PolicyFactory};
use mas_router::UrlBuilder;
use mas_storage::{
BoxClock, BoxRepository, BoxRepositoryFactory, BoxRng, RepositoryFactory, SystemClock,
};
use mas_storage::{BoxRepository, BoxRepositoryFactory, RepositoryFactory};
use mas_storage_pg::PgRepositoryFactory;
use mas_templates::Templates;
use opentelemetry::KeyValue;
@@ -29,7 +27,7 @@ use rand::SeedableRng;
use sqlx::PgPool;
use tracing::Instrument;
use crate::telemetry::METER;
use crate::{VERSION, telemetry::METER};
#[derive(Clone)]
pub struct AppState {
@@ -216,6 +214,12 @@ impl FromRef<AppState> for Arc<dyn HomeserverConnection> {
}
}
impl FromRef<AppState> for AppVersion {
fn from_ref(_input: &AppState) -> Self {
AppVersion(VERSION)
}
}
impl FromRequestParts<AppState> for BoxClock {
type Rejection = Infallible;
@@ -275,10 +279,10 @@ fn infer_client_ip(
let peer = if let Some(info) = connection_info {
// We can always trust the proxy protocol to give us the correct IP address
if let Some(proxy) = info.get_proxy_ref() {
if let Some(source) = proxy.source() {
return Some(source.ip());
}
if let Some(proxy) = info.get_proxy_ref()
&& let Some(source) = proxy.source()
{
return Some(source.ip());
}
info.get_peer_addr().map(|addr| addr.ip())

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::process::ExitCode;
@@ -11,7 +11,7 @@ use camino::Utf8PathBuf;
use clap::Parser;
use figment::Figment;
use mas_config::{ConfigurationSection, RootConfig, SyncConfig};
use mas_storage::{Clock as _, SystemClock};
use mas_data_model::{Clock as _, SystemClock};
use mas_storage_pg::MIGRATOR;
use rand::SeedableRng;
use tokio::io::AsyncWriteExt;
@@ -72,7 +72,7 @@ impl Options {
SC::Dump { output } => {
let _span = info_span!("cli.config.dump").entered();
let config = RootConfig::extract(figment)?;
let config = RootConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
let config = serde_yaml::to_string(&config)?;
if let Some(output) = output {
@@ -88,7 +88,7 @@ impl Options {
SC::Check => {
let _span = info_span!("cli.config.check").entered();
let _config = RootConfig::extract(figment)?;
let _config = RootConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
info!("Configuration file looks good");
}
@@ -105,7 +105,8 @@ impl Options {
if !synapse_config.is_empty() {
info!("Adjusting MAS config to match Synapse config from {synapse_config:?}");
let synapse_config = syn2mas::synapse_config::Config::load(&synapse_config)?;
let synapse_config = syn2mas::synapse_config::Config::load(&synapse_config)
.map_err(anyhow::Error::from_boxed)?;
config = synapse_config.adjust_mas_config(config, &mut rng, clock.now());
}
@@ -121,7 +122,7 @@ impl Options {
}
SC::Sync { prune, dry_run } => {
let config = SyncConfig::extract(figment)?;
let config = SyncConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
let clock = SystemClock::default();
let encrypter = config.secrets.encrypter().await?;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::process::ExitCode;
@@ -30,7 +30,8 @@ enum Subcommand {
impl Options {
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
let _span = info_span!("cli.database.migrate").entered();
let config = DatabaseConfig::extract_or_default(figment)?;
let config =
DatabaseConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&config).await?;
// Run pending migrations

View File

@@ -1,8 +1,8 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::process::ExitCode;
@@ -41,13 +41,16 @@ impl Options {
match self.subcommand {
SC::Policy { with_dynamic_data } => {
let _span = info_span!("cli.debug.policy").entered();
let config = PolicyConfig::extract_or_default(figment)?;
let matrix_config = MatrixConfig::extract(figment)?;
let config =
PolicyConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?;
let matrix_config =
MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
info!("Loading and compiling the policy module");
let policy_factory = policy_factory_from_config(&config, &matrix_config).await?;
if with_dynamic_data {
let database_config = DatabaseConfig::extract(figment)?;
let database_config =
DatabaseConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
let pool = database_pool_from_config(&database_config).await?;
let repository_factory = PgRepositoryFactory::new(pool.clone());
load_policy_factory_dynamic_data(&policy_factory, &repository_factory).await?;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Diagnostic utility to check the health of the deployment
//!
@@ -14,6 +14,7 @@ use std::process::ExitCode;
use anyhow::Context;
use clap::Parser;
use figment::Figment;
use hyper::StatusCode;
use mas_config::{ConfigurationSection, RootConfig};
use mas_http::RequestBuilderExt;
use tracing::{error, info, info_span, warn};
@@ -26,14 +27,13 @@ const DOCS_BASE: &str = "https://element-hq.github.io/matrix-authentication-serv
pub(super) struct Options {}
impl Options {
#[allow(clippy::too_many_lines)]
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
let _span = info_span!("cli.doctor").entered();
info!(
"💡 Running diagnostics, make sure that both MAS and Synapse are running, and that MAS is using the same configuration files as this tool."
);
let config = RootConfig::extract(figment)?;
let config = RootConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
// We'll need an HTTP client
let http_client = mas_http::reqwest_client();
@@ -44,8 +44,8 @@ impl Options {
r"The homeserver host in the config (`matrix.homeserver`) is not a valid domain.
See {DOCS_BASE}/setup/homeserver.html",
)?;
let secret = config.matrix.secret().await?;
let hs_api = config.matrix.endpoint;
let admin_token = config.matrix.secret;
if !issuer.starts_with("https://") {
warn!(
@@ -100,17 +100,14 @@ Make sure that the MAS config contains:
http:
public_base: {issuer:?}
# Or, if the issuer is different from the public base:
issuer: {issuer:?}
And in the Synapse config:
experimental_features:
msc3861:
enabled: true
# This must exactly match:
issuer: {issuer:?}
# ...
matrix_authentication_service:
enabled: true
# This must point to where MAS is reachable by Synapse
endpoint: {issuer:?}
# ...
See {DOCS_BASE}/setup/homeserver.html
"#
@@ -129,11 +126,10 @@ Check the well-known document at "{well_known_uri}"
Check the well-known document at "{well_known_uri}"
Make sure Synapse has delegated auth enabled:
experimental_features:
msc3861:
enabled: true
issuer: {issuer:?}
# ...
matrix_authentication_service:
enabled: true
endpoint: {issuer:?}
# ...
If it is not Synapse handling the well-known document, update it to include the following:
@@ -284,70 +280,50 @@ Error details: {e}
),
}
// Try to reach the admin API on an unauthorized endpoint
let server_version = hs_api.join("/_synapse/admin/v1/server_version")?;
let result = http_client.get(server_version.as_str()).send_traced().await;
match result {
Ok(response) => {
let status = response.status();
if status.is_success() {
info!(r#"✅ The Synapse admin API is reachable at "{server_version}"."#);
} else {
error!(
r#"❌ A Synapse admin API endpoint at "{server_version}" replied with {status}.
Make sure MAS can reach the admin API, and that the homeserver is running.
"#
);
}
}
Err(e) => error!(
r#"❌ Can't reach the Synapse admin API at "{server_version}".
Make sure MAS can reach the admin API, and that the homeserver is running.
Error details: {e}
"#
),
}
// Try to reach an authenticated admin API endpoint
let background_updates = hs_api.join("/_synapse/admin/v1/background_updates/status")?;
// Try to reach an authenticated MAS API endpoint
let mas_api = hs_api.join("/_synapse/mas/is_localpart_available")?;
let result = http_client
.get(background_updates.as_str())
.bearer_auth(&admin_token)
.get(mas_api.as_str())
.bearer_auth(&secret)
.send_traced()
.await;
match result {
Ok(response) => {
let status = response.status();
if status.is_success() {
// We intentionally omit the required 'localpart' parameter
// in this request. If authentication is successful, Synapse
// returns a 400 Bad Request because of the missing
// parameter. If authentication fails, Synapse will return a
// 403 Forbidden. If the MAS integration isn't enabled,
// Synapse will return a 404 Not found.
if status == StatusCode::BAD_REQUEST {
info!(
r#"✅ The Synapse admin API is reachable with authentication at "{background_updates}"."#
r#"✅ The Synapse MAS API is reachable with authentication at "{mas_api}"."#
);
} else {
error!(
r#"❌ A Synapse admin API endpoint at "{background_updates}" replied with {status}.
r#"❌ A Synapse MAS API endpoint at "{mas_api}" replied with {status}.
Make sure the homeserver is running, and that the MAS config has the correct `matrix.secret`.
It should match the `admin_token` set in the Synapse config.
It should match the `secret` set in the Synapse config.
experimental_features:
msc3861:
enabled: true
issuer: {issuer}
# This must exactly match the secret in the MAS config:
admin_token: {admin_token:?}
matrix_authentication_service:
enabled: true
endpoint: {issuer:?}
# This must exactly match the secret in the MAS config:
secret: {secret:?}
And in the MAS config:
matrix:
homeserver: "{matrix_domain}"
endpoint: "{hs_api}"
secret: {admin_token:?}
secret: {secret:?}
"#
);
}
}
Err(e) => error!(
r#"❌ Can't reach the Synapse admin API at "{background_updates}".
r#"❌ Can't reach the Synapse MAS API at "{mas_api}".
Make sure the homeserver is running, and that the MAS config has the correct `matrix.secret`.
Error details: {e}

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{collections::BTreeMap, process::ExitCode};
@@ -15,18 +15,21 @@ use figment::Figment;
use mas_config::{
ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, PasswordsConfig,
};
use mas_data_model::{Device, TokenType, Ulid, UpstreamOAuthProvider, User};
use mas_data_model::{Clock, Device, SystemClock, TokenType, Ulid, UpstreamOAuthProvider, User};
use mas_email::Address;
use mas_matrix::HomeserverConnection;
use mas_storage::{
Clock, RepositoryAccess, SystemClock,
Pagination, RepositoryAccess,
compat::{CompatAccessTokenRepository, CompatSessionFilter, CompatSessionRepository},
oauth2::OAuth2SessionFilter,
queue::{
DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _, ReactivateUserJob,
SyncDevicesJob,
},
user::{BrowserSessionFilter, UserEmailRepository, UserPasswordRepository, UserRepository},
user::{
BrowserSessionFilter, UserEmailRepository, UserFilter, UserPasswordRepository,
UserRepository,
},
};
use mas_storage_pg::{DatabaseError, PgRepository};
use rand::{
@@ -85,6 +88,15 @@ enum Subcommand {
ignore_complexity: bool,
},
/// Make a user admin
PromoteAdmin { username: String },
/// Make a user non-admin
DemoteAdmin { username: String },
/// List all users with admin privileges
ListAdminUsers,
/// Issue a compatibility token
IssueCompatibilityToken {
/// User for which to issue the token
@@ -149,6 +161,10 @@ enum Subcommand {
UnlockUser {
/// User to unlock
username: String,
/// Whether to reactivate the user if it had been deactivated
#[arg(long)]
reactivate: bool,
},
/// Register a user
@@ -203,7 +219,6 @@ enum Subcommand {
}
impl Options {
#[allow(clippy::too_many_lines)]
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
use Subcommand as SC;
let clock = SystemClock::default();
@@ -219,8 +234,10 @@ impl Options {
let _span =
info_span!("cli.manage.set_password", user.username = %username).entered();
let database_config = DatabaseConfig::extract_or_default(figment)?;
let passwords_config = PasswordsConfig::extract_or_default(figment)?;
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let passwords_config = PasswordsConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let password_manager = password_manager_from_config(&passwords_config).await?;
@@ -260,7 +277,8 @@ impl Options {
)
.entered();
let database_config = DatabaseConfig::extract_or_default(figment)?;
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
@@ -309,12 +327,95 @@ impl Options {
Ok(ExitCode::SUCCESS)
}
SC::PromoteAdmin { username } => {
let _span =
info_span!("cli.manage.promote_admin", user.username = username,).entered();
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
let user = repo
.user()
.find_by_username(&username)
.await?
.context("User not found")?;
let user = repo.user().set_can_request_admin(user, true).await?;
repo.into_inner().commit().await?;
info!(%user.id, %user.username, "User promoted to admin");
Ok(ExitCode::SUCCESS)
}
SC::DemoteAdmin { username } => {
let _span =
info_span!("cli.manage.demote_admin", user.username = username,).entered();
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
let user = repo
.user()
.find_by_username(&username)
.await?
.context("User not found")?;
let user = repo.user().set_can_request_admin(user, false).await?;
repo.into_inner().commit().await?;
info!(%user.id, %user.username, "User is no longer admin");
Ok(ExitCode::SUCCESS)
}
SC::ListAdminUsers => {
let _span = info_span!("cli.manage.list_admins").entered();
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
let mut cursor = Pagination::first(1000);
let filter = UserFilter::new().can_request_admin_only();
let total = repo.user().count(filter).await?;
info!("The following users can request admin privileges ({total} total):");
loop {
let page = repo.user().list(filter, cursor).await?;
for edge in page.edges {
let user = edge.node;
info!(%user.id, username = %user.username);
cursor = cursor.after(edge.cursor);
}
if !page.has_next_page {
break;
}
}
Ok(ExitCode::SUCCESS)
}
SC::IssueCompatibilityToken {
username,
admin,
device_id,
} => {
let database_config = DatabaseConfig::extract_or_default(figment)?;
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let matrix_config =
MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
let http_client = mas_http::reqwest_client();
let homeserver =
homeserver_connection_from_config(&matrix_config, http_client).await?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
@@ -331,6 +432,24 @@ impl Options {
Device::generate(&mut rng)
};
if let Err(e) = homeserver
.upsert_device(&user.username, device.as_str(), None)
.await
{
error!(
error = &*e,
"Could not create the device on the homeserver, aborting"
);
// Schedule a device sync job to remove the potential leftover device
repo.queue_job()
.schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user))
.await?;
repo.into_inner().commit().await?;
return Ok(ExitCode::FAILURE);
}
let compat_session = repo
.compat_session()
.add(&mut rng, &clock, &user, device, None, admin, None)
@@ -372,7 +491,8 @@ impl Options {
(Some(_), true) => unreachable!(), // This should be handled by the clap group
};
let database_config = DatabaseConfig::extract_or_default(figment)?;
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
@@ -399,7 +519,8 @@ impl Options {
SC::ProvisionAllUsers => {
let _span = info_span!("cli.manage.provision_all_users").entered();
let database_config = DatabaseConfig::extract_or_default(figment)?;
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let mut txn = conn.begin().await?;
@@ -425,7 +546,8 @@ impl Options {
SC::KillSessions { username, dry_run } => {
let _span =
info_span!("cli.manage.kill_sessions", user.username = username).entered();
let database_config = DatabaseConfig::extract_or_default(figment)?;
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
@@ -497,7 +619,8 @@ impl Options {
deactivate,
} => {
let _span = info_span!("cli.manage.lock_user", user.username = username).entered();
let config = DatabaseConfig::extract_or_default(figment)?;
let config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
@@ -527,9 +650,14 @@ impl Options {
Ok(ExitCode::SUCCESS)
}
SC::UnlockUser { username } => {
let _span = info_span!("cli.manage.lock_user", user.username = username).entered();
let config = DatabaseConfig::extract_or_default(figment)?;
SC::UnlockUser {
username,
reactivate,
} => {
let _span =
info_span!("cli.manage.unlock_user", user.username = username).entered();
let config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let mut conn = database_connection_from_config(&config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
@@ -540,10 +668,14 @@ impl Options {
.await?
.context("User not found")?;
warn!(%user.id, "User scheduling user reactivation");
repo.queue_job()
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user))
.await?;
if reactivate {
warn!(%user.id, "Scheduling user reactivation");
repo.queue_job()
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user))
.await?;
} else {
repo.user().unlock(user).await?;
}
repo.into_inner().commit().await?;
@@ -562,23 +694,26 @@ impl Options {
ignore_password_complexity,
} => {
let http_client = mas_http::reqwest_client();
let password_config = PasswordsConfig::extract_or_default(figment)?;
let database_config = DatabaseConfig::extract_or_default(figment)?;
let matrix_config = MatrixConfig::extract(figment)?;
let password_config = PasswordsConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let database_config = DatabaseConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let matrix_config =
MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
let password_manager = password_manager_from_config(&password_config).await?;
let homeserver = homeserver_connection_from_config(&matrix_config, http_client);
let homeserver =
homeserver_connection_from_config(&matrix_config, http_client).await?;
let mut conn = database_connection_from_config(&database_config).await?;
let txn = conn.begin().await?;
let mut repo = PgRepository::from_conn(txn);
if let Some(password) = &password {
if !ignore_password_complexity
&& !password_manager.is_password_complex_enough(password)?
{
error!("That password is too weak.");
return Ok(ExitCode::from(1));
}
if let Some(password) = &password
&& !ignore_password_complexity
&& !password_manager.is_password_complex_enough(password)?
{
error!("That password is too weak.");
return Ok(ExitCode::from(1));
}
// If the username is provided, check if it's available and normalize it.

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::process::ExitCode;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{collections::BTreeSet, process::ExitCode, sync::Arc, time::Duration};
@@ -14,10 +14,10 @@ use mas_config::{
AppConfig, ClientsConfig, ConfigurationSection, ConfigurationSectionExt, UpstreamOAuth2Config,
};
use mas_context::LogContext;
use mas_data_model::SystemClock;
use mas_handlers::{ActivityTracker, CookieManager, Limiter, MetadataCache};
use mas_listener::server::Server;
use mas_router::UrlBuilder;
use mas_storage::SystemClock;
use mas_storage_pg::{MIGRATOR, PgRepositoryFactory};
use sqlx::migrate::Migrate;
use tracing::{Instrument, info, info_span, warn};
@@ -55,11 +55,10 @@ pub(super) struct Options {
}
impl Options {
#[allow(clippy::too_many_lines)]
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
let span = info_span!("cli.run.init").entered();
let mut shutdown = LifecycleManager::new()?;
let config = AppConfig::extract(figment)?;
let config = AppConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
info!(version = crate::VERSION, "Starting up");
@@ -101,8 +100,10 @@ impl Options {
} else {
// Sync the configuration with the database
let mut conn = pool.acquire().await?;
let clients_config = ClientsConfig::extract_or_default(figment)?;
let upstream_oauth2_config = UpstreamOAuth2Config::extract_or_default(figment)?;
let clients_config =
ClientsConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?;
let upstream_oauth2_config = UpstreamOAuth2Config::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
crate::sync::config_sync(
upstream_oauth2_config,
@@ -159,22 +160,29 @@ impl Options {
)?;
// Load and compile the templates
let templates =
templates_from_config(&config.templates, &site_config, &url_builder).await?;
let templates = templates_from_config(
&config.templates,
&site_config,
&url_builder,
// Don't use strict mode in production yet
false,
)
.await?;
shutdown.register_reloadable(&templates);
let http_client = mas_http::reqwest_client();
let homeserver_connection =
homeserver_connection_from_config(&config.matrix, http_client.clone());
homeserver_connection_from_config(&config.matrix, http_client.clone()).await?;
if !self.no_worker {
let mailer = mailer_from_config(&config.email, &templates)?;
test_mailer_in_background(&mailer, Duration::from_secs(30));
info!("Starting task worker");
mas_tasks::init(
mas_tasks::init_and_run(
PgRepositoryFactory::new(pool.clone()),
SystemClock::default(),
&mailer,
homeserver_connection.clone(),
url_builder.clone(),

View File

@@ -1,7 +1,7 @@
// Copyright 2024, 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{collections::HashMap, process::ExitCode, time::Duration};
@@ -13,7 +13,7 @@ use mas_config::{
ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, SyncConfig,
UpstreamOAuth2Config,
};
use mas_storage::SystemClock;
use mas_data_model::SystemClock;
use mas_storage_pg::MIGRATOR;
use rand::thread_rng;
use sqlx::{Connection, Either, PgConnection, postgres::PgConnectOptions, types::Uuid};
@@ -88,7 +88,6 @@ const NUM_WRITER_CONNECTIONS: usize = 8;
impl Options {
#[tracing::instrument("cli.syn2mas.run", skip_all)]
#[allow(clippy::too_many_lines)]
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
if self.synapse_configuration_files.is_empty() {
error!("Please specify the path to the Synapse configuration file(s).");
@@ -96,6 +95,7 @@ impl Options {
}
let synapse_config = synapse_config::Config::load(&self.synapse_configuration_files)
.map_err(anyhow::Error::from_boxed)
.context("Failed to load Synapse configuration")?;
// Establish a connection to Synapse's Postgres database
@@ -111,7 +111,8 @@ impl Options {
.await
.context("could not connect to Synapse Postgres database")?;
let config = DatabaseConfig::extract_or_default(figment)?;
let config =
DatabaseConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?;
let mut mas_connection = database_connection_from_config_with_options(
&config,
@@ -131,7 +132,7 @@ impl Options {
// First perform a config sync
// This is crucial to ensure we register upstream OAuth providers
// in the MAS database
let config = SyncConfig::extract(figment)?;
let config = SyncConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
let clock = SystemClock::default();
let encrypter = config.secrets.encrypter().await?;
@@ -213,7 +214,8 @@ impl Options {
Subcommand::Migrate { dry_run } => {
let provider_id_mappings: HashMap<String, Uuid> = {
let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(figment)?;
let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
mas_oauth2
.providers
@@ -252,7 +254,8 @@ impl Options {
let occasional_progress_logger_task =
tokio::spawn(occasional_progress_logger(progress.clone()));
let mas_matrix = MatrixConfig::extract(figment)?;
let mas_matrix =
MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
syn2mas::migrate(
reader,
writer,

View File

@@ -1,18 +1,20 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::process::ExitCode;
use std::{fmt::Write, process::ExitCode};
use anyhow::{Context as _, bail};
use camino::Utf8PathBuf;
use clap::Parser;
use figment::Figment;
use mas_config::{
AccountConfig, BrandingConfig, CaptchaConfig, ConfigurationSection, ConfigurationSectionExt,
ExperimentalConfig, MatrixConfig, PasswordsConfig, TemplatesConfig,
};
use mas_storage::{Clock, SystemClock};
use mas_data_model::{Clock, SystemClock};
use rand::SeedableRng;
use tracing::info_span;
@@ -27,23 +29,35 @@ pub(super) struct Options {
#[derive(Parser, Debug)]
enum Subcommand {
/// Check that the templates specified in the config are valid
Check,
Check {
/// If set, templates will be rendered to this directory.
/// The directory must either not exist or be empty.
#[arg(long = "out-dir")]
out_dir: Option<Utf8PathBuf>,
},
}
impl Options {
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
use Subcommand as SC;
match self.subcommand {
SC::Check => {
SC::Check { out_dir } => {
let _span = info_span!("cli.templates.check").entered();
let template_config = TemplatesConfig::extract_or_default(figment)?;
let branding_config = BrandingConfig::extract_or_default(figment)?;
let matrix_config = MatrixConfig::extract(figment)?;
let experimental_config = ExperimentalConfig::extract_or_default(figment)?;
let password_config = PasswordsConfig::extract_or_default(figment)?;
let account_config = AccountConfig::extract_or_default(figment)?;
let captcha_config = CaptchaConfig::extract_or_default(figment)?;
let template_config = TemplatesConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let branding_config = BrandingConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let matrix_config =
MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
let experimental_config = ExperimentalConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let password_config = PasswordsConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let account_config = AccountConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let captcha_config = CaptchaConfig::extract_or_default(figment)
.map_err(anyhow::Error::from_boxed)?;
let clock = SystemClock::default();
// XXX: we should disallow SeedableRng::from_entropy
@@ -58,9 +72,54 @@ impl Options {
&account_config,
&captcha_config,
)?;
let templates =
templates_from_config(&template_config, &site_config, &url_builder).await?;
templates.check_render(clock.now(), &mut rng)?;
let templates = templates_from_config(
&template_config,
&site_config,
&url_builder, // Use strict mode in template checks
true,
)
.await?;
let all_renders = templates.check_render(clock.now(), &mut rng)?;
if let Some(out_dir) = out_dir {
// Save renders to disk.
if out_dir.exists() {
let mut read_dir =
tokio::fs::read_dir(&out_dir).await.with_context(|| {
format!("could not read {out_dir} to check it's empty")
})?;
if read_dir.next_entry().await?.is_some() {
bail!("Render directory {out_dir} is not empty, refusing to write.");
}
} else {
tokio::fs::create_dir(&out_dir)
.await
.with_context(|| format!("could not create {out_dir}"))?;
}
for ((template, sample_identifier), template_render) in &all_renders {
let (template_filename_base, template_ext) =
template.rsplit_once('.').unwrap_or((template, "txt"));
let template_filename_base = template_filename_base.replace('/', "_");
// Make a string like `-index=0-browser-session=0-locale=fr`
let sample_suffix = {
let mut s = String::new();
for (k, v) in &sample_identifier.components {
write!(s, "-{k}={v}")?;
}
s
};
let render_path = out_dir.join(format!(
"{template_filename_base}{sample_suffix}.{template_ext}"
));
tokio::fs::write(&render_path, template_render.as_bytes())
.await
.with_context(|| format!("could not write render to {render_path}"))?;
}
}
Ok(ExitCode::SUCCESS)
}

View File

@@ -1,14 +1,15 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{process::ExitCode, time::Duration};
use clap::Parser;
use figment::Figment;
use mas_config::{AppConfig, ConfigurationSection};
use mas_data_model::SystemClock;
use mas_router::UrlBuilder;
use mas_storage_pg::PgRepositoryFactory;
use tracing::{info, info_span};
@@ -28,7 +29,7 @@ impl Options {
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
let shutdown = LifecycleManager::new()?;
let span = info_span!("cli.worker.init").entered();
let config = AppConfig::extract(figment)?;
let config = AppConfig::extract(figment).map_err(anyhow::Error::from_boxed)?;
// Connect to the database
info!("Connecting to the database");
@@ -51,20 +52,27 @@ impl Options {
)?;
// Load and compile the templates
let templates =
templates_from_config(&config.templates, &site_config, &url_builder).await?;
let templates = templates_from_config(
&config.templates,
&site_config,
&url_builder,
// Don't use strict mode on task workers for now
false,
)
.await?;
let mailer = mailer_from_config(&config.email, &templates)?;
test_mailer_in_background(&mailer, Duration::from_secs(30));
let http_client = mas_http::reqwest_client();
let conn = homeserver_connection_from_config(&config.matrix, http_client);
let conn = homeserver_connection_from_config(&config.matrix, http_client).await?;
drop(config);
info!("Starting task scheduler");
mas_tasks::init(
mas_tasks::init_and_run(
PgRepositoryFactory::new(pool.clone()),
SystemClock::default(),
&mailer,
conn,
url_builder,

View File

@@ -1,7 +1,7 @@
// Copyright 2024, 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{process::ExitCode, time::Duration};

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
#![allow(clippy::module_name_repetitions)]
@@ -115,8 +115,9 @@ async fn try_main() -> anyhow::Result<ExitCode> {
// Load the base configuration files
let figment = opts.figment();
let telemetry_config =
TelemetryConfig::extract_or_default(&figment).context("Failed to load telemetry config")?;
let telemetry_config = TelemetryConfig::extract_or_default(&figment)
.map_err(anyhow::Error::from_boxed)
.context("Failed to load telemetry config")?;
// Setup Sentry
let sentry = sentry::init((
@@ -127,8 +128,6 @@ async fn try_main() -> anyhow::Result<ExitCode> {
release: Some(VERSION.into()),
sample_rate: telemetry_config.sentry.sample_rate.unwrap_or(1.0),
traces_sample_rate: telemetry_config.sentry.traces_sample_rate.unwrap_or(0.0),
auto_session_tracking: true,
session_mode: sentry::SessionMode::Request,
..Default::default()
},
));
@@ -150,12 +149,14 @@ async fn try_main() -> anyhow::Result<ExitCode> {
// Setup OpenTelemetry tracing and metrics
self::telemetry::setup(&telemetry_config).context("failed to setup OpenTelemetry")?;
let telemetry_layer = self::telemetry::TRACER.get().map(|tracer| {
tracing_opentelemetry::layer()
.with_tracer(tracer.clone())
.with_tracked_inactivity(false)
.with_filter(LevelFilter::INFO)
});
let tracer = self::telemetry::TRACER
.get()
.context("TRACER was not set")?;
let telemetry_layer = tracing_opentelemetry::layer()
.with_tracer(tracer.clone())
.with_tracked_inactivity(false)
.with_filter(LevelFilter::INFO);
let subscriber = Registry::default()
.with(suppress_layer)

View File

@@ -1,8 +1,8 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, ToSocketAddrs},
@@ -143,7 +143,12 @@ fn make_http_span<B>(req: &Request<B>) -> Span {
propagator.extract_with_context(&context, &extractor)
});
span.set_parent(parent_context);
if let Err(err) = span.set_parent(parent_context) {
tracing::error!(
error = &err as &dyn std::error::Error,
"Failed to set parent context on span"
);
}
span
}
@@ -205,7 +210,6 @@ async fn log_response_middleware(
response
}
#[allow(clippy::too_many_lines)]
pub fn build_router(
state: AppState,
resources: &[HttpResource],
@@ -269,7 +273,7 @@ pub fn build_router(
}
mas_config::HttpResource::OAuth => router.merge(mas_handlers::api_router::<AppState>()),
mas_config::HttpResource::Compat => {
router.merge(mas_handlers::compat_router::<AppState>())
router.merge(mas_handlers::compat_router::<AppState>(templates.clone()))
}
mas_config::HttpResource::AdminApi => {
let (_, api_router) = mas_handlers::admin_api_router::<AppState>();
@@ -333,7 +337,7 @@ pub fn build_router(
// which is the other way around compared to `tower::ServiceBuilder`.
// So even if the Sentry docs has an example that does
// 'NewSentryHttpLayer then SentryHttpLayer', we must do the opposite.
.layer(SentryHttpLayer::with_transaction())
.layer(SentryHttpLayer::new().enable_transaction())
.layer(NewSentryLayer::new_from_top())
.with_state(state)
}

View File

@@ -1,17 +1,18 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Utilities to synchronize the configuration file with the database.
use std::collections::{BTreeMap, BTreeSet};
use mas_config::{ClientsConfig, UpstreamOAuth2Config};
use mas_data_model::Clock;
use mas_keystore::Encrypter;
use mas_storage::{
Clock, Pagination, RepositoryAccess,
Pagination, RepositoryAccess,
upstream_oauth2::{UpstreamOAuthProviderFilter, UpstreamOAuthProviderParams},
};
use mas_storage_pg::PgRepository;
@@ -37,6 +38,19 @@ fn map_import_action(
}
}
fn map_import_on_conflict(
config: mas_config::UpstreamOAuth2OnConflict,
) -> mas_data_model::UpstreamOAuthProviderOnConflict {
match config {
mas_config::UpstreamOAuth2OnConflict::Add => {
mas_data_model::UpstreamOAuthProviderOnConflict::Add
}
mas_config::UpstreamOAuth2OnConflict::Fail => {
mas_data_model::UpstreamOAuthProviderOnConflict::Fail
}
}
}
fn map_claims_imports(
config: &mas_config::UpstreamOAuth2ClaimsImports,
) -> mas_data_model::UpstreamOAuthProviderClaimsImports {
@@ -44,9 +58,10 @@ fn map_claims_imports(
subject: mas_data_model::UpstreamOAuthProviderSubjectPreference {
template: config.subject.template.clone(),
},
localpart: mas_data_model::UpstreamOAuthProviderImportPreference {
localpart: mas_data_model::UpstreamOAuthProviderLocalpartPreference {
action: map_import_action(config.localpart.action),
template: config.localpart.template.clone(),
on_conflict: map_import_on_conflict(config.localpart.on_conflict),
},
displayname: mas_data_model::UpstreamOAuthProviderImportPreference {
action: map_import_action(config.displayname.action),
@@ -117,7 +132,8 @@ pub async fn config_sync(
let mut existing_enabled_ids = BTreeSet::new();
let mut existing_disabled = BTreeMap::new();
// Process the existing providers
for provider in page.edges {
for edge in page.edges {
let provider = edge.node;
if provider.enabled() {
if config_ids.contains(&provider.id) {
existing_enabled_ids.insert(provider.id);
@@ -194,11 +210,11 @@ pub async fn config_sync(
// private key to hold the content of the private key file.
// private key (raw) takes precedence so both can be defined
// without issues
if siwa.private_key.is_none() {
if let Some(private_key_file) = siwa.private_key_file.take() {
let key = tokio::fs::read_to_string(private_key_file).await?;
siwa.private_key = Some(key);
}
if siwa.private_key.is_none()
&& let Some(private_key_file) = siwa.private_key_file.take()
{
let key = tokio::fs::read_to_string(private_key_file).await?;
siwa.private_key = Some(key);
}
let encoded = serde_json::to_vec(&siwa)?;
Some(encrypter.encrypt_to_string(&encoded)?)
@@ -276,6 +292,18 @@ pub async fn config_sync(
}
};
let on_backchannel_logout = match provider.on_backchannel_logout {
mas_config::UpstreamOAuth2OnBackchannelLogout::DoNothing => {
mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing
}
mas_config::UpstreamOAuth2OnBackchannelLogout::LogoutBrowserOnly => {
mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::LogoutBrowserOnly
}
mas_config::UpstreamOAuth2OnBackchannelLogout::LogoutAll => {
mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::LogoutAll
}
};
repo.upstream_oauth_provider()
.upsert(
clock,
@@ -306,6 +334,7 @@ pub async fn config_sync(
.collect(),
forward_login_hint: provider.forward_login_hint,
ui_order,
on_backchannel_logout,
},
)
.await?;
@@ -357,7 +386,7 @@ pub async fn config_sync(
continue;
}
let client_secret = client.client_secret.as_deref();
let client_secret = client.client_secret().await?;
let client_name = client.client_name.as_ref();
let client_auth_method = client.client_auth_method();
let jwks = client.jwks.as_ref();

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
mod tokio;
@@ -23,18 +23,17 @@ use opentelemetry::{
trace::TracerProvider as _,
};
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
use opentelemetry_prometheus::PrometheusExporter;
use opentelemetry_prometheus_text_exporter::PrometheusExporter;
use opentelemetry_sdk::{
Resource,
metrics::{ManualReader, SdkMeterProvider, periodic_reader_with_async_runtime::PeriodicReader},
propagation::{BaggagePropagator, TraceContextPropagator},
trace::{
Sampler, SdkTracerProvider, Tracer, span_processor_with_async_runtime::BatchSpanProcessor,
IdGenerator, Sampler, SdkTracerProvider, Tracer,
span_processor_with_async_runtime::BatchSpanProcessor,
},
};
use opentelemetry_semantic_conventions as semcov;
use prometheus::Registry;
use url::Url;
static SCOPE: LazyLock<InstrumentationScope> = LazyLock::new(|| {
InstrumentationScope::builder(env!("CARGO_PKG_NAME"))
@@ -49,7 +48,7 @@ pub static METER: LazyLock<Meter> =
pub static TRACER: OnceLock<Tracer> = OnceLock::new();
static METER_PROVIDER: OnceLock<SdkMeterProvider> = OnceLock::new();
static TRACER_PROVIDER: OnceLock<SdkTracerProvider> = OnceLock::new();
static PROMETHEUS_REGISTRY: OnceLock<Registry> = OnceLock::new();
static PROMETHEUS_EXPORTER: OnceLock<PrometheusExporter> = OnceLock::new();
pub fn setup(config: &TelemetryConfig) -> anyhow::Result<()> {
let propagator = propagator(&config.tracing.propagators);
@@ -95,50 +94,65 @@ fn propagator(propagators: &[Propagator]) -> TextMapCompositePropagator {
TextMapCompositePropagator::new(propagators)
}
fn stdout_tracer_provider() -> SdkTracerProvider {
let exporter = opentelemetry_stdout::SpanExporter::default();
SdkTracerProvider::builder()
.with_simple_exporter(exporter)
.build()
/// An [`IdGenerator`] which always returns an invalid trace ID and span ID
///
/// This is used when no exporter is being used, so that we don't log the trace
/// ID when we're not tracing.
#[derive(Debug, Clone, Copy)]
struct InvalidIdGenerator;
impl IdGenerator for InvalidIdGenerator {
fn new_trace_id(&self) -> opentelemetry::TraceId {
opentelemetry::TraceId::INVALID
}
fn new_span_id(&self) -> opentelemetry::SpanId {
opentelemetry::SpanId::INVALID
}
}
fn otlp_tracer_provider(
endpoint: Option<&Url>,
sample_rate: f64,
) -> anyhow::Result<SdkTracerProvider> {
let mut exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_http_client(mas_http::reqwest_client());
if let Some(endpoint) = endpoint {
exporter = exporter.with_endpoint(endpoint.to_string());
}
let exporter = exporter
.build()
.context("Failed to configure OTLP trace exporter")?;
let batch_processor =
BatchSpanProcessor::builder(exporter, opentelemetry_sdk::runtime::Tokio).build();
fn init_tracer(config: &TracingConfig) -> anyhow::Result<()> {
let sample_rate = config.sample_rate.unwrap_or(1.0);
// We sample traces based on the parent if we have one, and if not, we
// sample a ratio based on the configured sample rate
let sampler = Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(sample_rate)));
let tracer_provider = SdkTracerProvider::builder()
.with_span_processor(batch_processor)
let tracer_provider_builder = SdkTracerProvider::builder()
.with_resource(resource())
.with_sampler(sampler)
.build();
.with_sampler(sampler);
Ok(tracer_provider)
}
fn init_tracer(config: &TracingConfig) -> anyhow::Result<()> {
let sample_rate = config.sample_rate.unwrap_or(1.0);
let tracer_provider = match config.exporter {
TracingExporterKind::None => return Ok(()),
TracingExporterKind::Stdout => stdout_tracer_provider(),
TracingExporterKind::Otlp => otlp_tracer_provider(config.endpoint.as_ref(), sample_rate)?,
TracingExporterKind::None => tracer_provider_builder
.with_id_generator(InvalidIdGenerator)
.with_sampler(Sampler::AlwaysOff)
.build(),
TracingExporterKind::Stdout => {
let exporter = opentelemetry_stdout::SpanExporter::default();
tracer_provider_builder
.with_simple_exporter(exporter)
.build()
}
TracingExporterKind::Otlp => {
let mut exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_http_client(mas_http::reqwest_client());
if let Some(endpoint) = &config.endpoint {
exporter = exporter.with_endpoint(endpoint.as_str());
}
let exporter = exporter
.build()
.context("Failed to configure OTLP trace exporter")?;
let batch_processor =
BatchSpanProcessor::builder(exporter, opentelemetry_sdk::runtime::Tokio).build();
tracer_provider_builder
.with_span_processor(batch_processor)
.build()
}
};
TRACER_PROVIDER
.set(tracer_provider.clone())
.map_err(|_| anyhow::anyhow!("TRACER_PROVIDER was set twice"))?;
@@ -180,21 +194,30 @@ type PromServiceFuture =
#[allow(clippy::needless_pass_by_value)]
fn prometheus_service_fn<T>(_req: T) -> PromServiceFuture {
use prometheus::{Encoder, TextEncoder};
let response = if let Some(exporter) = PROMETHEUS_EXPORTER.get() {
// We'll need some space for this, so we preallocate a bit
let mut buffer = Vec::with_capacity(1024);
let response = if let Some(registry) = PROMETHEUS_REGISTRY.get() {
let mut buffer = Vec::new();
let encoder = TextEncoder::new();
let metric_families = registry.gather();
if let Err(err) = exporter.export(&mut buffer) {
tracing::error!(
error = &err as &dyn std::error::Error,
"Failed to export Prometheus metrics"
);
// That shouldn't panic, unless we're constructing invalid labels
encoder.encode(&metric_families, &mut buffer).unwrap();
Response::builder()
.status(200)
.header(CONTENT_TYPE, encoder.format_type())
.body(Full::new(Bytes::from(buffer)))
.unwrap()
Response::builder()
.status(500)
.header(CONTENT_TYPE, "text/plain")
.body(Full::new(Bytes::from_static(
b"Failed to export Prometheus metrics, see logs for details",
)))
.unwrap()
} else {
Response::builder()
.status(200)
.header(CONTENT_TYPE, "text/plain;version=1.0.0")
.body(Full::new(Bytes::from(buffer)))
.unwrap()
}
} else {
Response::builder()
.status(500)
@@ -209,7 +232,7 @@ fn prometheus_service_fn<T>(_req: T) -> PromServiceFuture {
}
pub fn prometheus_service<T>() -> tower::util::ServiceFn<fn(T) -> PromServiceFuture> {
if PROMETHEUS_REGISTRY.get().is_none() {
if PROMETHEUS_EXPORTER.get().is_none() {
tracing::warn!(
"A Prometheus resource was mounted on a listener, but the Prometheus exporter was not setup in the config"
);
@@ -219,16 +242,11 @@ pub fn prometheus_service<T>() -> tower::util::ServiceFn<fn(T) -> PromServiceFut
}
fn prometheus_metric_reader() -> anyhow::Result<PrometheusExporter> {
let registry = Registry::new();
let exporter = PrometheusExporter::builder().without_scope_info().build();
PROMETHEUS_REGISTRY
.set(registry.clone())
.map_err(|_| anyhow::anyhow!("PROMETHEUS_REGISTRY was set twice"))?;
let exporter = opentelemetry_prometheus::exporter()
.with_registry(registry)
.without_scope_info()
.build()?;
PROMETHEUS_EXPORTER
.set(exporter.clone())
.map_err(|_| anyhow::anyhow!("PROMETHEUS_EXPORTER was set twice"))?;
Ok(exporter)
}

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use opentelemetry::KeyValue;
use tokio::runtime::RuntimeMetrics;
@@ -9,7 +9,6 @@ use tokio::runtime::RuntimeMetrics;
use super::METER;
/// Install metrics for the tokio runtime.
#[allow(clippy::too_many_lines)]
pub fn observe(metrics: RuntimeMetrics) {
{
let metrics = metrics.clone();

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{sync::Arc, time::Duration};
@@ -17,7 +17,7 @@ use mas_data_model::{SessionExpirationConfig, SiteConfig};
use mas_email::{MailTransport, Mailer};
use mas_handlers::passwords::PasswordManager;
use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection};
use mas_matrix_synapse::SynapseConnection;
use mas_matrix_synapse::{LegacySynapseConnection, SynapseConnection};
use mas_policy::PolicyFactory;
use mas_router::UrlBuilder;
use mas_storage::{BoxRepositoryFactory, RepositoryAccess, RepositoryFactory};
@@ -211,6 +211,7 @@ pub fn site_config_from_config(
password_login_enabled: password_config.enabled(),
password_registration_enabled: password_config.enabled()
&& account_config.password_registration_enabled,
password_registration_email_required: account_config.password_registration_email_required,
registration_token_required: account_config.registration_token_required,
email_change_allowed: account_config.email_change_allowed,
displayname_change_allowed: account_config.displayname_change_allowed,
@@ -231,6 +232,7 @@ pub async fn templates_from_config(
config: &TemplatesConfig,
site_config: &SiteConfig,
url_builder: &UrlBuilder,
strict: bool,
) -> Result<Templates, anyhow::Error> {
Templates::load(
config.path.clone(),
@@ -239,6 +241,7 @@ pub async fn templates_from_config(
config.translations_path.clone(),
site_config.templates_branding(),
site_config.templates_features(),
strict,
)
.await
.with_context(|| format!("Failed to load the templates at {}", config.path))
@@ -464,28 +467,36 @@ pub async fn load_policy_factory_dynamic_data(
/// Create a clonable, type-erased [`HomeserverConnection`] from the
/// configuration
pub fn homeserver_connection_from_config(
pub async fn homeserver_connection_from_config(
config: &MatrixConfig,
http_client: reqwest::Client,
) -> Arc<dyn HomeserverConnection> {
match config.kind {
HomeserverKind::Synapse => Arc::new(SynapseConnection::new(
) -> anyhow::Result<Arc<dyn HomeserverConnection>> {
Ok(match config.kind {
HomeserverKind::Synapse | HomeserverKind::SynapseModern => {
Arc::new(SynapseConnection::new(
config.homeserver.clone(),
config.endpoint.clone(),
config.secret().await?,
http_client,
))
}
HomeserverKind::SynapseLegacy => Arc::new(LegacySynapseConnection::new(
config.homeserver.clone(),
config.endpoint.clone(),
config.secret.clone(),
config.secret().await?,
http_client,
)),
HomeserverKind::SynapseReadOnly => {
let connection = SynapseConnection::new(
config.homeserver.clone(),
config.endpoint.clone(),
config.secret.clone(),
config.secret().await?,
http_client,
);
let readonly = ReadOnlyHomeserverConnection::new(connection);
Arc::new(readonly)
}
}
})
}
#[cfg(test)]

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[package]
name = "mas-config"
version.workspace = true

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use schemars::{
generate::SchemaSettings,

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
#![deny(missing_docs, rustdoc::missing_crate_level_docs)]
#![allow(clippy::module_name_repetitions)]

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Useful JSON Schema definitions

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -50,6 +50,13 @@ pub struct AccountConfig {
#[serde(default = "default_false", skip_serializing_if = "is_default_false")]
pub password_registration_enabled: bool,
/// Whether self-service password registrations require a valid email.
/// Defaults to `true`.
///
/// This has no effect if password registration is disabled.
#[serde(default = "default_true", skip_serializing_if = "is_default_true")]
pub password_registration_email_required: bool,
/// Whether users are allowed to change their passwords. Defaults to `true`.
///
/// This has no effect if password login is disabled.
@@ -89,6 +96,7 @@ impl Default for AccountConfig {
email_change_allowed: default_true(),
displayname_change_allowed: default_true(),
password_registration_enabled: default_false(),
password_registration_email_required: default_true(),
password_change_allowed: default_true(),
password_recovery_enabled: default_false(),
account_deactivation_allowed: default_true(),

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de::Error};
@@ -51,7 +51,10 @@ impl CaptchaConfig {
impl ConfigurationSection for CaptchaConfig {
const PATH: Option<&'static str> = Some("captcha");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let metadata = figment.find_metadata(Self::PATH.unwrap());
let error_on_field = |mut error: figment::error::Error, field: &'static str| {
@@ -67,11 +70,11 @@ impl ConfigurationSection for CaptchaConfig {
if let Some(CaptchaServiceKind::RecaptchaV2) = self.service {
if self.site_key.is_none() {
return Err(missing_field("site_key"));
return Err(missing_field("site_key").into());
}
if self.secret_key.is_none() {
return Err(missing_field("secret_key"));
return Err(missing_field("secret_key").into());
}
}

View File

@@ -1,16 +1,18 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::ops::Deref;
use figment::Figment;
use anyhow::bail;
use camino::Utf8PathBuf;
use mas_iana::oauth::OAuthClientAuthenticationMethod;
use mas_jose::jwk::PublicJsonWebKeySet;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de::Error};
use serde_with::serde_as;
use ulid::Ulid;
use url::Url;
@@ -29,6 +31,66 @@ impl From<PublicJsonWebKeySet> for JwksOrJwksUri {
}
}
/// Client secret config option.
///
/// It either holds the client secret value directly or references a file where
/// the client secret is stored.
#[derive(Clone, Debug)]
pub enum ClientSecret {
File(Utf8PathBuf),
Value(String),
}
/// Client secret fields as serialized in JSON.
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
struct ClientSecretRaw {
/// Path to the file containing the client secret. The client secret is used
/// by the `client_secret_basic`, `client_secret_post` and
/// `client_secret_jwt` authentication methods.
#[schemars(with = "Option<String>")]
#[serde(skip_serializing_if = "Option::is_none")]
client_secret_file: Option<Utf8PathBuf>,
/// Alternative to `client_secret_file`: Reads the client secret directly
/// from the config.
#[serde(skip_serializing_if = "Option::is_none")]
client_secret: Option<String>,
}
impl TryFrom<ClientSecretRaw> for Option<ClientSecret> {
type Error = anyhow::Error;
fn try_from(value: ClientSecretRaw) -> Result<Self, Self::Error> {
match (value.client_secret, value.client_secret_file) {
(None, None) => Ok(None),
(None, Some(path)) => Ok(Some(ClientSecret::File(path))),
(Some(client_secret), None) => Ok(Some(ClientSecret::Value(client_secret))),
(Some(_), Some(_)) => {
bail!("Cannot specify both `client_secret` and `client_secret_file`")
}
}
}
}
impl From<Option<ClientSecret>> for ClientSecretRaw {
fn from(value: Option<ClientSecret>) -> Self {
match value {
Some(ClientSecret::File(path)) => ClientSecretRaw {
client_secret_file: Some(path),
client_secret: None,
},
Some(ClientSecret::Value(client_secret)) => ClientSecretRaw {
client_secret_file: None,
client_secret: Some(client_secret),
},
None => ClientSecretRaw {
client_secret_file: None,
client_secret: None,
},
}
}
}
/// Authentication method used by clients
#[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "snake_case")]
@@ -66,6 +128,7 @@ impl std::fmt::Display for ClientAuthMethodConfig {
}
/// An OAuth 2.0 client configuration
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ClientConfig {
/// The client ID
@@ -85,8 +148,10 @@ pub struct ClientConfig {
/// The client secret, used by the `client_secret_basic`,
/// `client_secret_post` and `client_secret_jwt` authentication methods
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[schemars(with = "ClientSecretRaw")]
#[serde_as(as = "serde_with::TryFromInto<ClientSecretRaw>")]
#[serde(flatten)]
pub client_secret: Option<ClientSecret>,
/// The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication
/// method. Mutually exclusive with `jwks_uri`
@@ -104,7 +169,7 @@ pub struct ClientConfig {
}
impl ClientConfig {
fn validate(&self) -> Result<(), figment::error::Error> {
fn validate(&self) -> Result<(), Box<figment::error::Error>> {
let auth_method = self.client_auth_method;
match self.client_auth_method {
ClientAuthMethodConfig::PrivateKeyJwt => {
@@ -112,20 +177,20 @@ impl ClientConfig {
let error = figment::error::Error::custom(
"jwks or jwks_uri is required for private_key_jwt",
);
return Err(error.with_path("client_auth_method"));
return Err(Box::new(error.with_path("client_auth_method")));
}
if self.jwks.is_some() && self.jwks_uri.is_some() {
let error =
figment::error::Error::custom("jwks and jwks_uri are mutually exclusive");
return Err(error.with_path("jwks"));
return Err(Box::new(error.with_path("jwks")));
}
if self.client_secret.is_some() {
let error = figment::error::Error::custom(
"client_secret is not allowed with private_key_jwt",
);
return Err(error.with_path("client_secret"));
return Err(Box::new(error.with_path("client_secret")));
}
}
@@ -136,21 +201,21 @@ impl ClientConfig {
let error = figment::error::Error::custom(format!(
"client_secret is required for {auth_method}"
));
return Err(error.with_path("client_auth_method"));
return Err(Box::new(error.with_path("client_auth_method")));
}
if self.jwks.is_some() {
let error = figment::error::Error::custom(format!(
"jwks is not allowed with {auth_method}"
));
return Err(error.with_path("jwks"));
return Err(Box::new(error.with_path("jwks")));
}
if self.jwks_uri.is_some() {
let error = figment::error::Error::custom(format!(
"jwks_uri is not allowed with {auth_method}"
));
return Err(error.with_path("jwks_uri"));
return Err(Box::new(error.with_path("jwks_uri")));
}
}
@@ -159,21 +224,21 @@ impl ClientConfig {
let error = figment::error::Error::custom(
"client_secret is not allowed with none authentication method",
);
return Err(error.with_path("client_secret"));
return Err(Box::new(error.with_path("client_secret")));
}
if self.jwks.is_some() {
let error = figment::error::Error::custom(
"jwks is not allowed with none authentication method",
);
return Err(error);
return Err(Box::new(error));
}
if self.jwks_uri.is_some() {
let error = figment::error::Error::custom(
"jwks_uri is not allowed with none authentication method",
);
return Err(error);
return Err(Box::new(error));
}
}
}
@@ -198,6 +263,21 @@ impl ClientConfig {
ClientAuthMethodConfig::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt,
}
}
/// Returns the client secret.
///
/// If `client_secret_file` was given, the secret is read from that file.
///
/// # Errors
///
/// Returns an error when the client secret could not be read from file.
pub async fn client_secret(&self) -> anyhow::Result<Option<String>> {
Ok(match &self.client_secret {
Some(ClientSecret::File(path)) => Some(tokio::fs::read_to_string(path).await?),
Some(ClientSecret::Value(client_secret)) => Some(client_secret.clone()),
None => None,
})
}
}
/// List of OAuth 2.0/OIDC clients config
@@ -232,7 +312,10 @@ impl IntoIterator for ClientsConfig {
impl ConfigurationSection for ClientsConfig {
const PATH: Option<&'static str> = Some("clients");
fn validate(&self, figment: &Figment) -> Result<(), figment::error::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
for (index, client) in self.0.iter().enumerate() {
client.validate().map_err(|mut err| {
// Save the error location information in the error
@@ -256,75 +339,91 @@ mod tests {
Figment, Jail,
providers::{Format, Yaml},
};
use tokio::{runtime::Handle, task};
use super::*;
#[test]
fn load_config() {
Jail::expect_with(|jail| {
jail.create_file(
"config.yaml",
r#"
clients:
- client_id: 01GFWR28C4KNE04WG3HKXB7C9R
client_auth_method: none
redirect_uris:
- https://exemple.fr/callback
#[tokio::test]
async fn load_config() {
task::spawn_blocking(|| {
Jail::expect_with(|jail| {
jail.create_file(
"config.yaml",
r#"
clients:
- client_id: 01GFWR28C4KNE04WG3HKXB7C9R
client_auth_method: none
redirect_uris:
- https://exemple.fr/callback
- client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6
client_auth_method: client_secret_basic
client_secret: hello
- client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6
client_auth_method: client_secret_basic
client_secret_file: secret
- client_id: 01GFWR3WHR93Y5HK389H28VHZ9
client_auth_method: client_secret_post
client_secret: hello
- client_id: 01GFWR3WHR93Y5HK389H28VHZ9
client_auth_method: client_secret_post
client_secret: c1!3n753c237
- client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG
client_auth_method: client_secret_jwt
client_secret: hello
- client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG
client_auth_method: client_secret_jwt
client_secret_file: secret
- client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR
client_auth_method: private_key_jwt
jwks:
keys:
- kid: "03e84aed4ef4431014e8617567864c4efaaaede9"
kty: "RSA"
alg: "RS256"
use: "sig"
e: "AQAB"
n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw"
- client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR
client_auth_method: private_key_jwt
jwks:
keys:
- kid: "03e84aed4ef4431014e8617567864c4efaaaede9"
kty: "RSA"
alg: "RS256"
use: "sig"
e: "AQAB"
n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw"
- kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567"
kty: "RSA"
alg: "RS256"
use: "sig"
e: "AQAB"
n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw"
"#,
)?;
- kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567"
kty: "RSA"
alg: "RS256"
use: "sig"
e: "AQAB"
n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw"
"#,
)?;
jail.create_file("secret", r"c1!3n753c237")?;
let config = Figment::new()
.merge(Yaml::file("config.yaml"))
.extract_inner::<ClientsConfig>("clients")?;
let config = Figment::new()
.merge(Yaml::file("config.yaml"))
.extract_inner::<ClientsConfig>("clients")?;
assert_eq!(config.0.len(), 5);
assert_eq!(config.0.len(), 5);
assert_eq!(
config.0[0].client_id,
Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap()
);
assert_eq!(
config.0[0].redirect_uris,
vec!["https://exemple.fr/callback".parse().unwrap()]
);
assert_eq!(
config.0[0].client_id,
Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap()
);
assert_eq!(
config.0[0].redirect_uris,
vec!["https://exemple.fr/callback".parse().unwrap()]
);
assert_eq!(
config.0[1].client_id,
Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap()
);
assert_eq!(config.0[1].redirect_uris, Vec::new());
assert_eq!(
config.0[1].client_id,
Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap()
);
assert_eq!(config.0[1].redirect_uris, Vec::new());
Ok(())
});
assert!(config.0[0].client_secret.is_none());
assert!(matches!(config.0[1].client_secret, Some(ClientSecret::File(ref p)) if p == "secret"));
assert!(matches!(config.0[2].client_secret, Some(ClientSecret::Value(ref v)) if v == "c1!3n753c237"));
assert!(matches!(config.0[3].client_secret, Some(ClientSecret::File(ref p)) if p == "secret"));
assert!(config.0[4].client_secret.is_none());
Handle::current().block_on(async move {
assert_eq!(config.0[1].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
assert_eq!(config.0[2].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
assert_eq!(config.0[3].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
});
Ok(())
});
}).await.unwrap();
}
}

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{num::NonZeroU32, time::Duration};
@@ -222,13 +222,16 @@ pub struct DatabaseConfig {
impl ConfigurationSection for DatabaseConfig {
const PATH: Option<&'static str> = Some("database");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::error::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let metadata = figment.find_metadata(Self::PATH.unwrap());
let annotate = |mut error: figment::Error| {
error.metadata = metadata.cloned();
error.profile = Some(figment::Profile::Default);
error.path = vec![Self::PATH.unwrap().to_owned()];
Err(error)
error
};
// Check that the user did not specify both `uri` and the split options at the
@@ -241,37 +244,41 @@ impl ConfigurationSection for DatabaseConfig {
|| self.database.is_some();
if self.uri.is_some() && has_split_options {
return annotate(figment::error::Error::from(
return Err(annotate(figment::error::Error::from(
"uri must not be specified if host, port, socket, username, password, or database are specified".to_owned(),
));
)).into());
}
if self.ssl_ca.is_some() && self.ssl_ca_file.is_some() {
return annotate(figment::error::Error::from(
return Err(annotate(figment::error::Error::from(
"ssl_ca must not be specified if ssl_ca_file is specified".to_owned(),
));
))
.into());
}
if self.ssl_certificate.is_some() && self.ssl_certificate_file.is_some() {
return annotate(figment::error::Error::from(
return Err(annotate(figment::error::Error::from(
"ssl_certificate must not be specified if ssl_certificate_file is specified"
.to_owned(),
));
))
.into());
}
if self.ssl_key.is_some() && self.ssl_key_file.is_some() {
return annotate(figment::error::Error::from(
return Err(annotate(figment::error::Error::from(
"ssl_key must not be specified if ssl_key_file is specified".to_owned(),
));
))
.into());
}
if (self.ssl_key.is_some() || self.ssl_key_file.is_some())
^ (self.ssl_certificate.is_some() || self.ssl_certificate_file.is_some())
{
return annotate(figment::error::Error::from(
return Err(annotate(figment::error::Error::from(
"both a ssl_certificate and a ssl_key must be set at the same time or none of them"
.to_owned(),
));
))
.into());
}
Ok(())

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
#![allow(deprecated)]
@@ -175,7 +175,10 @@ impl Default for EmailConfig {
impl ConfigurationSection for EmailConfig {
const PATH: Option<&'static str> = Some("email");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::error::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let metadata = figment.find_metadata(Self::PATH.unwrap());
let error_on_field = |mut error: figment::error::Error, field: &'static str| {
@@ -201,29 +204,29 @@ impl ConfigurationSection for EmailConfig {
EmailTransportKind::Smtp => {
if let Err(e) = Mailbox::from_str(&self.from) {
return Err(error_on_field(figment::error::Error::custom(e), "from"));
return Err(error_on_field(figment::error::Error::custom(e), "from").into());
}
if let Err(e) = Mailbox::from_str(&self.reply_to) {
return Err(error_on_field(figment::error::Error::custom(e), "reply_to"));
return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into());
}
match (self.username.is_some(), self.password.is_some()) {
(true, true) | (false, false) => {}
(true, false) => {
return Err(missing_field("password"));
return Err(missing_field("password").into());
}
(false, true) => {
return Err(missing_field("username"));
return Err(missing_field("username").into());
}
}
if self.mode.is_none() {
return Err(missing_field("mode"));
return Err(missing_field("mode").into());
}
if self.hostname.is_none() {
return Err(missing_field("hostname"));
return Err(missing_field("hostname").into());
}
if self.command.is_some() {
@@ -239,7 +242,8 @@ impl ConfigurationSection for EmailConfig {
"username",
"password",
],
));
)
.into());
}
}
@@ -247,35 +251,35 @@ impl ConfigurationSection for EmailConfig {
let expected_fields = &["from", "reply_to", "transport", "command"];
if let Err(e) = Mailbox::from_str(&self.from) {
return Err(error_on_field(figment::error::Error::custom(e), "from"));
return Err(error_on_field(figment::error::Error::custom(e), "from").into());
}
if let Err(e) = Mailbox::from_str(&self.reply_to) {
return Err(error_on_field(figment::error::Error::custom(e), "reply_to"));
return Err(error_on_field(figment::error::Error::custom(e), "reply_to").into());
}
if self.command.is_none() {
return Err(missing_field("command"));
return Err(missing_field("command").into());
}
if self.mode.is_some() {
return Err(unexpected_field("mode", expected_fields));
return Err(unexpected_field("mode", expected_fields).into());
}
if self.hostname.is_some() {
return Err(unexpected_field("hostname", expected_fields));
return Err(unexpected_field("hostname", expected_fields).into());
}
if self.port.is_some() {
return Err(unexpected_field("port", expected_fields));
return Err(unexpected_field("port", expected_fields).into());
}
if self.username.is_some() {
return Err(unexpected_field("username", expected_fields));
return Err(unexpected_field("username", expected_fields).into());
}
if self.password.is_some() {
return Err(unexpected_field("password", expected_fields));
return Err(unexpected_field("password", expected_fields).into());
}
}
}

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use chrono::Duration;
use schemars::JsonSchema;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
#![allow(deprecated)]
@@ -400,7 +400,10 @@ impl Default for HttpConfig {
impl ConfigurationSection for HttpConfig {
const PATH: Option<&'static str> = Some("http");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
for (index, listener) in self.listeners.iter().enumerate() {
let annotate = |mut error: figment::Error| {
error.metadata = figment
@@ -412,49 +415,57 @@ impl ConfigurationSection for HttpConfig {
"listeners".to_owned(),
index.to_string(),
];
Err(error)
error
};
if listener.resources.is_empty() {
return annotate(figment::Error::from("listener has no resources".to_owned()));
return Err(
annotate(figment::Error::from("listener has no resources".to_owned())).into(),
);
}
if listener.binds.is_empty() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"listener does not bind to any address".to_owned(),
));
))
.into());
}
if let Some(tls_config) = &listener.tls {
if tls_config.certificate.is_some() && tls_config.certificate_file.is_some() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"Only one of `certificate` or `certificate_file` can be set at a time"
.to_owned(),
));
))
.into());
}
if tls_config.certificate.is_none() && tls_config.certificate_file.is_none() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"TLS configuration is missing a certificate".to_owned(),
));
))
.into());
}
if tls_config.key.is_some() && tls_config.key_file.is_some() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"Only one of `key` or `key_file` can be set at a time".to_owned(),
));
))
.into());
}
if tls_config.key.is_none() && tls_config.key_file.is_none() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"TLS configuration is missing a private key".to_owned(),
));
))
.into());
}
if tls_config.password.is_some() && tls_config.password_file.is_some() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"Only one of `password` or `password_file` can be set at a time".to_owned(),
));
))
.into());
}
}
}

View File

@@ -1,9 +1,11 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use anyhow::bail;
use camino::Utf8PathBuf;
use rand::{
Rng,
distributions::{Alphanumeric, DistString},
@@ -27,15 +29,69 @@ fn default_endpoint() -> Url {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum HomeserverKind {
/// Homeserver is Synapse
/// Homeserver is Synapse, version 1.135.0 or newer
#[default]
Synapse,
/// Homeserver is Synapse, in read-only mode
/// Homeserver is Synapse, version 1.135.0 or newer, in read-only mode
///
/// This is meant for testing rolling out Matrix Authentication Service with
/// no risk of writing data to the homeserver.
SynapseReadOnly,
/// Homeserver is Synapse, using the legacy API
SynapseLegacy,
/// Homeserver is Synapse, with the modern API available (>= 1.135.0)
SynapseModern,
}
/// Shared secret between MAS and the homeserver.
///
/// It either holds the secret value directly or references a file where the
/// secret is stored.
#[derive(Clone, Debug)]
pub enum Secret {
File(Utf8PathBuf),
Value(String),
}
/// Secret fields as serialized in JSON.
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
struct SecretRaw {
#[schemars(with = "Option<String>")]
#[serde(skip_serializing_if = "Option::is_none")]
secret_file: Option<Utf8PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
secret: Option<String>,
}
impl TryFrom<SecretRaw> for Secret {
type Error = anyhow::Error;
fn try_from(value: SecretRaw) -> Result<Self, Self::Error> {
match (value.secret, value.secret_file) {
(None, None) => bail!("Missing `secret` or `secret_file`"),
(None, Some(path)) => Ok(Secret::File(path)),
(Some(secret), None) => Ok(Secret::Value(secret)),
(Some(_), Some(_)) => bail!("Cannot specify both `secret` and `secret_file`"),
}
}
}
impl From<Secret> for SecretRaw {
fn from(value: Secret) -> Self {
match value {
Secret::File(path) => SecretRaw {
secret_file: Some(path),
secret: None,
},
Secret::Value(secret) => SecretRaw {
secret_file: None,
secret: Some(secret),
},
}
}
}
/// Configuration related to the Matrix homeserver
@@ -51,7 +107,10 @@ pub struct MatrixConfig {
pub homeserver: String,
/// Shared secret to use for calls to the admin API
pub secret: String,
#[schemars(with = "SecretRaw")]
#[serde_as(as = "serde_with::TryFromInto<SecretRaw>")]
#[serde(flatten)]
pub secret: Secret,
/// The base URL of the homeserver's client API
#[serde(default = "default_endpoint")]
@@ -63,6 +122,24 @@ impl ConfigurationSection for MatrixConfig {
}
impl MatrixConfig {
/// Returns the shared secret.
///
/// If `secret_file` was given, the secret is read from that file.
///
/// # Errors
///
/// Returns an error when the shared secret could not be read from file.
pub async fn secret(&self) -> anyhow::Result<String> {
Ok(match &self.secret {
Secret::File(path) => {
let raw = tokio::fs::read_to_string(path).await?;
// Trim the secret when read from file to match Synapse's behaviour
raw.trim().to_string()
}
Secret::Value(secret) => secret.clone(),
})
}
pub(crate) fn generate<R>(mut rng: R) -> Self
where
R: Rng + Send,
@@ -70,7 +147,7 @@ impl MatrixConfig {
Self {
kind: HomeserverKind::default(),
homeserver: default_homeserver(),
secret: Alphanumeric.sample_string(&mut rng, 32),
secret: Secret::Value(Alphanumeric.sample_string(&mut rng, 32)),
endpoint: default_endpoint(),
}
}
@@ -79,7 +156,7 @@ impl MatrixConfig {
Self {
kind: HomeserverKind::default(),
homeserver: default_homeserver(),
secret: "test".to_owned(),
secret: Secret::Value("test".to_owned()),
endpoint: default_endpoint(),
}
}
@@ -91,29 +168,68 @@ mod tests {
Figment, Jail,
providers::{Format, Yaml},
};
use tokio::{runtime::Handle, task};
use super::*;
#[test]
fn load_config() {
Jail::expect_with(|jail| {
jail.create_file(
"config.yaml",
r"
matrix:
homeserver: matrix.org
secret: test
",
)?;
#[tokio::test]
async fn load_config() {
task::spawn_blocking(|| {
Jail::expect_with(|jail| {
jail.create_file(
"config.yaml",
r"
matrix:
homeserver: matrix.org
secret_file: secret
",
)?;
jail.create_file("secret", r"m472!x53c237")?;
let config = Figment::new()
.merge(Yaml::file("config.yaml"))
.extract_inner::<MatrixConfig>("matrix")?;
let config = Figment::new()
.merge(Yaml::file("config.yaml"))
.extract_inner::<MatrixConfig>("matrix")?;
assert_eq!(&config.homeserver, "matrix.org");
assert_eq!(&config.secret, "test");
Handle::current().block_on(async move {
assert_eq!(&config.homeserver, "matrix.org");
assert!(matches!(config.secret, Secret::File(ref p) if p == "secret"));
assert_eq!(config.secret().await.unwrap(), "m472!x53c237");
});
Ok(())
});
Ok(())
});
})
.await
.unwrap();
}
#[tokio::test]
async fn load_config_inline_secrets() {
task::spawn_blocking(|| {
Jail::expect_with(|jail| {
jail.create_file(
"config.yaml",
r"
matrix:
homeserver: matrix.org
secret: m472!x53c237
",
)?;
let config = Figment::new()
.merge(Yaml::file("config.yaml"))
.extract_inner::<MatrixConfig>("matrix")?;
Handle::current().block_on(async move {
assert_eq!(&config.homeserver, "matrix.org");
assert!(matches!(config.secret, Secret::Value(ref v) if v == "m472!x53c237"));
assert_eq!(config.secret().await.unwrap(), "m472!x53c237");
});
Ok(())
});
})
.await
.unwrap();
}
}

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use rand::Rng;
use schemars::JsonSchema;
@@ -52,7 +52,9 @@ pub use self::{
upstream_oauth2::{
ClaimsImports as UpstreamOAuth2ClaimsImports, DiscoveryMode as UpstreamOAuth2DiscoveryMode,
EmailImportPreference as UpstreamOAuth2EmailImportPreference,
ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod,
ImportAction as UpstreamOAuth2ImportAction,
OnBackchannelLogout as UpstreamOAuth2OnBackchannelLogout,
OnConflict as UpstreamOAuth2OnConflict, PkceMethod as UpstreamOAuth2PkceMethod,
Provider as UpstreamOAuth2Provider, ResponseMode as UpstreamOAuth2ResponseMode,
TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config,
},
@@ -128,7 +130,10 @@ pub struct RootConfig {
}
impl ConfigurationSection for RootConfig {
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
self.clients.validate(figment)?;
self.http.validate(figment)?;
self.database.validate(figment)?;
@@ -247,7 +252,10 @@ pub struct AppConfig {
}
impl ConfigurationSection for AppConfig {
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
self.http.validate(figment)?;
self.database.validate(figment)?;
self.templates.validate(figment)?;
@@ -283,7 +291,10 @@ pub struct SyncConfig {
}
impl ConfigurationSection for SyncConfig {
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
self.database.validate(figment)?;
self.secrets.validate(figment)?;
self.clients.validate(figment)?;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::cmp::Reverse;
@@ -72,12 +72,15 @@ impl Default for PasswordsConfig {
impl ConfigurationSection for PasswordsConfig {
const PATH: Option<&'static str> = Some("passwords");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let annotate = |mut error: figment::Error| {
error.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned();
error.profile = Some(figment::Profile::Default);
error.path = vec![Self::PATH.unwrap().to_owned()];
Err(error)
error
};
if !self.enabled {
@@ -86,16 +89,18 @@ impl ConfigurationSection for PasswordsConfig {
}
if self.schemes.is_empty() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"Requires at least one password scheme in the config".to_owned(),
));
))
.into());
}
for scheme in &self.schemes {
if scheme.secret.is_some() && scheme.secret_file.is_some() {
return annotate(figment::Error::from(
return Err(annotate(figment::Error::from(
"Cannot specify both `secret` and `secret_file`".to_owned(),
));
))
.into());
}
}

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use camino::Utf8PathBuf;
use schemars::JsonSchema;

View File

@@ -1,8 +1,8 @@
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{num::NonZeroU32, time::Duration};
@@ -117,7 +117,10 @@ pub struct RateLimiterConfiguration {
impl ConfigurationSection for RateLimitingConfig {
const PATH: Option<&'static str> = Some("rate_limiting");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let metadata = figment.find_metadata(Self::PATH.unwrap());
let error_on_field = |mut error: figment::error::Error, field: &'static str| {
@@ -154,25 +157,21 @@ impl ConfigurationSection for RateLimitingConfig {
};
if let Some(error) = error_on_limiter(&self.account_recovery.per_ip) {
return Err(error_on_nested_field(error, "account_recovery", "per_ip"));
return Err(error_on_nested_field(error, "account_recovery", "per_ip").into());
}
if let Some(error) = error_on_limiter(&self.account_recovery.per_address) {
return Err(error_on_nested_field(
error,
"account_recovery",
"per_address",
));
return Err(error_on_nested_field(error, "account_recovery", "per_address").into());
}
if let Some(error) = error_on_limiter(&self.registration) {
return Err(error_on_field(error, "registration"));
return Err(error_on_field(error, "registration").into());
}
if let Some(error) = error_on_limiter(&self.login.per_ip) {
return Err(error_on_nested_field(error, "login", "per_ip"));
return Err(error_on_nested_field(error, "login", "per_ip").into());
}
if let Some(error) = error_on_limiter(&self.login.per_account) {
return Err(error_on_nested_field(error, "login", "per_account"));
return Err(error_on_nested_field(error, "login", "per_account").into());
}
Ok(())

View File

@@ -1,21 +1,17 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::borrow::Cow;
use anyhow::{Context, bail};
use camino::Utf8PathBuf;
use futures_util::future::{try_join, try_join_all};
use mas_jose::jwk::{JsonWebKey, JsonWebKeySet};
use mas_jose::jwk::{JsonWebKey, JsonWebKeySet, Thumbprint};
use mas_keystore::{Encrypter, Keystore, PrivateKey};
use rand::{
Rng, SeedableRng,
distributions::{Alphanumeric, DistString, Standard},
prelude::Distribution as _,
};
use rand::{Rng, SeedableRng, distributions::Standard, prelude::Distribution as _};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
@@ -128,7 +124,11 @@ impl From<Key> for KeyRaw {
#[serde_as]
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
pub struct KeyConfig {
kid: String,
/// The key ID `kid` of the key as used by JWKs.
///
/// If not given, `kid` will be the keys RFC 7638 JWK Thumbprint.
#[serde(skip_serializing_if = "Option::is_none")]
kid: Option<String>,
#[schemars(with = "PasswordRaw")]
#[serde_as(as = "serde_with::TryFromInto<PasswordRaw>")]
@@ -145,10 +145,10 @@ impl KeyConfig {
/// Returns the password in case any is provided.
///
/// If `password_file` was given, the password is read from that file.
async fn password(&self) -> anyhow::Result<Option<Cow<String>>> {
async fn password(&self) -> anyhow::Result<Option<Cow<'_, [u8]>>> {
Ok(match &self.password {
Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read_to_string(path).await?)),
Some(Password::Value(password)) => Some(Cow::Borrowed(password)),
Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read(path).await?)),
Some(Password::Value(password)) => Some(Cow::Borrowed(password.as_bytes())),
None => None,
})
}
@@ -156,10 +156,10 @@ impl KeyConfig {
/// Returns the key.
///
/// If `key_file` was given, the key is read from that file.
async fn key(&self) -> anyhow::Result<Cow<String>> {
async fn key(&self) -> anyhow::Result<Cow<'_, [u8]>> {
Ok(match &self.key {
Key::File(path) => Cow::Owned(tokio::fs::read_to_string(path).await?),
Key::Value(key) => Cow::Borrowed(key),
Key::File(path) => Cow::Owned(tokio::fs::read(path).await?),
Key::Value(key) => Cow::Borrowed(key.as_bytes()),
})
}
@@ -170,12 +170,17 @@ impl KeyConfig {
let (key, password) = try_join(self.key(), self.password()).await?;
let private_key = match password {
Some(password) => PrivateKey::load_encrypted(key.as_bytes(), password.as_bytes())?,
None => PrivateKey::load(key.as_bytes())?,
Some(password) => PrivateKey::load_encrypted(&key, password)?,
None => PrivateKey::load(&key)?,
};
let kid = match self.kid.clone() {
Some(kid) => kid,
None => private_key.thumbprint_sha256_base64(),
};
Ok(JsonWebKey::new(private_key)
.with_kid(self.kid.clone())
.with_kid(kid)
.with_use(mas_iana::jose::JsonWebKeyUse::Sig))
}
}
@@ -299,6 +304,7 @@ impl ConfigurationSection for SecretsConfig {
}
impl SecretsConfig {
#[expect(clippy::similar_names, reason = "Key type names are very similar")]
#[tracing::instrument(skip_all)]
pub(crate) async fn generate<R>(mut rng: R) -> anyhow::Result<Self>
where
@@ -317,7 +323,7 @@ impl SecretsConfig {
.await
.context("could not join blocking task")?;
let rsa_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
@@ -333,7 +339,7 @@ impl SecretsConfig {
.await
.context("could not join blocking task")?;
let ec_p256_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
@@ -343,13 +349,13 @@ impl SecretsConfig {
let ec_p384_key = task::spawn_blocking(move || {
let _entered = span.enter();
let ret = PrivateKey::generate_ec_p384(key_rng);
info!("Done generating EC P-256 key");
info!("Done generating EC P-384 key");
ret
})
.await
.context("could not join blocking task")?;
let ec_p384_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
@@ -365,7 +371,7 @@ impl SecretsConfig {
.await
.context("could not join blocking task")?;
let ec_k256_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
@@ -378,7 +384,7 @@ impl SecretsConfig {
pub(crate) fn test() -> Self {
let rsa_key = KeyConfig {
kid: "abcdef".to_owned(),
kid: None,
password: None,
key: Key::Value(
indoc::indoc! {r"
@@ -397,7 +403,7 @@ impl SecretsConfig {
),
};
let ecdsa_key = KeyConfig {
kid: "ghijkl".to_owned(),
kid: None,
password: None,
key: Key::Value(
indoc::indoc! {r"
@@ -417,3 +423,68 @@ impl SecretsConfig {
}
}
}
#[cfg(test)]
mod tests {
use figment::{
Figment, Jail,
providers::{Format, Yaml},
};
use mas_jose::constraints::Constrainable;
use tokio::{runtime::Handle, task};
use super::*;
#[tokio::test]
async fn load_config_inline_secrets() {
task::spawn_blocking(|| {
Jail::expect_with(|jail| {
jail.create_file(
"config.yaml",
indoc::indoc! {r"
secrets:
encryption: >-
0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff
keys:
- kid: lekid0
key: |
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49
AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl
fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw==
-----END EC PRIVATE KEY-----
- key: |
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
h27LAir5RqxByHvua2XsP46rSTChof78uw==
-----END EC PRIVATE KEY-----
"},
)?;
let config = Figment::new()
.merge(Yaml::file("config.yaml"))
.extract_inner::<SecretsConfig>("secrets")?;
Handle::current().block_on(async move {
assert_eq!(
config.encryption().await.unwrap(),
[
0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
255
]
);
let key_store = config.key_store().await.unwrap();
assert!(key_store.iter().any(|k| k.kid() == Some("lekid0")));
assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
});
Ok(())
});
})
.await
.unwrap();
}
}

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de::Error as _};
@@ -182,32 +182,38 @@ impl TelemetryConfig {
impl ConfigurationSection for TelemetryConfig {
const PATH: Option<&'static str> = Some("telemetry");
fn validate(&self, _figment: &figment::Figment) -> Result<(), figment::Error> {
if let Some(sample_rate) = self.sentry.sample_rate {
if !(0.0..=1.0).contains(&sample_rate) {
return Err(figment::error::Error::custom(
"Sentry sample rate must be between 0.0 and 1.0",
)
.with_path("sentry.sample_rate"));
}
fn validate(
&self,
_figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
if let Some(sample_rate) = self.sentry.sample_rate
&& !(0.0..=1.0).contains(&sample_rate)
{
return Err(figment::error::Error::custom(
"Sentry sample rate must be between 0.0 and 1.0",
)
.with_path("sentry.sample_rate")
.into());
}
if let Some(sample_rate) = self.sentry.traces_sample_rate {
if !(0.0..=1.0).contains(&sample_rate) {
return Err(figment::error::Error::custom(
"Sentry sample rate must be between 0.0 and 1.0",
)
.with_path("sentry.traces_sample_rate"));
}
if let Some(sample_rate) = self.sentry.traces_sample_rate
&& !(0.0..=1.0).contains(&sample_rate)
{
return Err(figment::error::Error::custom(
"Sentry sample rate must be between 0.0 and 1.0",
)
.with_path("sentry.traces_sample_rate")
.into());
}
if let Some(sample_rate) = self.tracing.sample_rate {
if !(0.0..=1.0).contains(&sample_rate) {
return Err(figment::error::Error::custom(
"Tracing sample rate must be between 0.0 and 1.0",
)
.with_path("tracing.sample_rate"));
}
if let Some(sample_rate) = self.tracing.sample_rate
&& !(0.0..=1.0).contains(&sample_rate)
{
return Err(figment::error::Error::custom(
"Tracing sample rate must be between 0.0 and 1.0",
)
.with_path("tracing.sample_rate")
.into());
}
Ok(())

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use camino::Utf8PathBuf;
use schemars::JsonSchema;

View File

@@ -1,8 +1,8 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::collections::BTreeMap;
@@ -33,7 +33,10 @@ impl UpstreamOAuth2Config {
impl ConfigurationSection for UpstreamOAuth2Config {
const PATH: Option<&'static str> = Some("upstream_oauth2");
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
fn validate(
&self,
figment: &figment::Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
for (index, provider) in self.providers.iter().enumerate() {
let annotate = |mut error: figment::Error| {
error.metadata = figment
@@ -45,15 +48,16 @@ impl ConfigurationSection for UpstreamOAuth2Config {
"providers".to_owned(),
index.to_string(),
];
Err(error)
error
};
if !matches!(provider.discovery_mode, DiscoveryMode::Disabled)
&& provider.issuer.is_none()
{
return annotate(figment::Error::custom(
return Err(annotate(figment::Error::custom(
"The `issuer` field is required when discovery is enabled",
));
))
.into());
}
match provider.token_endpoint_auth_method {
@@ -61,16 +65,16 @@ impl ConfigurationSection for UpstreamOAuth2Config {
| TokenAuthMethod::PrivateKeyJwt
| TokenAuthMethod::SignInWithApple => {
if provider.client_secret.is_some() {
return annotate(figment::Error::custom(
return Err(annotate(figment::Error::custom(
"Unexpected field `client_secret` for the selected authentication method",
));
)).into());
}
}
TokenAuthMethod::ClientSecretBasic
| TokenAuthMethod::ClientSecretPost
| TokenAuthMethod::ClientSecretJwt => {
if provider.client_secret.is_none() {
return annotate(figment::Error::missing_field("client_secret"));
return Err(annotate(figment::Error::missing_field("client_secret")).into());
}
}
}
@@ -81,16 +85,17 @@ impl ConfigurationSection for UpstreamOAuth2Config {
| TokenAuthMethod::ClientSecretPost
| TokenAuthMethod::SignInWithApple => {
if provider.token_endpoint_auth_signing_alg.is_some() {
return annotate(figment::Error::custom(
return Err(annotate(figment::Error::custom(
"Unexpected field `token_endpoint_auth_signing_alg` for the selected authentication method",
));
)).into());
}
}
TokenAuthMethod::ClientSecretJwt | TokenAuthMethod::PrivateKeyJwt => {
if provider.token_endpoint_auth_signing_alg.is_none() {
return annotate(figment::Error::missing_field(
return Err(annotate(figment::Error::missing_field(
"token_endpoint_auth_signing_alg",
));
))
.into());
}
}
}
@@ -98,18 +103,32 @@ impl ConfigurationSection for UpstreamOAuth2Config {
match provider.token_endpoint_auth_method {
TokenAuthMethod::SignInWithApple => {
if provider.sign_in_with_apple.is_none() {
return annotate(figment::Error::missing_field("sign_in_with_apple"));
return Err(
annotate(figment::Error::missing_field("sign_in_with_apple")).into(),
);
}
}
_ => {
if provider.sign_in_with_apple.is_some() {
return annotate(figment::Error::custom(
return Err(annotate(figment::Error::custom(
"Unexpected field `sign_in_with_apple` for the selected authentication method",
));
)).into());
}
}
}
if matches!(
provider.claims_imports.localpart.on_conflict,
OnConflict::Add
) && !matches!(
provider.claims_imports.localpart.action,
ImportAction::Force | ImportAction::Require
) {
return Err(annotate(figment::Error::custom(
"The field `action` must be either `force` or `require` when `on_conflict` is set to `add`",
)).into());
}
}
Ok(())
@@ -183,6 +202,26 @@ impl ImportAction {
}
}
/// How to handle an existing localpart claim
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OnConflict {
/// Fails the sso login on conflict
#[default]
Fail,
/// Adds the oauth identity link, regardless of whether there is an existing
/// link or not
Add,
}
impl OnConflict {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_default(&self) -> bool {
matches!(self, OnConflict::Fail)
}
}
/// What should be done for the subject attribute
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub struct SubjectImportPreference {
@@ -211,6 +250,10 @@ pub struct LocalpartImportPreference {
/// If not provided, the default template is `{{ user.preferred_username }}`
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
/// How to handle conflicts on the claim, default value is `Fail`
#[serde(default, skip_serializing_if = "OnConflict::is_default")]
pub on_conflict: OnConflict,
}
impl LocalpartImportPreference {
@@ -408,6 +451,29 @@ fn is_default_scope(scope: &str) -> bool {
scope == default_scope()
}
/// What to do when receiving an OIDC Backchannel logout request.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum OnBackchannelLogout {
/// Do nothing
#[default]
DoNothing,
/// Only log out the MAS 'browser session' started by this OIDC session
LogoutBrowserOnly,
/// Log out all sessions started by this OIDC session, including MAS
/// 'browser sessions' and client sessions
LogoutAll,
}
impl OnBackchannelLogout {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_default(&self) -> bool {
matches!(self, OnBackchannelLogout::DoNothing)
}
}
/// Configuration for one upstream OAuth 2 provider.
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -583,4 +649,10 @@ pub struct Provider {
/// Defaults to `false`.
#[serde(default)]
pub forward_login_hint: bool,
/// What to do when receiving an OIDC Backchannel logout request.
///
/// Defaults to `do_nothing`.
#[serde(default, skip_serializing_if = "OnBackchannelLogout::is_default")]
pub on_backchannel_logout: OnBackchannelLogout,
}

View File

@@ -1,10 +1,10 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use figment::{Figment, error::Error as FigmentError};
use figment::Figment;
use serde::de::DeserializeOwned;
/// Trait implemented by all configuration section to help loading specific part
@@ -18,7 +18,10 @@ pub trait ConfigurationSection: Sized + DeserializeOwned {
/// # Errors
///
/// Returns an error if the configuration is invalid
fn validate(&self, _figment: &Figment) -> Result<(), FigmentError> {
fn validate(
&self,
_figment: &Figment,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
Ok(())
}
@@ -27,7 +30,9 @@ pub trait ConfigurationSection: Sized + DeserializeOwned {
/// # Errors
///
/// Returns an error if the configuration could not be loaded
fn extract(figment: &Figment) -> Result<Self, FigmentError> {
fn extract(
figment: &Figment,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync + 'static>> {
let this: Self = if let Some(path) = Self::PATH {
figment.extract_inner(path)?
} else {
@@ -49,7 +54,9 @@ pub trait ConfigurationSectionExt: ConfigurationSection + Default {
/// # Errors
///
/// Returns an error if the configuration section is invalid.
fn extract_or_default(figment: &Figment) -> Result<Self, figment::Error> {
fn extract_or_default(
figment: &Figment,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync + 'static>> {
let this: Self = if let Some(path) = Self::PATH {
// If the configuration section is not present, we return the default value
if !figment.contains(path) {

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[package]
name = "mas-context"
version.workspace = true

View File

@@ -1,13 +1,10 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use console::{Color, Style};
use opentelemetry::{
TraceId,
trace::{SamplingDecision, TraceContextExt},
};
use opentelemetry::TraceId;
use tracing::{Level, Subscriber};
use tracing_opentelemetry::OtelData;
use tracing_subscriber::{
@@ -129,33 +126,16 @@ where
field_fromatter.format_fields(writer.by_ref(), event)?;
// If we have a OTEL span, we can add the trace ID to the end of the log line
if let Some(span) = ctx.lookup_current() {
if let Some(otel) = span.extensions().get::<OtelData>() {
let parent_cx_span = otel.parent_cx.span();
let sc = parent_cx_span.span_context();
// Check if the span is sampled, first from the span builder,
// then from the parent context if nothing is set there
if otel
.builder
.sampling_result
.as_ref()
.map_or(sc.is_sampled(), |r| {
r.decision == SamplingDecision::RecordAndSample
})
{
// If it is the root span, the trace ID will be in the span builder. Else, it
// will be in the parent OTEL context
let trace_id = otel.builder.trace_id.unwrap_or(sc.trace_id());
if trace_id != TraceId::INVALID {
let label = Style::new()
.italic()
.force_styling(ansi)
.apply_to("trace.id");
write!(&mut writer, " {label}={trace_id}")?;
}
}
}
if let Some(span) = ctx.lookup_current()
&& let Some(otel) = span.extensions().get::<OtelData>()
&& let Some(trace_id) = otel.trace_id()
&& trace_id != TraceId::INVALID
{
let label = Style::new()
.italic()
.force_styling(ansi)
.apply_to("trace.id");
write!(&mut writer, " {label}={trace_id}")?;
}
writeln!(&mut writer)

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{
pin::Pin,

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::borrow::Cow;

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
mod fmt;
mod future;

View File

@@ -1,7 +1,7 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{
borrow::Cow,

View File

@@ -1,3 +1,8 @@
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
[package]
name = "mas-data-model"
version.workspace = true
@@ -21,10 +26,13 @@ url.workspace = true
crc.workspace = true
ulid.workspace = true
rand.workspace = true
rand_chacha.workspace = true
regex.workspace = true
woothee.workspace = true
ruma-common.workspace = true
lettre.workspace = true
mas-iana.workspace = true
mas-jose.workspace = true
oauth2-types.workspace = true

Some files were not shown because too many files have changed in this diff Show More