Skip to main content

headless_lms_models/library/credit_registration/
fast_track.rs

1//! Linking a student number without the emailed round trip, when the study registry's primary
2//! address for a person is an address one of our accounts has proved it controls.
3//!
4//! This is a *terminal branch* taken before a linking mail is claimed, never a filter in front of
5//! one: every outcome other than [`FastTrackDecision::Link`] falls through to the ordinary mailed
6//! link. The population whose two addresses differ is exactly the population the linking mail exists
7//! for, and nothing here may narrow it.
8
9use std::collections::HashMap;
10
11use unicode_normalization::UnicodeNormalization;
12
13use crate::credit_registration_events::CreditRegistrationEventKind;
14use crate::prelude::*;
15use crate::user_details::EmailVerificationMethod;
16use crate::verified_student_numbers::{
17    NewVerifiedStudentNumber, StudentNumberVerificationMethod, replace_verified_student_number,
18};
19
20/// Recorded on the link as `verified_via_email_match_field`. The study registry's secondary address
21/// is self-entered, so it is never proof; the value is reserved rather than accepted.
22pub const MATCHED_FIELD_PRIMARY: &str = "primary";
23
24/// What an account offers the fast track, gathered in one query so the decision below stays pure.
25#[derive(Debug, Clone, PartialEq)]
26pub struct FastTrackCandidate {
27    pub user_id: Uuid,
28    pub email: String,
29    pub email_verified_at: Option<DateTime<Utc>>,
30    pub email_verified_method: Option<EmailVerificationMethod>,
31    pub first_name: Option<String>,
32    pub last_name: Option<String>,
33    /// Any live link, whatever its number: replacing one silently is worse than mailing the link,
34    /// whose confirmation screen names both numbers.
35    pub has_live_student_number: bool,
36    pub unlinked_a_fast_track_link_before: bool,
37}
38
39/// Why the fast track did or did not fire for one listed person. Every variant but `Link` means the
40/// ordinary linking mail is still owed.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum FastTrackDecision {
43    Link,
44    /// The registry's primary address is not an address of any live account here.
45    NoAccountMatch,
46    /// The account holds the address but has never proved control of it. Without that proof, address
47    /// equality is a one-request impersonation primitive, since the address is self-service editable.
48    UnverifiedAccount,
49    StaleVerification,
50    NameMismatch,
51    AccountHasStudentNumber,
52    UnlinkedBefore,
53}
54
55/// The names the study registry holds for a listed person, for the loose name check.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct RegistryName<'a> {
58    pub first_names: Option<&'a str>,
59    pub last_name: Option<&'a str>,
60}
61
62/// The whole fast-track predicate, as one pure function over facts the caller has already read.
63///
64/// `max_verification_age` bounds how old the proof may be: a deprovisioned university address can be
65/// reissued to a different person, and the account holding it would still look verified.
66/// [`EmailVerificationMethod::AdminAsserted`] is deliberately not accepted — it is support's word,
67/// not a proof of mailbox control, and it is the obvious social-engineering target.
68pub fn decide_fast_track(
69    candidate: Option<&FastTrackCandidate>,
70    registry_name: RegistryName<'_>,
71    now: DateTime<Utc>,
72    max_verification_age: chrono::Duration,
73) -> FastTrackDecision {
74    let Some(candidate) = candidate else {
75        return FastTrackDecision::NoAccountMatch;
76    };
77    let (Some(verified_at), Some(method)) =
78        (candidate.email_verified_at, candidate.email_verified_method)
79    else {
80        return FastTrackDecision::UnverifiedAccount;
81    };
82    if method == EmailVerificationMethod::AdminAsserted {
83        return FastTrackDecision::UnverifiedAccount;
84    }
85    if verified_at < now - max_verification_age {
86        return FastTrackDecision::StaleVerification;
87    }
88    if candidate.unlinked_a_fast_track_link_before {
89        return FastTrackDecision::UnlinkedBefore;
90    }
91    if candidate.has_live_student_number {
92        return FastTrackDecision::AccountHasStudentNumber;
93    }
94    if !names_loosely_match(registry_name, candidate) {
95        return FastTrackDecision::NameMismatch;
96    }
97    FastTrackDecision::Link
98}
99
100/// Whether the two records plausibly describe the same person: either surname or any one of the
101/// registry's given names has to agree, ignoring case and diacritics.
102///
103/// Loose on purpose. The ordinary case is a registry holding a full official name against an account
104/// holding a nickname, and punishing that would cost coverage for no safety. A mismatch is still a
105/// fall-through to the emailed link, which resolves the ambiguity by asking a human.
106fn names_loosely_match(registry_name: RegistryName<'_>, candidate: &FastTrackCandidate) -> bool {
107    let account_first = fold(candidate.first_name.as_deref().unwrap_or_default());
108    let account_last = fold(candidate.last_name.as_deref().unwrap_or_default());
109    let registry_last = fold(registry_name.last_name.unwrap_or_default());
110
111    if !account_last.is_empty() && account_last == registry_last {
112        return true;
113    }
114    if account_first.is_empty() {
115        return false;
116    }
117    registry_name
118        .first_names
119        .unwrap_or_default()
120        .split_whitespace()
121        .any(|given| fold(given) == account_first)
122}
123
124/// Casefolds and strips diacritics so `Mäkelä` and `Makela` compare equal.
125fn fold(name: &str) -> String {
126    name.trim()
127        .nfkd()
128        .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
129        .flat_map(char::to_lowercase)
130        .collect()
131}
132
133/// The one live account holding `primary_email`, with everything [`decide_fast_track`] needs.
134/// `None` when no account holds it, and also when more than one does — the fast track only ever acts
135/// on an unambiguous account, and ambiguity falls through to the mailed link like everything else.
136///
137/// Compares on `lower(email)`, the same normalisation the `users_email` unique index uses, and no
138/// other: inventing address equivalences (Gmail dot-stripping, `+tag` folding) inside a security
139/// predicate is how one earns a CVE. `sisu_person_id` is only used to tell an earlier automatic link
140/// this account already rejected from someone else's.
141///
142/// Locks the account row for the caller's transaction, so a concurrent profile edit cannot change
143/// the address between reading the proof and writing the link that rests on it.
144pub async fn find_fast_track_candidate(
145    conn: &mut PgConnection,
146    primary_email: &str,
147    sisu_person_id: &str,
148) -> ModelResult<Option<FastTrackCandidate>> {
149    let address = primary_email.trim();
150    if address.is_empty() {
151        return Ok(None);
152    }
153    let mut candidates = sqlx::query_as!(
154        FastTrackCandidate,
155        r#"
156SELECT ud.user_id,
157  ud.email,
158  ud.email_verified_at,
159  ud.email_verified_method,
160  ud.first_name,
161  ud.last_name,
162  EXISTS(
163    SELECT 1
164    FROM verified_student_numbers vsn
165    WHERE vsn.user_id = ud.user_id
166      AND vsn.deleted_at IS NULL
167  ) AS "has_live_student_number!",
168  EXISTS(
169    SELECT 1
170    FROM verified_student_numbers vsn
171    WHERE vsn.user_id = ud.user_id
172      AND vsn.sisu_person_id = $2
173      AND vsn.verified_via = 'email_match_fast_track'::student_number_verification_method
174      AND vsn.deleted_at IS NOT NULL
175  ) AS "unlinked_a_fast_track_link_before!"
176FROM user_details ud
177  JOIN users u ON u.id = ud.user_id
178WHERE LOWER(ud.email) = LOWER($1)
179  AND u.deleted_at IS NULL
180LIMIT 2 FOR SHARE OF ud
181        "#,
182        address,
183        sisu_person_id,
184    )
185    .fetch_all(conn)
186    .await?;
187    Ok((candidates.len() == 1).then(|| candidates.remove(0)))
188}
189
190/// [`find_fast_track_candidate`] for a whole roster in one query, keyed by `sisu_person_id`.
191///
192/// Same rules, and the same silence about an address more than one account holds. Deliberately
193/// without the row lock: this is the cheap pass that says which few people are worth a
194/// transaction, and each of those is read again under [`find_fast_track_candidate`]'s lock before
195/// anything is written.
196pub async fn find_fast_track_candidates(
197    conn: &mut PgConnection,
198    people: &[FastTrackLookup],
199) -> ModelResult<HashMap<String, FastTrackCandidate>> {
200    let (emails, person_ids): (Vec<String>, Vec<String>) = people
201        .iter()
202        .filter(|person| !person.primary_email.trim().is_empty())
203        .map(|person| {
204            (
205                person.primary_email.trim().to_string(),
206                person.sisu_person_id.clone(),
207            )
208        })
209        .unzip();
210    if emails.is_empty() {
211        return Ok(HashMap::new());
212    }
213    let rows = sqlx::query!(
214        r#"
215SELECT wanted.sisu_person_id AS "sisu_person_id!",
216  ud.user_id,
217  ud.email,
218  ud.email_verified_at,
219  ud.email_verified_method AS "email_verified_method?: EmailVerificationMethod",
220  ud.first_name,
221  ud.last_name,
222  EXISTS(
223    SELECT 1
224    FROM verified_student_numbers vsn
225    WHERE vsn.user_id = ud.user_id
226      AND vsn.deleted_at IS NULL
227  ) AS "has_live_student_number!",
228  EXISTS(
229    SELECT 1
230    FROM verified_student_numbers vsn
231    WHERE vsn.user_id = ud.user_id
232      AND vsn.sisu_person_id = wanted.sisu_person_id
233      AND vsn.verified_via = 'email_match_fast_track'::student_number_verification_method
234      AND vsn.deleted_at IS NOT NULL
235  ) AS "unlinked_a_fast_track_link_before!"
236FROM UNNEST($1::text [], $2::text []) AS wanted(email, sisu_person_id)
237  JOIN user_details ud ON LOWER(ud.email) = LOWER(wanted.email)
238  JOIN users u ON u.id = ud.user_id
239WHERE u.deleted_at IS NULL
240  AND (
241    SELECT COUNT(*)
242    FROM user_details other
243      JOIN users other_user ON other_user.id = other.user_id
244    WHERE LOWER(other.email) = LOWER(wanted.email)
245      AND other_user.deleted_at IS NULL
246  ) = 1
247        "#,
248        &emails,
249        &person_ids,
250    )
251    .fetch_all(conn)
252    .await?;
253    Ok(rows
254        .into_iter()
255        .map(|row| {
256            (
257                row.sisu_person_id,
258                FastTrackCandidate {
259                    user_id: row.user_id,
260                    email: row.email,
261                    email_verified_at: row.email_verified_at,
262                    email_verified_method: row.email_verified_method,
263                    first_name: row.first_name,
264                    last_name: row.last_name,
265                    has_live_student_number: row.has_live_student_number,
266                    unlinked_a_fast_track_link_before: row.unlinked_a_fast_track_link_before,
267                },
268            )
269        })
270        .collect())
271}
272
273/// One roster entry to look an account up by.
274#[derive(Debug, Clone, PartialEq)]
275pub struct FastTrackLookup {
276    pub primary_email: String,
277    pub sisu_person_id: String,
278}
279
280/// One person the fast track is about to link, as the caller read them off the registry's roster.
281#[derive(Debug, Clone, PartialEq)]
282pub struct FastTrackLink<'a> {
283    pub student_number: &'a str,
284    pub sisu_person_id: &'a str,
285    pub first_names: Option<&'a str>,
286    pub last_name: Option<&'a str>,
287    pub course_id: Uuid,
288}
289
290/// Links `person`'s student number to `candidate`'s account and returns the new link's id.
291///
292/// Goes through `replace_verified_student_number`, so it also soft-deletes the outstanding mailed
293/// tokens for that number — a link already in somebody's inbox must stop working once the number is
294/// linked — and recomputes the account's registrations. Caller must have decided
295/// [`FastTrackDecision::Link`] first; this function re-checks nothing.
296pub async fn link_by_email_match(
297    conn: &mut PgConnection,
298    person: &FastTrackLink<'_>,
299    candidate: &FastTrackCandidate,
300) -> ModelResult<Uuid> {
301    let (id, _) = replace_verified_student_number(
302        conn,
303        None,
304        &NewVerifiedStudentNumber {
305            user_id: candidate.user_id,
306            student_number: person.student_number.to_string(),
307            sisu_person_id: person.sisu_person_id.to_string(),
308            first_names: person.first_names.map(str::to_string),
309            last_name: person.last_name.map(str::to_string),
310            verified_via: StudentNumberVerificationMethod::EmailMatchFastTrack,
311            verified_via_email: Some(candidate.email.clone()),
312            verified_via_email_match_field: Some(MATCHED_FIELD_PRIMARY.to_string()),
313            // Frozen onto the row: the account's own flag is cleared the first time the student
314            // changes their address, and an audit years later still has to answer how old the proof
315            // was when the link was made.
316            account_email_verified_at: candidate.email_verified_at,
317            linked_by_user_id: None,
318            link_reason: None,
319            verified_from_course_id: Some(person.course_id),
320        },
321        None,
322        CreditRegistrationEventKind::Created,
323        "Linked automatically: the study registry holds this account's verified email address.",
324    )
325    .await?;
326    Ok(id)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    const YEAR: i64 = 365;
334
335    fn candidate() -> FastTrackCandidate {
336        FastTrackCandidate {
337            user_id: Uuid::new_v4(),
338            email: "aada.virtanen@helsinki.fi".to_string(),
339            email_verified_at: Some(Utc::now() - chrono::Duration::days(30)),
340            email_verified_method: Some(EmailVerificationMethod::EmailedCode),
341            first_name: Some("Aada".to_string()),
342            last_name: Some("Virtanen".to_string()),
343            has_live_student_number: false,
344            unlinked_a_fast_track_link_before: false,
345        }
346    }
347
348    fn decide(candidate: Option<&FastTrackCandidate>) -> FastTrackDecision {
349        decide_fast_track(
350            candidate,
351            RegistryName {
352                first_names: Some("Aada Maria"),
353                last_name: Some("Virtanen"),
354            },
355            Utc::now(),
356            chrono::Duration::days(YEAR),
357        )
358    }
359
360    #[test]
361    fn a_fresh_proof_on_a_matching_name_links() {
362        assert_eq!(decide(Some(&candidate())), FastTrackDecision::Link);
363    }
364
365    #[test]
366    fn an_address_no_account_holds_falls_through() {
367        assert_eq!(decide(None), FastTrackDecision::NoAccountMatch);
368    }
369
370    #[test]
371    fn an_unproven_address_never_links() {
372        let unverified = FastTrackCandidate {
373            email_verified_at: None,
374            email_verified_method: None,
375            ..candidate()
376        };
377        assert_eq!(
378            decide(Some(&unverified)),
379            FastTrackDecision::UnverifiedAccount
380        );
381    }
382
383    #[test]
384    fn support_asserting_an_address_is_not_a_proof_of_mailbox_control() {
385        let asserted = FastTrackCandidate {
386            email_verified_method: Some(EmailVerificationMethod::AdminAsserted),
387            ..candidate()
388        };
389        assert_eq!(
390            decide(Some(&asserted)),
391            FastTrackDecision::UnverifiedAccount
392        );
393    }
394
395    #[test]
396    fn a_proof_older_than_the_window_never_links() {
397        let stale = FastTrackCandidate {
398            email_verified_at: Some(Utc::now() - chrono::Duration::days(YEAR + 1)),
399            ..candidate()
400        };
401        assert_eq!(decide(Some(&stale)), FastTrackDecision::StaleVerification);
402    }
403
404    #[test]
405    fn an_account_that_already_holds_a_number_is_left_to_the_mailed_link() {
406        let linked = FastTrackCandidate {
407            has_live_student_number: true,
408            ..candidate()
409        };
410        assert_eq!(
411            decide(Some(&linked)),
412            FastTrackDecision::AccountHasStudentNumber
413        );
414    }
415
416    /// Otherwise the one-click unlink would be undone by the next roster listing.
417    #[test]
418    fn an_account_that_unlinked_this_person_before_is_not_relinked() {
419        let rejected = FastTrackCandidate {
420            unlinked_a_fast_track_link_before: true,
421            ..candidate()
422        };
423        assert_eq!(decide(Some(&rejected)), FastTrackDecision::UnlinkedBefore);
424    }
425
426    #[test]
427    fn a_wholly_different_name_falls_through() {
428        let stranger = FastTrackCandidate {
429            first_name: Some("Bertta".to_string()),
430            last_name: Some("Korhonen".to_string()),
431            ..candidate()
432        };
433        assert_eq!(decide(Some(&stranger)), FastTrackDecision::NameMismatch);
434    }
435
436    #[test]
437    fn one_matching_given_name_is_enough() {
438        let nickname = FastTrackCandidate {
439            first_name: Some("Maria".to_string()),
440            last_name: Some("Married-Name".to_string()),
441            ..candidate()
442        };
443        assert_eq!(decide(Some(&nickname)), FastTrackDecision::Link);
444    }
445
446    #[test]
447    fn diacritics_and_case_do_not_make_two_names_differ() {
448        let folded = FastTrackCandidate {
449            first_name: Some("aada".to_string()),
450            last_name: Some("VIRTÄNEN".to_string()),
451            ..candidate()
452        };
453        assert_eq!(decide(Some(&folded)), FastTrackDecision::Link);
454    }
455}