Skip to main content

tuwunel_api_mas/
provision_user.rs

1use std::collections::BTreeSet;
2
3use axum::extract::State;
4use futures::{StreamExt, TryStreamExt};
5use ruma::{MilliSecondsSinceUnixEpoch, UserId, profile::ProfileFieldName, thirdparty::Medium};
6use synapse_admin_api::mas::provision_user::{Request, Response};
7use tuwunel_core::{
8	Result,
9	arrayvec::ArrayVec,
10	utils::{
11		IterStream, ReadyExt,
12		option::OptionExt,
13		stream::{TryBroadbandExt, automatic_width},
14		string::truncate_chars,
15	},
16	warn,
17};
18use tuwunel_service::{
19	profile::{MAX_DISPLAYNAME_LENGTH, ProfileValue, string_field},
20	threepid::canonicalize_email,
21	users::PASSWORD_SENTINEL,
22};
23
24use super::{Mas, local_user};
25use crate::Ruma;
26
27type ProfileWrites = ArrayVec<ProfileValue, 2>;
28
29/// # `POST /_synapse/mas/provision_user`
30pub(crate) async fn provision_user_route(
31	_mas: Mas,
32	State(services): State<crate::State>,
33	body: Ruma<Request>,
34) -> Result<Response> {
35	let user_id = local_user(services, &body.localpart)?;
36
37	// Canonicalize up front so a malformed address fails before any mutation.
38	let desired_emails: Option<BTreeSet<String>> = if body.unset_emails {
39		Some(BTreeSet::new())
40	} else {
41		body.set_emails
42			.as_deref()
43			.map(canonicalize_emails)
44			.transpose()?
45	};
46
47	let created = !services.users.exists(&user_id).await;
48	let profile = requested_profile(services, &user_id, &body).await;
49
50	let account = create_account(services, &user_id, created);
51
52	// A refused profile value returns before the account exists.
53	services
54		.profile
55		.set_profile_keys_after(&user_id, &profile, None, account)
56		.await?;
57
58	if let Some(desired) = desired_emails {
59		sync_emails(services, &user_id, desired).await?;
60	}
61
62	match body.locked {
63		| Some(true) => services
64			.users
65			.set_locked(&user_id, &services.globals.server_user),
66		| Some(false) => services.users.clear_locked(&user_id),
67		| None => {},
68	}
69
70	Ok(Response::new(created))
71}
72
73fn canonicalize_emails(addrs: &[String]) -> Result<BTreeSet<String>> {
74	addrs
75		.iter()
76		.map(|a| canonicalize_email(a))
77		.collect()
78}
79
80/// The display name and avatar URL the request sets or unsets.
81///
82/// MAS relays the upstream provider's display name, which the user may have no
83/// way to shorten there, so an overlong one is cut to the profile's limit
84/// rather than refused.
85async fn requested_profile(
86	services: crate::State,
87	user_id: &UserId,
88	body: &Request,
89) -> ProfileWrites {
90	let displayname = body
91		.set_displayname
92		.as_deref()
93		.map_async(|name| fitted_displayname(services, user_id, name))
94		.await;
95
96	let touch_displayname = displayname.is_some() || body.unset_displayname;
97	let touch_avatar = body.set_avatar_url.is_some() || body.unset_avatar_url;
98	let avatar_url = body.set_avatar_url.as_deref();
99
100	[
101		touch_displayname.then(|| string_field(ProfileFieldName::DisplayName, displayname)),
102		touch_avatar.then(|| string_field(ProfileFieldName::AvatarUrl, avatar_url)),
103	]
104	.into_iter()
105	.flatten()
106	.collect()
107}
108
109// A name equal to the stored one is restated whole, as the profile accepts it.
110async fn fitted_displayname<'a>(
111	services: crate::State,
112	user_id: &UserId,
113	name: &'a str,
114) -> &'a str {
115	let fitted = truncate_chars(name, MAX_DISPLAYNAME_LENGTH);
116
117	if fitted.len() == name.len() {
118		return name;
119	}
120
121	let restated = services
122		.profile
123		.displayname(user_id)
124		.await
125		.is_ok_and(|stored| stored == name);
126
127	if restated { name } else { fitted }
128}
129
130#[tracing::instrument(level = "debug", skip_all)]
131async fn create_account(services: crate::State, user_id: &UserId, created: bool) -> Result {
132	if !created {
133		return Ok(());
134	}
135
136	services
137		.users
138		.create(user_id, Some(PASSWORD_SENTINEL), Some("oidc"))
139		.await
140}
141
142/// Reconcile the user's bound email addresses to exactly `desired`. A desired
143/// address bound to another user is reassigned, its prior binding removed first
144/// so no dangling forward row survives.
145async fn sync_emails(
146	services: crate::State,
147	user_id: &UserId,
148	desired: BTreeSet<String>,
149) -> Result {
150	let current: BTreeSet<String> = services
151		.threepid
152		.get_bindings(user_id)
153		.ready_filter_map(|tpid| (tpid.medium == Medium::Email).then_some(tpid.address))
154		.collect()
155		.await;
156
157	current
158		.difference(&desired)
159		.stream()
160		.for_each_concurrent(automatic_width(), |address| {
161			services.threepid.del_binding(user_id, address)
162		})
163		.await;
164
165	let now = MilliSecondsSinceUnixEpoch::now();
166
167	desired
168		.difference(&current)
169		.try_stream()
170		.broad_and_then(async |address| {
171			if let Some(prior) = services
172				.threepid
173				.user_id_for_email(address)
174				.await?
175				&& prior != user_id
176			{
177				warn!(%user_id, %prior, "MAS provisioned an email bound to another user; reassigning");
178
179				services
180					.threepid
181					.del_binding(&prior, address)
182					.await;
183			}
184
185			services
186				.threepid
187				.put_binding(user_id, address, Medium::Email, now, now)
188				.await;
189
190			Ok(())
191		})
192		.try_collect::<()>()
193		.await
194}