1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

Use minijinja templates to map OIDC claims to user attributes

This commit is contained in:
Quentin Gliech
2023-11-07 19:31:29 +01:00
parent 939ee57569
commit 6ded397977
10 changed files with 298 additions and 118 deletions

1
Cargo.lock generated
View File

@ -2972,6 +2972,7 @@ dependencies = [
"mas-storage-pg", "mas-storage-pg",
"mas-templates", "mas-templates",
"mime", "mime",
"minijinja",
"oauth2-types", "oauth2-types",
"opentelemetry", "opentelemetry",
"opentelemetry-semantic-conventions", "opentelemetry-semantic-conventions",

View File

@ -45,51 +45,36 @@ fn map_import_action(
} }
} }
fn map_import_preference(
config: &mas_config::UpstreamOAuth2ImportPreference,
) -> mas_data_model::UpstreamOAuthProviderImportPreference {
mas_data_model::UpstreamOAuthProviderImportPreference {
action: map_import_action(&config.action),
}
}
fn map_claims_imports( fn map_claims_imports(
config: &mas_config::UpstreamOAuth2ClaimsImports, config: &mas_config::UpstreamOAuth2ClaimsImports,
) -> mas_data_model::UpstreamOAuthProviderClaimsImports { ) -> mas_data_model::UpstreamOAuthProviderClaimsImports {
mas_data_model::UpstreamOAuthProviderClaimsImports { mas_data_model::UpstreamOAuthProviderClaimsImports {
localpart: config subject: mas_data_model::UpstreamOAuthProviderSubjectPreference {
.localpart template: config.subject.template.clone(),
.as_ref() },
.map(map_import_preference) localpart: mas_data_model::UpstreamOAuthProviderImportPreference {
.unwrap_or_default(), action: map_import_action(&config.localpart.action),
displayname: config template: config.localpart.template.clone(),
.displayname },
.as_ref() displayname: mas_data_model::UpstreamOAuthProviderImportPreference {
.map(map_import_preference) action: map_import_action(&config.displayname.action),
.unwrap_or_default(), template: config.displayname.template.clone(),
email: config },
.email email: mas_data_model::UpstreamOAuthProviderImportPreference {
.as_ref() action: map_import_action(&config.email.action),
.map(|c| mas_data_model::UpstreamOAuthProviderImportPreference { template: config.email.template.clone(),
action: map_import_action(&c.action), },
}) verify_email: match config.email.set_email_verification {
.unwrap_or_default(), mas_config::UpstreamOAuth2SetEmailVerification::Always => {
// XXX: this is a bit ugly mas_data_model::UpsreamOAuthProviderSetEmailVerification::Always
verify_email: config }
.email mas_config::UpstreamOAuth2SetEmailVerification::Never => {
.as_ref() mas_data_model::UpsreamOAuthProviderSetEmailVerification::Never
.map(|c| match c.set_email_verification { }
mas_config::UpstreamOAuth2SetEmailVerification::Always => { mas_config::UpstreamOAuth2SetEmailVerification::Import => {
mas_data_model::UpsreamOAuthProviderSetEmailVerification::Always mas_data_model::UpsreamOAuthProviderSetEmailVerification::Import
} }
mas_config::UpstreamOAuth2SetEmailVerification::Never => { },
mas_data_model::UpsreamOAuthProviderSetEmailVerification::Never
}
mas_config::UpstreamOAuth2SetEmailVerification::Import => {
mas_data_model::UpsreamOAuthProviderSetEmailVerification::Import
}
})
.unwrap_or_default(),
} }
} }

View File

@ -96,10 +96,10 @@ pub enum ImportAction {
Require, Require,
} }
/// What should be done with a claim /// What should be done with a attribute
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub struct ImportPreference { pub struct ImportPreference {
/// How to handle the claim /// How to handle the attribute
#[serde(default)] #[serde(default)]
pub action: ImportAction, pub action: ImportAction,
} }
@ -120,13 +120,57 @@ pub enum SetEmailVerification {
Import, Import,
} }
/// What should be done with the email claim /// What should be done for the subject attribute
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub struct SubjectImportPreference {
/// The Jinja2 template to use for the subject attribute
///
/// If not provided, the default template is `{{ user.sub }}`
#[serde(default)]
pub template: Option<String>,
}
/// What should be done for the localpart attribute
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub struct LocalpartImportPreference {
/// How to handle the attribute
#[serde(default)]
pub action: ImportAction,
/// The Jinja2 template to use for the localpart attribute
///
/// If not provided, the default template is `{{ user.preferred_username }}`
#[serde(default)]
pub template: Option<String>,
}
/// What should be done for the displayname attribute
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub struct DisplaynameImportPreference {
/// How to handle the attribute
#[serde(default)]
pub action: ImportAction,
/// The Jinja2 template to use for the displayname attribute
///
/// If not provided, the default template is `{{ user.name }}`
#[serde(default)]
pub template: Option<String>,
}
/// What should be done with the email attribute
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub struct EmailImportPreference { pub struct EmailImportPreference {
/// How to handle the claim /// How to handle the claim
#[serde(default)] #[serde(default)]
pub action: ImportAction, pub action: ImportAction,
/// The Jinja2 template to use for the email address attribute
///
/// If not provided, the default template is `{{ user.email }}`
#[serde(default)]
pub template: Option<String>,
/// Should the email address be marked as verified /// Should the email address be marked as verified
#[serde(default)] #[serde(default)]
pub set_email_verification: SetEmailVerification, pub set_email_verification: SetEmailVerification,
@ -135,18 +179,22 @@ pub struct EmailImportPreference {
/// How claims should be imported /// How claims should be imported
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub struct ClaimsImports { pub struct ClaimsImports {
/// Import the localpart of the MXID based on the `preferred_username` claim /// How to determine the subject of the user
#[serde(default)] #[serde(default)]
pub localpart: Option<ImportPreference>, pub subject: SubjectImportPreference,
/// Import the displayname of the user based on the `name` claim /// Import the localpart of the MXID
#[serde(default)] #[serde(default)]
pub displayname: Option<ImportPreference>, pub localpart: LocalpartImportPreference,
/// Import the displayname of the user.
#[serde(default)]
pub displayname: DisplaynameImportPreference,
/// Import the email address of the user based on the `email` and /// Import the email address of the user based on the `email` and
/// `email_verified` claims /// `email_verified` claims
#[serde(default)] #[serde(default)]
pub email: Option<EmailImportPreference>, pub email: EmailImportPreference,
} }
#[skip_serializing_none] #[skip_serializing_none]

View File

@ -49,7 +49,7 @@ pub use self::{
UpsreamOAuthProviderSetEmailVerification, UpstreamOAuthAuthorizationSession, UpsreamOAuthProviderSetEmailVerification, UpstreamOAuthAuthorizationSession,
UpstreamOAuthAuthorizationSessionState, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthAuthorizationSessionState, UpstreamOAuthLink, UpstreamOAuthProvider,
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderImportAction, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderImportAction,
UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderSubjectPreference,
}, },
users::{ users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,

View File

@ -22,7 +22,8 @@ pub use self::{
ClaimsImports as UpstreamOAuthProviderClaimsImports, ClaimsImports as UpstreamOAuthProviderClaimsImports,
ImportAction as UpstreamOAuthProviderImportAction, ImportAction as UpstreamOAuthProviderImportAction,
ImportPreference as UpstreamOAuthProviderImportPreference, ImportPreference as UpstreamOAuthProviderImportPreference,
SetEmailVerification as UpsreamOAuthProviderSetEmailVerification, UpstreamOAuthProvider, SetEmailVerification as UpsreamOAuthProviderSetEmailVerification,
SubjectPreference as UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProvider,
}, },
session::{UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState}, session::{UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState},
}; };

View File

@ -59,6 +59,9 @@ impl SetEmailVerification {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ClaimsImports { pub struct ClaimsImports {
#[serde(default)]
pub subject: SubjectPreference,
#[serde(default)] #[serde(default)]
pub localpart: ImportPreference, pub localpart: ImportPreference,
@ -72,10 +75,19 @@ pub struct ClaimsImports {
pub verify_email: SetEmailVerification, pub verify_email: SetEmailVerification,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SubjectPreference {
#[serde(default)]
pub template: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ImportPreference { pub struct ImportPreference {
#[serde(default)] #[serde(default)]
pub action: ImportAction, pub action: ImportAction,
#[serde(default)]
pub template: Option<String>,
} }
impl std::ops::Deref for ImportPreference { impl std::ops::Deref for ImportPreference {

View File

@ -57,6 +57,7 @@ psl = "2.1.4"
time = "0.3.30" time = "0.3.30"
url.workspace = true url.workspace = true
mime = "0.3.17" mime = "0.3.17"
minijinja.workspace = true
rand.workspace = true rand.workspace = true
rand_chacha = "0.3.1" rand_chacha = "0.3.1"
headers = "0.3.9" headers = "0.3.9"

View File

@ -20,7 +20,6 @@ use hyper::StatusCode;
use mas_axum_utils::{ use mas_axum_utils::{
cookies::CookieJar, http_client_factory::HttpClientFactory, sentry::SentryEventID, cookies::CookieJar, http_client_factory::HttpClientFactory, sentry::SentryEventID,
}; };
use mas_jose::claims::ClaimError;
use mas_keystore::{Encrypter, Keystore}; use mas_keystore::{Encrypter, Keystore};
use mas_oidc_client::requests::{ use mas_oidc_client::requests::{
authorization_code::AuthorizationValidationData, jose::JwtVerificationData, authorization_code::AuthorizationValidationData, jose::JwtVerificationData,
@ -83,8 +82,11 @@ pub(crate) enum RouteError {
#[error("Missing ID token")] #[error("Missing ID token")]
MissingIDToken, MissingIDToken,
#[error("Invalid ID token")] #[error("Could not extract subject from ID token")]
InvalidIdToken(#[from] ClaimError), ExtractSubject(#[source] minijinja::Error),
#[error("Subject is empty")]
EmptySubject,
#[error("Error from the provider: {error}")] #[error("Error from the provider: {error}")]
ClientError { ClientError {
@ -236,10 +238,27 @@ pub(crate) async fn get(
) )
.await?; .await?;
let (_header, mut id_token) = id_token.ok_or(RouteError::MissingIDToken)?.into_parts(); let (_header, id_token) = id_token.ok_or(RouteError::MissingIDToken)?.into_parts();
// Extract the subject from the id_token let env = {
let subject = mas_jose::claims::SUB.extract_required(&mut id_token)?; let mut env = minijinja::Environment::new();
env.add_global("user", minijinja::Value::from_serializable(&id_token));
env
};
let template = provider
.claims_imports
.subject
.template
.as_deref()
.unwrap_or("{{ user.sub }}");
let subject = env
.render_str(template, ())
.map_err(RouteError::ExtractSubject)?;
if subject.is_empty() {
return Err(RouteError::EmptySubject);
}
// Look for an existing link // Look for an existing link
let maybe_link = repo let maybe_link = repo

View File

@ -24,7 +24,7 @@ use mas_axum_utils::{
sentry::SentryEventID, sentry::SentryEventID,
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_data_model::{UpstreamOAuthProviderImportPreference, User}; use mas_data_model::{UpstreamOAuthProviderImportAction, User};
use mas_jose::jwt::Jwt; use mas_jose::jwt::Jwt;
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
@ -38,6 +38,7 @@ use mas_templates::{
ErrorContext, TemplateContext, Templates, UpstreamExistingLinkContext, UpstreamRegister, ErrorContext, TemplateContext, Templates, UpstreamExistingLinkContext, UpstreamRegister,
UpstreamSuggestLink, UpstreamSuggestLink,
}; };
use minijinja::Environment;
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
use ulid::Ulid; use ulid::Ulid;
@ -64,8 +65,13 @@ pub(crate) enum RouteError {
ProviderNotFound, ProviderNotFound,
/// Required claim was missing in id_token /// Required claim was missing in id_token
#[error("Required claim {0:?} missing from the upstream provider's response")] #[error("Template {template:?} could not be rendered from the upstream provider's response for required claim")]
RequiredClaimMissing(&'static str), RequiredAttributeRender {
template: String,
#[source]
source: minijinja::Error,
},
/// Session was already consumed /// Session was already consumed
#[error("Session already consumed")] #[error("Session already consumed")]
@ -119,15 +125,6 @@ impl IntoResponse for RouteError {
} }
} }
#[derive(Deserialize, Default)]
struct StandardClaims {
name: Option<String>,
email: Option<String>,
#[serde(default)]
email_verified: bool,
preferred_username: Option<String>,
}
/// Utility function to import a claim from the upstream provider's response, /// Utility function to import a claim from the upstream provider's response,
/// based on the preference for that attribute. /// based on the preference for that attribute.
/// ///
@ -144,23 +141,31 @@ struct StandardClaims {
/// ///
/// Returns an error if the claim is required but missing. /// Returns an error if the claim is required but missing.
fn import_claim( fn import_claim(
name: &'static str, environment: &Environment,
value: Option<String>, template: &str,
preference: &UpstreamOAuthProviderImportPreference, action: &UpstreamOAuthProviderImportAction,
mut run: impl FnMut(String, bool), mut run: impl FnMut(String, bool),
) -> Result<(), RouteError> { ) -> Result<(), RouteError> {
// If this claim is ignored, we don't need to do anything. // If this claim is ignored, we don't need to do anything.
if preference.ignore() { if action.ignore() {
return Ok(()); return Ok(());
} }
// If this claim is required and missing, we can't continue. match environment.render_str(template, ()) {
if value.is_none() && preference.is_required() { Ok(value) if value.is_empty() => { /* Do nothing on empty strings */ }
return Err(RouteError::RequiredClaimMissing(name));
}
if let Some(value) = value { Ok(value) => run(value, action.is_forced()),
run(value, preference.is_forced());
Err(source) => {
if action.is_required() {
return Err(RouteError::RequiredAttributeRender {
template: template.to_owned(),
source,
});
}
tracing::warn!(error = &source as &dyn std::error::Error, %template, "Error while rendering template");
}
} }
Ok(()) Ok(())
@ -321,7 +326,7 @@ pub(crate) async fn get(
// account or logging in an existing user // account or logging in an existing user
let id_token = upstream_session let id_token = upstream_session
.id_token() .id_token()
.map(Jwt::<'_, StandardClaims>::try_from) .map(Jwt::<'_, minijinja::Value>::try_from)
.transpose()?; .transpose()?;
let provider = repo let provider = repo
@ -336,9 +341,20 @@ pub(crate) async fn get(
let mut ctx = UpstreamRegister::new(&link); let mut ctx = UpstreamRegister::new(&link);
let env = {
let mut e = Environment::new();
e.add_global("user", payload);
e
};
import_claim( import_claim(
"name", &env,
payload.name, provider
.claims_imports
.displayname
.template
.as_deref()
.unwrap_or("{{ user.name }}"),
&provider.claims_imports.displayname, &provider.claims_imports.displayname,
|value, force| { |value, force| {
ctx.set_display_name(value, force); ctx.set_display_name(value, force);
@ -346,8 +362,13 @@ pub(crate) async fn get(
)?; )?;
import_claim( import_claim(
"email", &env,
payload.email, provider
.claims_imports
.email
.template
.as_deref()
.unwrap_or("{{ user.email }}"),
&provider.claims_imports.email, &provider.claims_imports.email,
|value, force| { |value, force| {
ctx.set_email(value, force); ctx.set_email(value, force);
@ -355,8 +376,13 @@ pub(crate) async fn get(
)?; )?;
import_claim( import_claim(
"preferred_username", &env,
payload.preferred_username, provider
.claims_imports
.localpart
.template
.as_deref()
.unwrap_or("{{ user.preferred_username }}"),
&provider.claims_imports.localpart, &provider.claims_imports.localpart,
|value, force| { |value, force| {
ctx.set_localpart(value, force); ctx.set_localpart(value, force);
@ -450,7 +476,7 @@ pub(crate) async fn post(
let id_token = upstream_session let id_token = upstream_session
.id_token() .id_token()
.map(Jwt::<'_, StandardClaims>::try_from) .map(Jwt::<'_, minijinja::Value>::try_from)
.transpose()?; .transpose()?;
let provider = repo let provider = repo
@ -463,12 +489,28 @@ pub(crate) async fn post(
.map(|id_token| id_token.into_parts().1) .map(|id_token| id_token.into_parts().1)
.unwrap_or_default(); .unwrap_or_default();
let provider_email_verified = payload
.get_item(&minijinja::Value::from("email_verified"))
.map(|v| v.is_true())
.unwrap_or(false);
// Let's try to import the claims from the ID token // Let's try to import the claims from the ID token
let env = {
let mut e = Environment::new();
e.add_global("user", payload);
e
};
let mut name = None; let mut name = None;
import_claim( import_claim(
"name", &env,
payload.name, provider
.claims_imports
.displayname
.template
.as_deref()
.unwrap_or("{{ user.name }}"),
&provider.claims_imports.displayname, &provider.claims_imports.displayname,
|value, force| { |value, force| {
// Import the display name if it is either forced or the user has requested it // Import the display name if it is either forced or the user has requested it
@ -480,8 +522,13 @@ pub(crate) async fn post(
let mut email = None; let mut email = None;
import_claim( import_claim(
"email", &env,
payload.email, provider
.claims_imports
.email
.template
.as_deref()
.unwrap_or("{{ user.email }}"),
&provider.claims_imports.email, &provider.claims_imports.email,
|value, force| { |value, force| {
// Import the email if it is either forced or the user has requested it // Import the email if it is either forced or the user has requested it
@ -493,8 +540,13 @@ pub(crate) async fn post(
let mut username = username; let mut username = username;
import_claim( import_claim(
"preferred_username", &env,
payload.preferred_username, provider
.claims_imports
.localpart
.template
.as_deref()
.unwrap_or("{{ user.preferred_username }}"),
&provider.claims_imports.localpart, &provider.claims_imports.localpart,
|value, force| { |value, force| {
// If the username is forced, override whatever was in the form // If the username is forced, override whatever was in the form
@ -535,13 +587,12 @@ pub(crate) async fn post(
.user_email() .user_email()
.add(&mut rng, &clock, &user, email) .add(&mut rng, &clock, &user, email)
.await?; .await?;
// Mark the email as verified according to the policy and whether the provider // Mark the email as verified according to the policy and whether the provider
// claims it is, and make it the primary email. // claims it is, and make it the primary email.
if provider if provider
.claims_imports .claims_imports
.verify_email .verify_email
.should_mark_as_verified(payload.email_verified) .should_mark_as_verified(provider_email_verified)
{ {
let user_email = repo let user_email = repo
.user_email() .user_email()

View File

@ -348,17 +348,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"displayname": { "displayname": {
"description": "Import the displayname of the user based on the `name` claim", "description": "Import the displayname of the user.",
"default": null, "default": {
"action": "ignore",
"template": null
},
"allOf": [ "allOf": [
{ {
"$ref": "#/definitions/ImportPreference" "$ref": "#/definitions/DisplaynameImportPreference"
} }
] ]
}, },
"email": { "email": {
"description": "Import the email address of the user based on the `email` and `email_verified` claims", "description": "Import the email address of the user based on the `email` and `email_verified` claims",
"default": null, "default": {
"action": "ignore",
"set_email_verification": "import",
"template": null
},
"allOf": [ "allOf": [
{ {
"$ref": "#/definitions/EmailImportPreference" "$ref": "#/definitions/EmailImportPreference"
@ -366,11 +373,25 @@
] ]
}, },
"localpart": { "localpart": {
"description": "Import the localpart of the MXID based on the `preferred_username` claim", "description": "Import the localpart of the MXID",
"default": null, "default": {
"action": "ignore",
"template": null
},
"allOf": [ "allOf": [
{ {
"$ref": "#/definitions/ImportPreference" "$ref": "#/definitions/LocalpartImportPreference"
}
]
},
"subject": {
"description": "How to determine the subject of the user",
"default": {
"template": null
},
"allOf": [
{
"$ref": "#/definitions/SubjectImportPreference"
} }
] ]
} }
@ -612,6 +633,26 @@
} }
} }
}, },
"DisplaynameImportPreference": {
"description": "What should be done for the displayname attribute",
"type": "object",
"properties": {
"action": {
"description": "How to handle the attribute",
"default": "ignore",
"allOf": [
{
"$ref": "#/definitions/ImportAction"
}
]
},
"template": {
"description": "The Jinja2 template to use for the displayname attribute\n\nIf not provided, the default template is `{{ user.name }}`",
"default": null,
"type": "string"
}
}
},
"EmailConfig": { "EmailConfig": {
"description": "Configuration related to sending emails", "description": "Configuration related to sending emails",
"type": "object", "type": "object",
@ -728,7 +769,7 @@
} }
}, },
"EmailImportPreference": { "EmailImportPreference": {
"description": "What should be done with the email claim", "description": "What should be done with the email attribute",
"type": "object", "type": "object",
"properties": { "properties": {
"action": { "action": {
@ -748,6 +789,11 @@
"$ref": "#/definitions/SetEmailVerification" "$ref": "#/definitions/SetEmailVerification"
} }
] ]
},
"template": {
"description": "The Jinja2 template to use for the email address attribute\n\nIf not provided, the default template is `{{ user.email }}`",
"default": null,
"type": "string"
} }
} }
}, },
@ -942,21 +988,6 @@
} }
] ]
}, },
"ImportPreference": {
"description": "What should be done with a claim",
"type": "object",
"properties": {
"action": {
"description": "How to handle the claim",
"default": "ignore",
"allOf": [
{
"$ref": "#/definitions/ImportAction"
}
]
}
}
},
"IpNetwork": { "IpNetwork": {
"oneOf": [ "oneOf": [
{ {
@ -1348,6 +1379,26 @@
} }
} }
}, },
"LocalpartImportPreference": {
"description": "What should be done for the localpart attribute",
"type": "object",
"properties": {
"action": {
"description": "How to handle the attribute",
"default": "ignore",
"allOf": [
{
"$ref": "#/definitions/ImportAction"
}
]
},
"template": {
"description": "The Jinja2 template to use for the localpart attribute\n\nIf not provided, the default template is `{{ user.preferred_username }}`",
"default": null,
"type": "string"
}
}
},
"MatrixConfig": { "MatrixConfig": {
"description": "Configuration related to the Matrix homeserver", "description": "Configuration related to the Matrix homeserver",
"type": "object", "type": "object",
@ -1914,6 +1965,17 @@
} }
] ]
}, },
"SubjectImportPreference": {
"description": "What should be done for the subject attribute",
"type": "object",
"properties": {
"template": {
"description": "The Jinja2 template to use for the subject attribute\n\nIf not provided, the default template is `{{ user.sub }}`",
"default": null,
"type": "string"
}
}
},
"TelemetryConfig": { "TelemetryConfig": {
"description": "Configuration related to sending monitoring data", "description": "Configuration related to sending monitoring data",
"type": "object", "type": "object",