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