Skip to main content

headless_lms_models/
credit_registration_events.rs

1//! Append-only audit trail for the credit registration ledger.
2//!
3//! No retention sweep touches this table, so every Suotar payload must go through
4//! [`scrub_suotar_body`] at the write site — redacting on read would leave the raw values on disk.
5use std::sync::LazyLock;
6
7use regex::{Captures, Regex};
8use serde_json::{Value, json};
9use utoipa::ToSchema;
10
11use crate::credit_registrations::{CreditRegistrationErrorCode, CreditRegistrationState};
12use crate::prelude::*;
13
14/// Replaces a redacted value; the key is kept so the payload shape survives.
15pub const REDACTED: &str = "[redacted]";
16
17/// Keys whose values identify a person or authenticate a request. Matched case-insensitively at any
18/// depth.
19const REDACTED_KEYS: &[&str] = &[
20    "studentnumber",
21    "firstnames",
22    "lastname",
23    "fullname",
24    "primaryemail",
25    "secondaryemail",
26    "email",
27    "emailedto",
28    "accesstoken",
29    "personid",
30    "sisupersonid",
31];
32
33/// Keys whose values the value scan must leave alone: they carry ids shaped like student numbers —
34/// `cr-{uuid}` request item ids, Sisu ids such as `hy-CUR-135176012` — that the scan would mangle.
35///
36/// Container keys do not belong here: an exemption stops at the objects below it.
37const NEVER_SCANNED_KEYS: &[&str] = &[
38    "requestitemid",
39    "code",
40    "coursecode",
41    "enrolmentid",
42    "gradescaleid",
43    "gradeid",
44    "credits",
45    "attainmentdate",
46    "attainmentlanguage",
47    "submittedattainmentid",
48    "attainmentid",
49    "sisuattainmentid",
50    "courseunitrealisationid",
51    "openuniversityproductid",
52];
53
54static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
55    Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}").expect("hardcoded regex")
56});
57
58/// Student-number-shaped digit runs. The id shapes come first because the `regex` crate has no
59/// lookaround: leftmost-first alternation is the only way to say "digits not part of an id". The
60/// word boundaries keep the digit branch off longer numbers such as millisecond timestamps.
61///
62/// Known cost of the `prefixed` branch: a student number hyphen-joined to a word,
63/// `person-012345678`, survives. A bare digit run, the shape Suotar's messages use, still goes.
64static STUDENT_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
65    Regex::new(
66        r"(?P<uuid>\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b)|(?P<prefixed>\b[A-Za-z][A-Za-z0-9-]*-[0-9]{6,12}\b)|(?P<digits>\b[0-9]{6,12}\b)",
67    )
68    .expect("hardcoded regex")
69});
70
71/// Best-effort removal of personal data from a Suotar request or response body. Not a guarantee and
72/// not an exhaustive PII filter.
73///
74/// Two mechanisms, because either alone leaks: key matching removes the structured person fields,
75/// and the free-text scan is the backstop for the input Suotar's error messages quote back. A key's
76/// treatment covers its own scalar value and the scalars of an array under it, but objects are
77/// always classified again key by key, so an exemption never spreads over a subtree.
78///
79/// The scan only removes email addresses and student-number-shaped digit runs. A name in free text
80/// is kept: no pattern separates it from error prose, and the study registry holds it anyway.
81pub fn scrub_suotar_body(value: &Value) -> Value {
82    scrub(value)
83}
84
85enum KeyPolicy {
86    /// Replaced by [`REDACTED`], subtree and all.
87    FullyRedact,
88    /// Passed through verbatim, except for objects, which are classified by their own keys.
89    NeverScan,
90    /// The default.
91    ScanFreeText,
92}
93
94fn key_policy(key: &str) -> KeyPolicy {
95    let normalized = normalize_key(key);
96    if REDACTED_KEYS.contains(&normalized.as_str()) {
97        KeyPolicy::FullyRedact
98    } else if NEVER_SCANNED_KEYS.contains(&normalized.as_str()) {
99        KeyPolicy::NeverScan
100    } else {
101        KeyPolicy::ScanFreeText
102    }
103}
104
105fn scrub(value: &Value) -> Value {
106    match value {
107        Value::Object(map) => Value::Object(
108            map.iter()
109                .map(|(key, child)| {
110                    let scrubbed = match key_policy(key) {
111                        KeyPolicy::FullyRedact => json!(REDACTED),
112                        KeyPolicy::NeverScan => keep_scalars(child),
113                        KeyPolicy::ScanFreeText => scrub(child),
114                    };
115                    (key.clone(), scrubbed)
116                })
117                .collect(),
118        ),
119        Value::Array(items) => Value::Array(items.iter().map(scrub).collect()),
120        Value::String(text) => Value::String(scrub_free_text(text)),
121        other => other.clone(),
122    }
123}
124
125/// Keeps bare ids and lists of ids, but hands objects back to [`scrub`] so an exemption cannot
126/// smuggle a nested error message past the value scan.
127fn keep_scalars(value: &Value) -> Value {
128    match value {
129        Value::Object(_) => scrub(value),
130        Value::Array(items) => Value::Array(items.iter().map(keep_scalars).collect()),
131        other => other.clone(),
132    }
133}
134
135fn normalize_key(key: &str) -> String {
136    key.chars()
137        .filter(|c| c.is_ascii_alphanumeric())
138        .flat_map(|c| c.to_lowercase())
139        .collect()
140}
141
142fn scrub_free_text(text: &str) -> String {
143    let without_emails = EMAIL_RE.replace_all(text, REDACTED);
144    STUDENT_NUMBER_RE
145        .replace_all(&without_emails, |captures: &Captures| {
146            // The id branches exist to beat the digit branch to the match; put them back unchanged.
147            if captures.name("digits").is_some() {
148                REDACTED.to_string()
149            } else {
150                captures[0].to_string()
151            }
152        })
153        .into_owned()
154}
155
156/// Both sides of a Suotar exchange, scrubbed on construction so there is no way to build an
157/// unscrubbed `details`. The request is kept because the ledger row no longer reflects it after a
158/// retry.
159pub fn suotar_exchange_details(request: Option<&Value>, response: Option<&Value>) -> Value {
160    let mut details = serde_json::Map::new();
161    if let Some(request) = request {
162        details.insert("request".to_string(), scrub_suotar_body(request));
163    }
164    if let Some(response) = response {
165        details.insert("response".to_string(), scrub_suotar_body(response));
166    }
167    Value::Object(details)
168}
169
170#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
171#[sqlx(
172    type_name = "credit_registration_event_kind",
173    rename_all = "snake_case"
174)]
175#[serde(rename_all = "snake_case")]
176pub enum CreditRegistrationEventKind {
177    Created,
178    StateChanged,
179    SuotarResponse,
180    RetryScheduled,
181    AdminAction,
182    StudentAction,
183    Cancelled,
184}
185
186#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
187pub struct CreditRegistrationEvent {
188    pub id: Uuid,
189    pub created_at: DateTime<Utc>,
190    pub updated_at: DateTime<Utc>,
191    pub deleted_at: Option<DateTime<Utc>>,
192    pub credit_registration_id: Uuid,
193    pub kind: CreditRegistrationEventKind,
194    pub from_state: Option<CreditRegistrationState>,
195    pub to_state: Option<CreditRegistrationState>,
196    pub error_code: Option<CreditRegistrationErrorCode>,
197    pub message: Option<String>,
198    pub suotar_api_call_id: Option<Uuid>,
199    pub actor_user_id: Option<Uuid>,
200    pub details: Option<Value>,
201}
202
203#[derive(Debug, Clone, PartialEq)]
204pub struct NewCreditRegistrationEvent {
205    pub credit_registration_id: Uuid,
206    pub kind: CreditRegistrationEventKind,
207    pub from_state: Option<CreditRegistrationState>,
208    pub to_state: Option<CreditRegistrationState>,
209    pub error_code: Option<CreditRegistrationErrorCode>,
210    pub message: Option<String>,
211    pub suotar_api_call_id: Option<Uuid>,
212    pub actor_user_id: Option<Uuid>,
213    /// Build with [`suotar_exchange_details`] so it is scrubbed.
214    pub details: Option<Value>,
215}
216
217impl NewCreditRegistrationEvent {
218    pub fn new(credit_registration_id: Uuid, kind: CreditRegistrationEventKind) -> Self {
219        Self {
220            credit_registration_id,
221            kind,
222            from_state: None,
223            to_state: None,
224            error_code: None,
225            message: None,
226            suotar_api_call_id: None,
227            actor_user_id: None,
228            details: None,
229        }
230    }
231}
232
233/// Callers that also change `state` must go through `credit_registrations::transition` instead,
234/// which writes both in one transaction.
235pub async fn insert(
236    conn: &mut PgConnection,
237    new: &NewCreditRegistrationEvent,
238) -> ModelResult<Uuid> {
239    let res = sqlx::query!(
240        r#"
241INSERT INTO credit_registration_events (
242    credit_registration_id,
243    kind,
244    from_state,
245    to_state,
246    error_code,
247    message,
248    suotar_api_call_id,
249    actor_user_id,
250    details
251  )
252VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
253RETURNING id
254        "#,
255        new.credit_registration_id,
256        new.kind as CreditRegistrationEventKind,
257        new.from_state as Option<CreditRegistrationState>,
258        new.to_state as Option<CreditRegistrationState>,
259        new.error_code as Option<CreditRegistrationErrorCode>,
260        new.message,
261        new.suotar_api_call_id,
262        new.actor_user_id,
263        new.details,
264    )
265    .fetch_one(conn)
266    .await?;
267    Ok(res.id)
268}
269
270/// The per-item timeline, newest first.
271pub async fn get_by_registration_id(
272    conn: &mut PgConnection,
273    credit_registration_id: Uuid,
274) -> ModelResult<Vec<CreditRegistrationEvent>> {
275    let res = sqlx::query_as!(
276        CreditRegistrationEvent,
277        r#"
278SELECT *
279FROM credit_registration_events
280WHERE credit_registration_id = $1
281  AND deleted_at IS NULL
282ORDER BY created_at DESC
283        "#,
284        credit_registration_id
285    )
286    .fetch_all(conn)
287    .await?;
288    Ok(res)
289}
290
291pub async fn get_recent_by_kind(
292    conn: &mut PgConnection,
293    kind: CreditRegistrationEventKind,
294    limit: i64,
295) -> ModelResult<Vec<CreditRegistrationEvent>> {
296    let res = sqlx::query_as!(
297        CreditRegistrationEvent,
298        r#"
299SELECT *
300FROM credit_registration_events
301WHERE kind = $1
302  AND deleted_at IS NULL
303ORDER BY created_at DESC
304LIMIT $2
305        "#,
306        kind as CreditRegistrationEventKind,
307        limit,
308    )
309    .fetch_all(conn)
310    .await?;
311    Ok(res)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn redacts_by_key_name_but_keeps_the_key() {
320        let scrubbed = scrub_suotar_body(&json!({
321            "studentNumber": "012345678",
322            "firstNames": "Aada Maria",
323            "lastName": "Virtanen",
324            "primaryEmail": "aada@helsinki.fi",
325            "accessToken": "abc123",
326            "personId": "hy-hlo-1",
327        }));
328        assert_eq!(
329            scrubbed,
330            json!({
331                "studentNumber": REDACTED,
332                "firstNames": REDACTED,
333                "lastName": REDACTED,
334                "primaryEmail": REDACTED,
335                "accessToken": REDACTED,
336                "personId": REDACTED,
337            })
338        );
339    }
340
341    #[test]
342    fn keeps_the_fields_debugging_needs() {
343        // These ids carry student-number-shaped digit runs the value scan must not touch.
344        let body = json!({
345            "requestItemId": "cr-2a4b0d6e-0000-4000-8000-000000000001",
346            "code": "sent",
347            "status": "ok",
348            "courseCode": "AYTKT21018",
349            "enrolmentId": "hy-CUR-135176012",
350            "gradeScaleId": "sis-0-5",
351            "gradeId": "4",
352            "credits": 5.0,
353            "attainmentDate": "2026-07-30",
354            "attainmentLanguage": "fi",
355            "submittedAttainmentId": "hy-att-1",
356        });
357        assert_eq!(scrub_suotar_body(&body), body);
358    }
359
360    #[test]
361    fn redacts_recursively_through_objects_and_arrays() {
362        let scrubbed = scrub_suotar_body(&json!({
363            "items": [
364                { "person": { "studentNumber": "012345678" }, "code": "ok" },
365                { "person": { "studentNumber": "012345679" }, "code": "ok" },
366            ]
367        }));
368        assert_eq!(
369            scrubbed,
370            json!({
371                "items": [
372                    { "person": { "studentNumber": REDACTED }, "code": "ok" },
373                    { "person": { "studentNumber": REDACTED }, "code": "ok" },
374                ]
375            })
376        );
377    }
378
379    #[test]
380    fn matches_keys_case_insensitively_and_across_naming_styles() {
381        let scrubbed = scrub_suotar_body(&json!({
382            "STUDENT_NUMBER": "012345678",
383            "Student-Number": "012345678",
384            "emailedTo": "aada@helsinki.fi",
385        }));
386        assert_eq!(
387            scrubbed,
388            json!({
389                "STUDENT_NUMBER": REDACTED,
390                "Student-Number": REDACTED,
391                "emailedTo": REDACTED,
392            })
393        );
394    }
395
396    #[test]
397    fn value_scan_catches_identifiers_quoted_in_free_text() {
398        // Suotar error messages quote the input, so key-based redaction alone would leak here.
399        let scrubbed = scrub_suotar_body(&json!({
400            "message": "Person 012345678 (aada@helsinki.fi) has no accepted enrolment",
401        }));
402        assert_eq!(
403            scrubbed,
404            json!({
405                "message": format!("Person {REDACTED} ({REDACTED}) has no accepted enrolment"),
406            })
407        );
408    }
409
410    #[test]
411    fn a_never_scanned_key_covers_the_ids_in_a_list_under_it() {
412        let body = json!({ "enrolmentId": ["hy-CUR-135176012", "hy-CUR-135176013"] });
413        assert_eq!(scrub_suotar_body(&body), body);
414
415        // An object in that list is classified by its own keys, so the exemption stops there.
416        let mixed = json!({
417            "code": ["hy-CUR-135176012", { "message": "Person 012345678 not found" }],
418        });
419        assert_eq!(
420            scrub_suotar_body(&mixed),
421            json!({
422                "code": [
423                    "hy-CUR-135176012",
424                    { "message": format!("Person {REDACTED} not found") },
425                ],
426            })
427        );
428    }
429
430    #[test]
431    fn a_redacted_key_takes_its_whole_value_with_it() {
432        // Over-redacting a person field costs debuggability; under-redacting is a permanent leak.
433        let scrubbed = scrub_suotar_body(&json!({
434            "firstNames": ["Aada", "Maria"],
435            "personId": { "value": "hy-hlo-1" },
436        }));
437        assert_eq!(
438            scrubbed,
439            json!({ "firstNames": REDACTED, "personId": REDACTED })
440        );
441    }
442
443    #[test]
444    fn value_scan_reaches_inside_a_never_scanned_key() {
445        // Suotar hangs per-item errors off an exempt key; the exemption covers only its own scalar.
446        let scrubbed = scrub_suotar_body(&json!({
447            "items": [{
448                "status": {
449                    "code": "personNotFound",
450                    "message": "Person 012345678 (aada@helsinki.fi) not found",
451                },
452            }],
453        }));
454        assert_eq!(
455            scrubbed,
456            json!({
457                "items": [{
458                    "status": {
459                        "code": "personNotFound",
460                        "message": format!("Person {REDACTED} ({REDACTED}) not found"),
461                    },
462                }],
463            })
464        );
465    }
466
467    #[test]
468    fn value_scan_keeps_request_item_ids_quoted_in_free_text_whole() {
469        // The last UUID group is 12 digits, so a naive digit-run scan would eat it.
470        let body = json!({ "message": "item cr-2a4b0d6e-0000-4000-8000-000000000001 rejected" });
471        assert_eq!(scrub_suotar_body(&body), body);
472
473        // An all-digit first group must not tip the alternation into the digit branch.
474        let numeric = json!({ "message": "item 12345678-1234-4321-8765-123456789012 rejected" });
475        assert_eq!(scrub_suotar_body(&numeric), numeric);
476
477        // A Sisu id is kept whole for the same reason, even though its tail is digits only.
478        let sisu = json!({ "message": "enrolment hy-CUR-135176012 rejected" });
479        assert_eq!(scrub_suotar_body(&sisu), sisu);
480
481        // Accepted cost: a student number hyphen-joined to a word reads as a prefixed id and
482        // survives. A bare run still goes, which is the shape Suotar sends.
483        let adjacent = json!({ "message": "person-012345678 and 012345678 not found" });
484        assert_eq!(
485            scrub_suotar_body(&adjacent),
486            json!({ "message": format!("person-012345678 and {REDACTED} not found") })
487        );
488    }
489
490    #[test]
491    fn free_text_names_are_deliberately_kept() {
492        // No pattern separates a name from error prose; only the known person keys are redacted.
493        let scrubbed = scrub_suotar_body(&json!({
494            "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
495            "fullName": "Aada Maria Virtanen",
496        }));
497        assert_eq!(
498            scrubbed,
499            json!({
500                "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
501                "fullName": REDACTED,
502            })
503        );
504    }
505
506    #[test]
507    fn value_scan_leaves_short_and_long_digit_runs_alone() {
508        let body = json!({ "message": "code 404 after 1234567890123 ms" });
509        assert_eq!(scrub_suotar_body(&body), body);
510    }
511
512    #[test]
513    fn scrubbing_is_idempotent() {
514        let body = json!({
515            "studentNumber": "012345678",
516            "message": "Person 012345678 not found",
517        });
518        let once = scrub_suotar_body(&body);
519        assert_eq!(scrub_suotar_body(&once), once);
520    }
521
522    #[test]
523    fn exchange_details_scrub_both_sides_and_omit_missing_ones() {
524        let details = suotar_exchange_details(
525            Some(&json!({ "studentNumber": "012345678" })),
526            Some(&json!({ "code": "sent", "fullName": "Aada Virtanen" })),
527        );
528        assert_eq!(
529            details,
530            json!({
531                "request": { "studentNumber": REDACTED },
532                "response": { "code": "sent", "fullName": REDACTED },
533            })
534        );
535
536        let request_only = suotar_exchange_details(Some(&json!({ "code": "x" })), None);
537        assert_eq!(request_only, json!({ "request": { "code": "x" } }));
538    }
539}