Skip to main content

tuwunel_service/migrations/
account_status.rs

1use std::sync::Arc;
2
3use futures::TryStreamExt;
4use ruma::{OwnedUserId, UserId};
5use tuwunel_core::{
6	Result, err, info,
7	utils::{ReadyExt, option::OptionExt, stream::BroadbandExt},
8	warn,
9};
10use tuwunel_database::Map;
11
12use super::local_user_id;
13use crate::{
14	Services,
15	users::{PASSWORD_DISABLED, PASSWORD_SENTINEL},
16};
17
18/// Reconciles account states a foreign database keeps outside the password
19/// column.
20///
21/// Some databases mark deactivation in a column of its own while leaving the
22/// hash in place, and spell an account authenticated elsewhere as an empty
23/// hash. This server reads both states from the password alone, so until they
24/// are adopted a deactivated account reads as active and an externally
25/// authenticated one reads as deactivated. Adoption happens once, because
26/// local administration writes the same column afterward and a second pass
27/// would undo it.
28pub(super) async fn migrate_account_status(services: &Services) -> Result {
29	let deactivated = services.db.open_cf("userid_deactivated")?;
30	let subjects = services.db.open_cf("openidsubject_localpart")?;
31
32	if let Some(deactivated) = deactivated.as_ref() {
33		adopt_deactivations(services, deactivated).await?;
34	}
35
36	if let Some(subjects) = subjects.as_ref() {
37		adopt_passwordless(services, subjects, deactivated.as_ref()).await?;
38	}
39
40	Ok(())
41}
42
43/// Empties the password of every account a foreign column marks deactivated.
44///
45/// The marker is invisible here while the surviving hash reads as an active
46/// account, restoring a login the origin had already withdrawn. An empty
47/// password is the same state spelled locally, and costs the foreign hash,
48/// which decides nothing for an account deactivated on both sides.
49async fn adopt_deactivations(services: &Services, deactivated: &Arc<Map>) -> Result {
50	let userid_password = &services.db["userid_password"];
51	let cork = services.db.cork_and_sync();
52
53	let (adopted, unreadable) = deactivated
54		.keys::<&UserId>()
55		.map_ok(ToOwned::to_owned)
56		.broad_filter_map(async |account: Result<OwnedUserId>| {
57			let user_id = match account {
58				| Ok(user_id) => user_id,
59				| Err(e) => return Some(Err(e)),
60			};
61
62			match hash_empty(userid_password, &user_id).await {
63				| Ok(Some(false)) => Some(Ok(user_id)),
64				| Ok(_) => None,
65				| Err(e) => Some(Err(e)),
66			}
67		})
68		.ready_fold((0_usize, 0_usize), |counts, account| {
69			write_password(userid_password, PASSWORD_DISABLED, counts, account)
70		})
71		.await;
72
73	drop(cork);
74
75	if adopted > 0 {
76		info!(%adopted, "Adopted deactivated accounts from a foreign database");
77	}
78
79	unreadable
80		.eq(&0)
81		.then_some(())
82		.ok_or_else(|| err!(Database("{unreadable} accounts could not be read")))
83}
84
85/// Restores the sentinel password on accounts an identity provider
86/// authenticates.
87///
88/// A foreign database spells "no local password" as an empty hash, which reads
89/// here as deactivated and refuses the account every login flow. Only accounts
90/// carrying a provider subject are restored, because an empty hash on its own
91/// cannot be told apart from a deactivation this server wrote.
92///
93/// The sentinel carries that meaning locally, leaving the account active with
94/// no password to verify against, while an account the foreign column marks
95/// deactivated keeps its deactivation.
96async fn adopt_passwordless(
97	services: &Services,
98	subjects: &Arc<Map>,
99	deactivated: Option<&Arc<Map>>,
100) -> Result {
101	let userid_password = &services.db["userid_password"];
102	let server_name = services.globals.server_name();
103	let cork = services.db.cork_and_sync();
104
105	let (adopted, unreadable) = subjects
106		.stream()
107		.ready_filter_map(|subject: Result<(&str, &str)>| match subject {
108			| Ok((_, localpart)) => local_user_id(localpart, server_name).map(Ok),
109			| Err(e) => Some(Err(e)),
110		})
111		.broad_filter_map(async |account: Result<OwnedUserId>| {
112			let user_id = match account {
113				| Ok(user_id) => user_id,
114				| Err(e) => return Some(Err(e)),
115			};
116
117			match restorable(services, deactivated, &user_id).await {
118				| Ok(false) => None,
119				| Ok(true) => Some(Ok(user_id)),
120				| Err(e) => Some(Err(e)),
121			}
122		})
123		.ready_fold((0_usize, 0_usize), |counts, account| {
124			write_password(userid_password, PASSWORD_SENTINEL, counts, account)
125		})
126		.await;
127
128	drop(cork);
129
130	if adopted > 0 {
131		info!(%adopted, "Restored accounts authenticated elsewhere from a foreign database");
132	}
133
134	unreadable
135		.eq(&0)
136		.then_some(())
137		.ok_or_else(|| err!(Database("{unreadable} accounts could not be read")))
138}
139
140/// Reports whether the account reads as deactivated here without the foreign
141/// column marking it so.
142///
143/// The empty password a foreign database gives an account authenticated
144/// elsewhere is the byte pattern this server writes for a deactivation, so the
145/// foreign marker is the only thing separating them. A row neither side can
146/// read is reported rather than guessed at.
147async fn restorable(
148	services: &Services,
149	deactivated: Option<&Arc<Map>>,
150	user_id: &UserId,
151) -> Result<bool> {
152	let userid_password = &services.db["userid_password"];
153	let passwordless = hash_empty(userid_password, user_id)
154		.await?
155		.is_some_and(|empty| empty);
156
157	let marked = match deactivated
158		.map_async(|deactivated| deactivated.exists(user_id))
159		.await
160	{
161		| None => false,
162		| Some(Ok(())) => true,
163		| Some(Err(e)) if e.is_not_found() => false,
164		| Some(Err(e)) => return Err(e),
165	};
166
167	Ok(passwordless && !marked)
168}
169
170/// Whether the account's stored password is empty, or `None` when it has no
171/// row at all.
172///
173/// Both folds need the three states kept apart: a read failure is neither an
174/// active account nor a deactivated one, and mistaking it for either is how a
175/// pass that runs once leaves an account in the wrong state for good.
176async fn hash_empty(userid_password: &Arc<Map>, user_id: &UserId) -> Result<Option<bool>> {
177	match userid_password.get(user_id).await {
178		| Ok(hash) => Ok(Some(hash.is_empty())),
179		| Err(e) if e.is_not_found() => Ok(None),
180		| Err(e) => Err(e),
181	}
182}
183
184/// Writes one adopted account, tallying it against the rows that could not be
185/// read.
186fn write_password(
187	userid_password: &Arc<Map>,
188	password: &str,
189	(adopted, unreadable): (usize, usize),
190	account: Result<OwnedUserId>,
191) -> (usize, usize) {
192	match account {
193		| Ok(user_id) => {
194			userid_password.insert(&user_id, password);
195
196			(adopted.saturating_add(1), unreadable)
197		},
198		| Err(e) => {
199			warn!(error = %e, "an account could not be read");
200
201			(adopted, unreadable.saturating_add(1))
202		},
203	}
204}