syn2mas/synapse_reader/
checks.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4// Please see LICENSE files 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(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
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    #[error(
135        "Synapse database contains {num_users} users associated to the OpenID Connect or OAuth2 provider '{provider}' but the Synapse configuration does not contain this provider."
136    )]
137    SynapseMissingOAuthProvider { provider: String, num_users: i64 },
138}
139
140/// Check that the Synapse configuration is sane for migration.
141#[must_use]
142pub fn synapse_config_check(synapse_config: &Config) -> (Vec<CheckWarning>, Vec<CheckError>) {
143    let mut errors = Vec::new();
144    let mut warnings = Vec::new();
145
146    if synapse_config.enable_registration {
147        warnings.push(CheckWarning::DisableRegistrationAfterMigration);
148    }
149    if synapse_config.user_consent.is_some() {
150        warnings.push(CheckWarning::DisableUserConsentAfterMigration);
151    }
152
153    // TODO provide guidance on migrating these auth systems
154    // that are not directly supported as upstreams in MAS
155    if synapse_config.cas_config.enabled {
156        warnings.push(CheckWarning::ExternalAuthSystem("CAS"));
157    }
158    if synapse_config.saml2_config.enabled {
159        warnings.push(CheckWarning::ExternalAuthSystem("SAML2"));
160    }
161    if synapse_config.jwt_config.enabled {
162        warnings.push(CheckWarning::ExternalAuthSystem("JWT"));
163    }
164    if synapse_config.password_config.enabled && !synapse_config.password_config.localdb_enabled {
165        warnings.push(CheckWarning::ExternalAuthSystem(
166            "non-standard password provider plugin",
167        ));
168    }
169
170    if synapse_config.enable_3pid_changes == Some(true) {
171        errors.push(CheckError::ThreepidChangesEnabled);
172    }
173
174    if synapse_config.login_via_existing_session.enabled {
175        errors.push(CheckError::LoginViaExistingSessionEnabled);
176    }
177
178    (warnings, errors)
179}
180
181/// Check that the given Synapse configuration is sane for migration to a MAS
182/// with the given MAS configuration.
183///
184/// # Errors
185///
186/// - If any necessary section of MAS config cannot be parsed.
187/// - If the MAS password configuration (including any necessary secrets) can't
188///   be loaded.
189pub async fn synapse_config_check_against_mas_config(
190    synapse: &Config,
191    mas: &Figment,
192) -> Result<(Vec<CheckWarning>, Vec<CheckError>), Error> {
193    let mut errors = Vec::new();
194    let mut warnings = Vec::new();
195
196    let mas_passwords = PasswordsConfig::extract_or_default(mas).map_err(Error::MasConfig)?;
197    let mas_password_schemes = mas_passwords
198        .load()
199        .await
200        .map_err(Error::MasPasswordConfig)?;
201
202    let mas_matrix = MatrixConfig::extract(mas).map_err(Error::MasConfig)?;
203
204    // Look for the MAS password hashing scheme that will be used for imported
205    // Synapse passwords, then check the configuration matches so that Synapse
206    // passwords will be compatible with MAS.
207    if let Some((_, algorithm, _, secret, _)) = mas_password_schemes
208        .iter()
209        .find(|(version, _, _, _, _)| *version == MIGRATED_PASSWORD_VERSION)
210    {
211        if algorithm != &PasswordAlgorithm::Bcrypt {
212            errors.push(CheckError::PasswordSchemeNotBcrypt);
213        }
214
215        let synapse_pepper = synapse
216            .password_config
217            .pepper
218            .as_ref()
219            .map(String::as_bytes);
220        if secret.as_deref() != synapse_pepper {
221            errors.push(CheckError::PasswordSchemeWrongPepper);
222        }
223    } else {
224        errors.push(CheckError::MissingPasswordScheme);
225    }
226
227    if synapse.allow_guest_access {
228        errors.push(CheckError::GuestsEnabled);
229    }
230
231    if synapse.server_name != mas_matrix.homeserver {
232        errors.push(CheckError::ServerNameMismatch {
233            synapse: synapse.server_name.clone(),
234            mas: mas_matrix.homeserver.clone(),
235        });
236    }
237
238    let mas_captcha = CaptchaConfig::extract_or_default(mas).map_err(Error::MasConfig)?;
239    if synapse.enable_registration_captcha && mas_captcha.service.is_none() {
240        warnings.push(CheckWarning::ShouldPortRegistrationCaptcha);
241    }
242
243    let mas_branding = BrandingConfig::extract_or_default(mas).map_err(Error::MasConfig)?;
244    if synapse.user_consent.is_some() && mas_branding.tos_uri.is_none() {
245        warnings.push(CheckWarning::ShouldPortUserConsentAsTerms);
246    }
247
248    Ok((warnings, errors))
249}
250
251/// Check that the Synapse database is sane for migration. Returns a list of
252/// warnings and errors.
253///
254/// # Errors
255///
256/// - If there is some database connection error, or the given database is not a
257///   Synapse database.
258/// - If the Upstream OAuth section of the MAS configuration could not be
259///   parsed.
260#[tracing::instrument(skip_all)]
261pub async fn synapse_database_check(
262    synapse_connection: &mut PgConnection,
263    synapse: &Config,
264    mas: &Figment,
265    ignore_missing_auth_providers: bool,
266) -> Result<(Vec<CheckWarning>, Vec<CheckError>), Error> {
267    #[derive(FromRow)]
268    struct UpstreamOAuthProvider {
269        auth_provider: String,
270        num_users: i64,
271    }
272
273    let mut errors = Vec::new();
274    let mut warnings = Vec::new();
275
276    let num_guests: i64 = query_scalar("SELECT COUNT(1) FROM users WHERE is_guest <> 0")
277        .fetch_one(&mut *synapse_connection)
278        .await?;
279    if num_guests > 0 {
280        warnings.push(CheckWarning::GuestsInDatabase { num_guests });
281    }
282
283    let num_non_email_3pids: i64 =
284        query_scalar("SELECT COUNT(1) FROM user_threepids WHERE medium <> 'email'")
285            .fetch_one(&mut *synapse_connection)
286            .await?;
287    if num_non_email_3pids > 0 {
288        warnings.push(CheckWarning::NonEmailThreepidsInDatabase {
289            num_non_email_3pids,
290        });
291    }
292
293    let oauth_provider_user_counts = query_as::<_, UpstreamOAuthProvider>(
294        "
295        SELECT auth_provider, COUNT(*) AS num_users
296        FROM user_external_ids
297        GROUP BY auth_provider
298        ORDER BY auth_provider
299        ",
300    )
301    .fetch_all(&mut *synapse_connection)
302    .await?;
303    if !oauth_provider_user_counts.is_empty() {
304        let syn_oauth2 = synapse.all_oidc_providers();
305        let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas).map_err(Error::MasConfig)?;
306        for row in oauth_provider_user_counts {
307            // This is a special case of a previous migration attempt to MAS
308            if row.auth_provider == "oauth-delegated" {
309                errors.push(CheckError::ExistingOAuthDelegated {
310                    num_users: row.num_users,
311                });
312                continue;
313            }
314
315            let matching_syn = syn_oauth2.get(&row.auth_provider);
316
317            let Some(matching_syn) = matching_syn else {
318                if ignore_missing_auth_providers {
319                    warnings.push(CheckWarning::SynapseMissingOAuthProvider {
320                        provider: row.auth_provider,
321                        num_users: row.num_users,
322                    });
323                } else {
324                    errors.push(CheckError::SynapseMissingOAuthProvider {
325                        provider: row.auth_provider,
326                        num_users: row.num_users,
327                    });
328                }
329                continue;
330            };
331
332            // Matching by `synapse_idp_id` is the same as what we'll do for the migration
333            let matching_mas = mas_oauth2.providers.iter().find(|mas_provider| {
334                mas_provider.synapse_idp_id.as_ref() == Some(&row.auth_provider)
335            });
336
337            if matching_mas.is_none() {
338                errors.push(CheckError::MasMissingOAuthProvider {
339                    provider: row.auth_provider,
340                    issuer: matching_syn
341                        .issuer
342                        .clone()
343                        .unwrap_or("<unspecified>".to_owned()),
344                    num_users: row.num_users,
345                });
346            }
347        }
348    }
349
350    Ok((warnings, errors))
351}