syn2mas/synapse_reader/
checks.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4// Please see LICENSE in the repository root for full details.
5
6//! # Synapse Checks
7//!
8//! This module provides safety checks to run against a Synapse database before
9//! running the Synapse-to-MAS migration.
10
11use figment::Figment;
12use mas_config::{
13    BrandingConfig, CaptchaConfig, ConfigurationSection, ConfigurationSectionExt, MatrixConfig,
14    PasswordAlgorithm, PasswordsConfig, UpstreamOAuth2Config,
15};
16use sqlx::{PgConnection, prelude::FromRow, query_as, query_scalar};
17use thiserror::Error;
18
19use super::config::Config;
20use crate::mas_writer::MIGRATED_PASSWORD_VERSION;
21
22#[derive(Debug, Error)]
23pub enum Error {
24    #[error("query failed: {0}")]
25    Sqlx(#[from] sqlx::Error),
26
27    #[error("failed to load MAS config: {0}")]
28    MasConfig(#[from] figment::Error),
29
30    #[error("failed to load MAS password config: {0}")]
31    MasPasswordConfig(#[source] anyhow::Error),
32}
33
34/// An error found whilst checking the Synapse database, that should block a
35/// migration.
36#[derive(Debug, Error)]
37pub enum CheckError {
38    #[error("MAS config is missing a password hashing scheme with version '1'")]
39    MissingPasswordScheme,
40
41    #[error(
42        "Password scheme version '1' in the MAS config must use the Bcrypt algorithm, so that Synapse passwords can be imported and will be compatible."
43    )]
44    PasswordSchemeNotBcrypt,
45
46    #[error(
47        "Password scheme version '1' in the MAS config must have the same secret as the `pepper` value from Synapse, so that Synapse passwords can be imported and will be compatible."
48    )]
49    PasswordSchemeWrongPepper,
50
51    #[error(
52        "Guest support is enabled in the Synapse configuration. Guests aren't supported by MAS, but if you don't have any then you could disable the option. See https://github.com/element-hq/matrix-authentication-service/issues/1445"
53    )]
54    GuestsEnabled,
55
56    #[error(
57        "Synapse config has `enable_3pid_changes` explicitly enabled, which must be disabled or removed."
58    )]
59    ThreepidChangesEnabled,
60
61    #[error(
62        "Synapse config has `login_via_existing_session.enabled` set to true, which must be disabled."
63    )]
64    LoginViaExistingSessionEnabled,
65
66    #[error(
67        "MAS configuration has the wrong `matrix.homeserver` set ({mas:?}), it should match Synapse's `server_name` ({synapse:?})"
68    )]
69    ServerNameMismatch { synapse: String, mas: String },
70
71    #[error(
72        "Synapse database contains {num_users} users associated to the OpenID Connect or OAuth2 provider '{provider}' but the Synapse configuration does not contain this provider."
73    )]
74    SynapseMissingOAuthProvider { provider: String, num_users: i64 },
75
76    #[error(
77        "Synapse config contains an OpenID Connect or OAuth2 provider '{provider}' (issuer: {issuer:?}) used by {num_users} users which must also be configured in the MAS configuration as an upstream provider."
78    )]
79    MasMissingOAuthProvider {
80        provider: String,
81        issuer: String,
82        num_users: i64,
83    },
84}
85
86/// A potential hazard found whilst checking the Synapse database, that should
87/// be presented to the operator to check they are aware of a caveat before
88/// proceeding with the migration.
89#[derive(Debug, Error)]
90pub enum CheckWarning {
91    #[error(
92        "Synapse config contains OIDC auth configuration (issuer: {issuer:?}) which will need to be manually mapped to an upstream OpenID Connect Provider during migration."
93    )]
94    UpstreamOidcProvider { issuer: String },
95
96    #[error(
97        "Synapse config contains {0} auth configuration which will need to be manually mapped as an upstream OAuth 2.0 provider during migration."
98    )]
99    ExternalAuthSystem(&'static str),
100
101    #[error(
102        "Synapse config has registration enabled. This must be disabled after migration before bringing Synapse back online."
103    )]
104    DisableRegistrationAfterMigration,
105
106    #[error("Synapse config has `user_consent` enabled. This should be disabled after migration.")]
107    DisableUserConsentAfterMigration,
108
109    #[error(
110        "Synapse config has `user_consent` enabled but MAS has not been configured with terms of service. You may wish to set up a `tos_uri` in your MAS branding configuration to replace the user consent."
111    )]
112    ShouldPortUserConsentAsTerms,
113
114    #[error(
115        "Synapse config has a registration CAPTCHA enabled, but no CAPTCHA has been configured in MAS. You may wish to manually configure this."
116    )]
117    ShouldPortRegistrationCaptcha,
118
119    #[error(
120        "Synapse database contains {num_guests} guests which will be migrated are not supported by MAS. See https://github.com/element-hq/matrix-authentication-service/issues/1445"
121    )]
122    GuestsInDatabase { num_guests: i64 },
123
124    #[error(
125        "Synapse database contains {num_non_email_3pids} non-email 3PIDs (probably phone numbers), which will be migrated but are not supported by MAS."
126    )]
127    NonEmailThreepidsInDatabase { num_non_email_3pids: i64 },
128}
129
130/// Check that the Synapse configuration is sane for migration.
131#[must_use]
132pub fn synapse_config_check(synapse_config: &Config) -> (Vec<CheckWarning>, Vec<CheckError>) {
133    let mut errors = Vec::new();
134    let mut warnings = Vec::new();
135
136    if synapse_config.enable_registration {
137        warnings.push(CheckWarning::DisableRegistrationAfterMigration);
138    }
139    if synapse_config.user_consent.is_some() {
140        warnings.push(CheckWarning::DisableUserConsentAfterMigration);
141    }
142
143    // TODO provide guidance on migrating these auth systems
144    // that are not directly supported as upstreams in MAS
145    if synapse_config.cas_config.enabled {
146        warnings.push(CheckWarning::ExternalAuthSystem("CAS"));
147    }
148    if synapse_config.saml2_config.enabled {
149        warnings.push(CheckWarning::ExternalAuthSystem("SAML2"));
150    }
151    if synapse_config.jwt_config.enabled {
152        warnings.push(CheckWarning::ExternalAuthSystem("JWT"));
153    }
154    if synapse_config.password_config.enabled && !synapse_config.password_config.localdb_enabled {
155        warnings.push(CheckWarning::ExternalAuthSystem(
156            "non-standard password provider plugin",
157        ));
158    }
159
160    if synapse_config.enable_3pid_changes {
161        errors.push(CheckError::ThreepidChangesEnabled);
162    }
163
164    if synapse_config.login_via_existing_session.enabled {
165        errors.push(CheckError::LoginViaExistingSessionEnabled);
166    }
167
168    (warnings, errors)
169}
170
171/// Check that the given Synapse configuration is sane for migration to a MAS
172/// with the given MAS configuration.
173///
174/// # Errors
175///
176/// - If any necessary section of MAS config cannot be parsed.
177/// - If the MAS password configuration (including any necessary secrets) can't
178///   be loaded.
179pub async fn synapse_config_check_against_mas_config(
180    synapse: &Config,
181    mas: &Figment,
182) -> Result<(Vec<CheckWarning>, Vec<CheckError>), Error> {
183    let mut errors = Vec::new();
184    let mut warnings = Vec::new();
185
186    let mas_passwords = PasswordsConfig::extract_or_default(mas)?;
187    let mas_password_schemes = mas_passwords
188        .load()
189        .await
190        .map_err(Error::MasPasswordConfig)?;
191
192    let mas_matrix = MatrixConfig::extract(mas)?;
193
194    // Look for the MAS password hashing scheme that will be used for imported
195    // Synapse passwords, then check the configuration matches so that Synapse
196    // passwords will be compatible with MAS.
197    if let Some((_, algorithm, _, secret)) = mas_password_schemes
198        .iter()
199        .find(|(version, _, _, _)| *version == MIGRATED_PASSWORD_VERSION)
200    {
201        if algorithm != &PasswordAlgorithm::Bcrypt {
202            errors.push(CheckError::PasswordSchemeNotBcrypt);
203        }
204
205        let synapse_pepper = synapse
206            .password_config
207            .pepper
208            .as_ref()
209            .map(String::as_bytes);
210        if secret.as_deref() != synapse_pepper {
211            errors.push(CheckError::PasswordSchemeWrongPepper);
212        }
213    } else {
214        errors.push(CheckError::MissingPasswordScheme);
215    }
216
217    if synapse.allow_guest_access {
218        errors.push(CheckError::GuestsEnabled);
219    }
220
221    if synapse.server_name != mas_matrix.homeserver {
222        errors.push(CheckError::ServerNameMismatch {
223            synapse: synapse.server_name.clone(),
224            mas: mas_matrix.homeserver.clone(),
225        });
226    }
227
228    let mas_captcha = CaptchaConfig::extract_or_default(mas)?;
229    if synapse.enable_registration_captcha && mas_captcha.service.is_none() {
230        warnings.push(CheckWarning::ShouldPortRegistrationCaptcha);
231    }
232
233    let mas_branding = BrandingConfig::extract_or_default(mas)?;
234    if synapse.user_consent.is_some() && mas_branding.tos_uri.is_none() {
235        warnings.push(CheckWarning::ShouldPortUserConsentAsTerms);
236    }
237
238    Ok((warnings, errors))
239}
240
241/// Check that the Synapse database is sane for migration. Returns a list of
242/// warnings and errors.
243///
244/// # Errors
245///
246/// - If there is some database connection error, or the given database is not a
247///   Synapse database.
248/// - If the OAuth2 section of the MAS configuration could not be parsed.
249#[tracing::instrument(skip_all)]
250pub async fn synapse_database_check(
251    synapse_connection: &mut PgConnection,
252    synapse: &Config,
253    mas: &Figment,
254) -> Result<(Vec<CheckWarning>, Vec<CheckError>), Error> {
255    #[derive(FromRow)]
256    struct UpstreamOAuthProvider {
257        auth_provider: String,
258        num_users: i64,
259    }
260
261    let mut errors = Vec::new();
262    let mut warnings = Vec::new();
263
264    let num_guests: i64 = query_scalar("SELECT COUNT(1) FROM users WHERE is_guest <> 0")
265        .fetch_one(&mut *synapse_connection)
266        .await?;
267    if num_guests > 0 {
268        warnings.push(CheckWarning::GuestsInDatabase { num_guests });
269    }
270
271    let num_non_email_3pids: i64 =
272        query_scalar("SELECT COUNT(1) FROM user_threepids WHERE medium <> 'email'")
273            .fetch_one(&mut *synapse_connection)
274            .await?;
275    if num_non_email_3pids > 0 {
276        warnings.push(CheckWarning::NonEmailThreepidsInDatabase {
277            num_non_email_3pids,
278        });
279    }
280
281    let oauth_provider_user_counts = query_as::<_, UpstreamOAuthProvider>(
282        "
283        SELECT auth_provider, COUNT(*) AS num_users
284        FROM user_external_ids
285        GROUP BY auth_provider
286        ORDER BY auth_provider
287        ",
288    )
289    .fetch_all(&mut *synapse_connection)
290    .await?;
291    if !oauth_provider_user_counts.is_empty() {
292        let syn_oauth2 = synapse.all_oidc_providers();
293        let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas)?;
294        for row in oauth_provider_user_counts {
295            let matching_syn = syn_oauth2.get(&row.auth_provider);
296
297            let Some(matching_syn) = matching_syn else {
298                errors.push(CheckError::SynapseMissingOAuthProvider {
299                    provider: row.auth_provider,
300                    num_users: row.num_users,
301                });
302                continue;
303            };
304
305            // Matching by `synapse_idp_id` is the same as what we'll do for the migration
306            let matching_mas = mas_oauth2.providers.iter().find(|mas_provider| {
307                mas_provider.synapse_idp_id.as_ref() == Some(&row.auth_provider)
308            });
309
310            if matching_mas.is_none() {
311                errors.push(CheckError::MasMissingOAuthProvider {
312                    provider: row.auth_provider,
313                    issuer: matching_syn
314                        .issuer
315                        .clone()
316                        .unwrap_or("<unspecified>".to_owned()),
317                    num_users: row.num_users,
318                });
319            }
320        }
321    }
322
323    Ok((warnings, errors))
324}