syn2mas/synapse_reader/
config.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
6use std::collections::BTreeMap;
7
8use camino::Utf8PathBuf;
9use figment::providers::{Format, Yaml};
10use serde::Deserialize;
11use sqlx::postgres::PgConnectOptions;
12
13/// The root of a Synapse configuration.
14/// This struct only includes fields which the Synapse-to-MAS migration is
15/// interested in.
16///
17/// See: <https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html>
18#[derive(Deserialize)]
19#[allow(clippy::struct_excessive_bools)]
20pub struct Config {
21    pub database: DatabaseSection,
22
23    #[serde(default)]
24    pub password_config: PasswordSection,
25
26    #[serde(default)]
27    pub allow_guest_access: bool,
28
29    #[serde(default)]
30    pub enable_registration: bool,
31
32    #[serde(default)]
33    pub enable_registration_captcha: bool,
34
35    /// Normally this defaults to true, but when MAS integration is enabled in
36    /// Synapse it defaults to false.
37    #[serde(default)]
38    pub enable_3pid_changes: bool,
39
40    #[serde(default)]
41    pub user_consent: Option<UserConsentSection>,
42
43    #[serde(default)]
44    pub registrations_require_3pid: Vec<String>,
45
46    #[serde(default)]
47    pub registration_requires_token: bool,
48
49    pub registration_shared_secret: Option<String>,
50
51    #[serde(default)]
52    pub login_via_existing_session: EnableableSection,
53
54    #[serde(default)]
55    pub cas_config: EnableableSection,
56
57    #[serde(default)]
58    pub saml2_config: EnableableSection,
59
60    #[serde(default)]
61    pub jwt_config: EnableableSection,
62
63    #[serde(default)]
64    pub oidc_config: Option<OidcProvider>,
65
66    #[serde(default)]
67    pub oidc_providers: Vec<OidcProvider>,
68
69    pub server_name: String,
70}
71
72impl Config {
73    /// Load a Synapse configuration from the given list of configuration files.
74    ///
75    /// # Errors
76    ///
77    /// - If there is a problem reading any of the files.
78    /// - If the configuration is not valid.
79    pub fn load(files: &[Utf8PathBuf]) -> Result<Config, figment::Error> {
80        let mut figment = figment::Figment::new();
81        for file in files {
82            // TODO this is not exactly correct behaviour — Synapse does not merge anything
83            // other than the top level dict.
84            // https://github.com/element-hq/matrix-authentication-service/pull/3805#discussion_r1922680825
85            // https://github.com/element-hq/synapse/blob/develop/synapse/config/_base.py?rgh-link-date=2025-01-20T17%3A02%3A56Z#L870
86            figment = figment.merge(Yaml::file(file));
87        }
88        figment.extract::<Config>()
89    }
90
91    /// Returns a map of all OIDC providers from the Synapse configuration.
92    ///
93    /// The keys are the `auth_provider` IDs as they would have been stored in
94    /// Synapse's database.
95    ///
96    /// These are compatible with the `synapse_idp_id` field of
97    /// [`mas_config::UpstreamOAuth2Provider`].
98    #[must_use]
99    pub fn all_oidc_providers(&self) -> BTreeMap<String, OidcProvider> {
100        let mut out = BTreeMap::new();
101
102        if let Some(provider) = &self.oidc_config {
103            if provider.issuer.is_some() {
104                // The legacy configuration has an implied IdP ID of `oidc`.
105                out.insert("oidc".to_owned(), provider.clone());
106            }
107        }
108
109        for provider in &self.oidc_providers {
110            if let Some(idp_id) = &provider.idp_id {
111                // Synapse internally prefixes the IdP IDs with `oidc-`.
112                out.insert(format!("oidc-{idp_id}"), provider.clone());
113            }
114        }
115
116        out
117    }
118}
119
120/// The `database` section of the Synapse configuration.
121///
122/// See: <https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#database>
123#[derive(Deserialize)]
124pub struct DatabaseSection {
125    /// Expecting `psycopg2` for Postgres or `sqlite3` for `SQLite3`, but may be
126    /// an arbitrary string and future versions of Synapse may support other
127    /// database drivers, e.g. psycopg3.
128    pub name: String,
129    #[serde(default)]
130    pub args: DatabaseArgsSuboption,
131}
132
133/// The database driver name for Synapse when it is using Postgres via psycopg2.
134pub const SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2: &str = "psycopg2";
135/// The database driver name for Synapse when it is using SQLite 3.
136pub const SYNAPSE_DATABASE_DRIVER_NAME_SQLITE3: &str = "sqlite3";
137
138impl DatabaseSection {
139    /// Process the configuration into Postgres connection options.
140    ///
141    /// Environment variables and libpq defaults will be used as fallback for
142    /// any missing values; this should match what Synapse does.
143    /// But note that if syn2mas is not run in the same context (host, user,
144    /// environment variables) as Synapse normally runs, then the connection
145    /// options may not be valid.
146    ///
147    /// Returns `None` if this database configuration is not configured for
148    /// Postgres.
149    #[must_use]
150    pub fn to_sqlx_postgres(&self) -> Option<PgConnectOptions> {
151        if self.name != SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2 {
152            return None;
153        }
154        let mut opts = PgConnectOptions::new().application_name("syn2mas-synapse");
155
156        if let Some(host) = &self.args.host {
157            opts = opts.host(host);
158        }
159        if let Some(port) = self.args.port {
160            opts = opts.port(port);
161        }
162        if let Some(dbname) = &self.args.dbname {
163            opts = opts.database(dbname);
164        }
165        if let Some(user) = &self.args.user {
166            opts = opts.username(user);
167        }
168        if let Some(password) = &self.args.password {
169            opts = opts.password(password);
170        }
171
172        Some(opts)
173    }
174}
175
176/// The `args` suboption of the `database` section of the Synapse configuration.
177/// This struct assumes Postgres is in use and does not represent fields used by
178/// SQLite.
179#[derive(Deserialize, Default)]
180pub struct DatabaseArgsSuboption {
181    pub user: Option<String>,
182    pub password: Option<String>,
183    pub dbname: Option<String>,
184    pub host: Option<String>,
185    pub port: Option<u16>,
186}
187
188/// The `password_config` section of the Synapse configuration.
189///
190/// See: <https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#password_config>
191#[derive(Deserialize)]
192pub struct PasswordSection {
193    #[serde(default = "default_true")]
194    pub enabled: bool,
195    #[serde(default = "default_true")]
196    pub localdb_enabled: bool,
197    pub pepper: Option<String>,
198}
199
200impl Default for PasswordSection {
201    fn default() -> Self {
202        PasswordSection {
203            enabled: true,
204            localdb_enabled: true,
205            pepper: None,
206        }
207    }
208}
209
210/// A section that we only care about whether it's enabled or not, but is not
211/// enabled by default.
212#[derive(Default, Deserialize)]
213pub struct EnableableSection {
214    #[serde(default)]
215    pub enabled: bool,
216}
217
218#[derive(Clone, Deserialize)]
219pub struct OidcProvider {
220    /// At least for `oidc_config`, if the dict is present but left empty then
221    /// the config should be ignored, so this field must be optional.
222    pub issuer: Option<String>,
223
224    /// Required, except for the old `oidc_config` where this is implied to be
225    /// "oidc".
226    pub idp_id: Option<String>,
227}
228
229fn default_true() -> bool {
230    true
231}
232
233#[cfg(test)]
234mod test {
235    use sqlx::postgres::PgConnectOptions;
236
237    use super::{DatabaseArgsSuboption, DatabaseSection};
238
239    #[test]
240    fn test_to_sqlx_postgres() {
241        #[track_caller]
242        #[allow(clippy::needless_pass_by_value)]
243        fn assert_eq_options(config: DatabaseSection, uri: &str) {
244            let config_connect_options = config
245                .to_sqlx_postgres()
246                .expect("no connection options generated by config");
247            let uri_connect_options: PgConnectOptions = uri
248                .parse()
249                .expect("example URI did not parse as PgConnectionOptions");
250
251            assert_eq!(
252                config_connect_options.get_host(),
253                uri_connect_options.get_host()
254            );
255            assert_eq!(
256                config_connect_options.get_port(),
257                uri_connect_options.get_port()
258            );
259            assert_eq!(
260                config_connect_options.get_username(),
261                uri_connect_options.get_username()
262            );
263            // The password is not public so we can't assert it. But that's hopefully fine.
264            assert_eq!(
265                config_connect_options.get_database(),
266                uri_connect_options.get_database()
267            );
268        }
269
270        // SQLite configs are not accepted
271        assert!(
272            DatabaseSection {
273                name: "sqlite3".to_owned(),
274                args: DatabaseArgsSuboption::default(),
275            }
276            .to_sqlx_postgres()
277            .is_none()
278        );
279
280        assert_eq_options(
281            DatabaseSection {
282                name: "psycopg2".to_owned(),
283                args: DatabaseArgsSuboption::default(),
284            },
285            "postgresql:///",
286        );
287        assert_eq_options(
288            DatabaseSection {
289                name: "psycopg2".to_owned(),
290                args: DatabaseArgsSuboption {
291                    user: Some("synapse_user".to_owned()),
292                    password: Some("verysecret".to_owned()),
293                    dbname: Some("synapse_db".to_owned()),
294                    host: Some("synapse-db.example.com".to_owned()),
295                    port: Some(42),
296                },
297            },
298            "postgresql://synapse_user:verysecret@synapse-db.example.com:42/synapse_db",
299        );
300    }
301}
302
303/// We don't care about any of the fields in this section,
304/// just whether it's present.
305#[derive(Deserialize)]
306pub struct UserConsentSection {}