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 database has {num_users} mapping entries from a previously-configured MAS instance. If this is from a previous migration attempt, run the following SQL query against the Synapse database: `DELETE FROM user_external_ids WHERE auth_provider = 'oauth-delegated';` and then run the migration again."
78    )]
79    ExistingOAuthDelegated { num_users: i64 },
80
81    #[error(
82        "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."
83    )]
84    MasMissingOAuthProvider {
85        provider: String,
86        issuer: String,
87        num_users: i64,
88    },
89}
90
91/// A potential hazard found whilst checking the Synapse database, that should
92/// be presented to the operator to check they are aware of a caveat before
93/// proceeding with the migration.
94#[derive(Debug, Error)]
95pub enum CheckWarning {
96    #[error(
97        "Synapse config contains OIDC auth configuration (issuer: {issuer:?}) which will need to be manually mapped to an upstream OpenID Connect Provider during migration."
98    )]
99    UpstreamOidcProvider { issuer: String },
100
101    #[error(
102        "Synapse config contains {0} auth configuration which will need to be manually mapped as an upstream OAuth 2.0 provider during migration."
103    )]
104    ExternalAuthSystem(&'static str),
105
106    #[error(
107        "Synapse config has registration enabled. This must be disabled after migration before bringing Synapse back online."
108    )]
109    DisableRegistrationAfterMigration,
110
111    #[error("Synapse config has `user_consent` enabled. This should be disabled after migration.")]
112    DisableUserConsentAfterMigration,
113
114    #[error(
115        "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."
116    )]
117    ShouldPortUserConsentAsTerms,
118
119    #[error(
120        "Synapse config has a registration CAPTCHA enabled, but no CAPTCHA has been configured in MAS. You may wish to manually configure this."
121    )]
122    ShouldPortRegistrationCaptcha,
123
124    #[error(
125        "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"
126    )]
127    GuestsInDatabase { num_guests: i64 },
128
129    #[error(
130        "Synapse database contains {num_non_email_3pids} non-email 3PIDs (probably phone numbers), which will be migrated but are not supported by MAS."
131    )]
132    NonEmailThreepidsInDatabase { num_non_email_3pids: i64 },
133}
134
135/// Check that the Synapse configuration is sane for migration.
136#[must_use]
137pub fn synapse_config_check(synapse_config: &Config) -> (Vec<CheckWarning>, Vec<CheckError>) {
138    let mut errors = Vec::new();
139    let mut warnings = Vec::new();
140
141    if synapse_config.enable_registration {
142        warnings.push(CheckWarning::DisableRegistrationAfterMigration);
143    }
144    if synapse_config.user_consent.is_some() {
145        warnings.push(CheckWarning::DisableUserConsentAfterMigration);
146    }
147
148    // TODO provide guidance on migrating these auth systems
149    // that are not directly supported as upstreams in MAS
150    if synapse_config.cas_config.enabled {
151        warnings.push(CheckWarning::ExternalAuthSystem("CAS"));
152    }
153    if synapse_config.saml2_config.enabled {
154        warnings.push(CheckWarning::ExternalAuthSystem("SAML2"));
155    }
156    if synapse_config.jwt_config.enabled {
157        warnings.push(CheckWarning::ExternalAuthSystem("JWT"));
158    }
159    if synapse_config.password_config.enabled && !synapse_config.password_config.localdb_enabled {
160        warnings.push(CheckWarning::ExternalAuthSystem(
161            "non-standard password provider plugin",
162        ));
163    }
164
165    if synapse_config.enable_3pid_changes == Some(true) {
166        errors.push(CheckError::ThreepidChangesEnabled);
167    }
168
169    if synapse_config.login_via_existing_session.enabled {
170        errors.push(CheckError::LoginViaExistingSessionEnabled);
171    }
172
173    (warnings, errors)
174}
175
176/// Check that the given Synapse configuration is sane for migration to a MAS
177/// with the given MAS configuration.
178///
179/// # Errors
180///
181/// - If any necessary section of MAS config cannot be parsed.
182/// - If the MAS password configuration (including any necessary secrets) can't
183///   be loaded.
184pub async fn synapse_config_check_against_mas_config(
185    synapse: &Config,
186    mas: &Figment,
187) -> Result<(Vec<CheckWarning>, Vec<CheckError>), Error> {
188    let mut errors = Vec::new();
189    let mut warnings = Vec::new();
190
191    let mas_passwords = PasswordsConfig::extract_or_default(mas)?;
192    let mas_password_schemes = mas_passwords
193        .load()
194        .await
195        .map_err(Error::MasPasswordConfig)?;
196
197    let mas_matrix = MatrixConfig::extract(mas)?;
198
199    // Look for the MAS password hashing scheme that will be used for imported
200    // Synapse passwords, then check the configuration matches so that Synapse
201    // passwords will be compatible with MAS.
202    if let Some((_, algorithm, _, secret)) = mas_password_schemes
203        .iter()
204        .find(|(version, _, _, _)| *version == MIGRATED_PASSWORD_VERSION)
205    {
206        if algorithm != &PasswordAlgorithm::Bcrypt {
207            errors.push(CheckError::PasswordSchemeNotBcrypt);
208        }
209
210        let synapse_pepper = synapse
211            .password_config
212            .pepper
213            .as_ref()
214            .map(String::as_bytes);
215        if secret.as_deref() != synapse_pepper {
216            errors.push(CheckError::PasswordSchemeWrongPepper);
217        }
218    } else {
219        errors.push(CheckError::MissingPasswordScheme);
220    }
221
222    if synapse.allow_guest_access {
223        errors.push(CheckError::GuestsEnabled);
224    }
225
226    if synapse.server_name != mas_matrix.homeserver {
227        errors.push(CheckError::ServerNameMismatch {
228            synapse: synapse.server_name.clone(),
229            mas: mas_matrix.homeserver.clone(),
230        });
231    }
232
233    let mas_captcha = CaptchaConfig::extract_or_default(mas)?;
234    if synapse.enable_registration_captcha && mas_captcha.service.is_none() {
235        warnings.push(CheckWarning::ShouldPortRegistrationCaptcha);
236    }
237
238    let mas_branding = BrandingConfig::extract_or_default(mas)?;
239    if synapse.user_consent.is_some() && mas_branding.tos_uri.is_none() {
240        warnings.push(CheckWarning::ShouldPortUserConsentAsTerms);
241    }
242
243    Ok((warnings, errors))
244}
245
246/// Check that the Synapse database is sane for migration. Returns a list of
247/// warnings and errors.
248///
249/// # Errors
250///
251/// - If there is some database connection error, or the given database is not a
252///   Synapse database.
253/// - If the OAuth2 section of the MAS configuration could not be parsed.
254#[tracing::instrument(skip_all)]
255pub async fn synapse_database_check(
256    synapse_connection: &mut PgConnection,
257    synapse: &Config,
258    mas: &Figment,
259) -> Result<(Vec<CheckWarning>, Vec<CheckError>), Error> {
260    #[derive(FromRow)]
261    struct UpstreamOAuthProvider {
262        auth_provider: String,
263        num_users: i64,
264    }
265
266    let mut errors = Vec::new();
267    let mut warnings = Vec::new();
268
269    let num_guests: i64 = query_scalar("SELECT COUNT(1) FROM users WHERE is_guest <> 0")
270        .fetch_one(&mut *synapse_connection)
271        .await?;
272    if num_guests > 0 {
273        warnings.push(CheckWarning::GuestsInDatabase { num_guests });
274    }
275
276    let num_non_email_3pids: i64 =
277        query_scalar("SELECT COUNT(1) FROM user_threepids WHERE medium <> 'email'")
278            .fetch_one(&mut *synapse_connection)
279            .await?;
280    if num_non_email_3pids > 0 {
281        warnings.push(CheckWarning::NonEmailThreepidsInDatabase {
282            num_non_email_3pids,
283        });
284    }
285
286    let oauth_provider_user_counts = query_as::<_, UpstreamOAuthProvider>(
287        "
288        SELECT auth_provider, COUNT(*) AS num_users
289        FROM user_external_ids
290        GROUP BY auth_provider
291        ORDER BY auth_provider
292        ",
293    )
294    .fetch_all(&mut *synapse_connection)
295    .await?;
296    if !oauth_provider_user_counts.is_empty() {
297        let syn_oauth2 = synapse.all_oidc_providers();
298        let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas)?;
299        for row in oauth_provider_user_counts {
300            // This is a special case of a previous migration attempt to MAS
301            if row.auth_provider == "oauth-delegated" {
302                errors.push(CheckError::ExistingOAuthDelegated {
303                    num_users: row.num_users,
304                });
305                continue;
306            }
307
308            let matching_syn = syn_oauth2.get(&row.auth_provider);
309
310            let Some(matching_syn) = matching_syn else {
311                errors.push(CheckError::SynapseMissingOAuthProvider {
312                    provider: row.auth_provider,
313                    num_users: row.num_users,
314                });
315                continue;
316            };
317
318            // Matching by `synapse_idp_id` is the same as what we'll do for the migration
319            let matching_mas = mas_oauth2.providers.iter().find(|mas_provider| {
320                mas_provider.synapse_idp_id.as_ref() == Some(&row.auth_provider)
321            });
322
323            if matching_mas.is_none() {
324                errors.push(CheckError::MasMissingOAuthProvider {
325                    provider: row.auth_provider,
326                    issuer: matching_syn
327                        .issuer
328                        .clone()
329                        .unwrap_or("<unspecified>".to_owned()),
330                    num_users: row.num_users,
331                });
332            }
333        }
334    }
335
336    Ok((warnings, errors))
337}