Skip to main content

headless_lms_models/library/credit_registration/
scrub.rs

1//! Removing personal data from what the credit registration pipeline keeps of its Suotar
2//! exchanges: the call log's bodies, the ledger's event details and error messages.
3
4use std::sync::LazyLock;
5
6use regex::{Captures, Regex};
7use serde_json::{Value, json};
8
9/// Replaces a redacted value; the key is kept so the payload shape survives.
10pub const REDACTED: &str = "[redacted]";
11
12/// Keys whose values identify a person or authenticate a request. Matched case-insensitively at any
13/// depth.
14const REDACTED_KEYS: &[&str] = &[
15    "studentnumber",
16    "firstnames",
17    "lastname",
18    "fullname",
19    "primaryemail",
20    "secondaryemail",
21    "email",
22    "emailedto",
23    "accesstoken",
24    "personid",
25    "sisupersonid",
26];
27
28/// Keys whose values the value scan must leave alone: they carry ids shaped like student numbers —
29/// request item ids, Sisu ids such as `hy-CUR-135176012` — that the scan would mangle.
30///
31/// Container keys do not belong here: an exemption stops at the objects below it.
32const NEVER_SCANNED_KEYS: &[&str] = &[
33    "requestitemid",
34    "code",
35    "coursecode",
36    "gradescaleid",
37    "gradeid",
38    "credits",
39    "attainmentdate",
40    "attainmentlanguage",
41    "submittedattainmentid",
42    "attainmentid",
43    "sisuattainmentid",
44    "courseunitrealisationid",
45];
46
47static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
48    Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}").expect("hardcoded regex")
49});
50
51/// Student-number-shaped digit runs. The id shapes come first because the `regex` crate has no
52/// lookaround: leftmost-first alternation is the only way to say "digits not part of an id". The
53/// word boundaries keep the digit branch off longer numbers such as millisecond timestamps.
54///
55/// Known cost of the `prefixed` branch: a student number hyphen-joined to a word,
56/// `person-012345678`, survives. A bare digit run, the shape Suotar's messages use, still goes.
57static STUDENT_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
58    Regex::new(
59        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)",
60    )
61    .expect("hardcoded regex")
62});
63
64/// Best-effort removal of personal data from a Suotar request or response body. Not a guarantee and
65/// not an exhaustive PII filter.
66///
67/// Two mechanisms, because either alone leaks: key matching removes the structured person fields,
68/// and the free-text scan is the backstop for the input Suotar's error messages quote back. A key's
69/// treatment covers its own scalar value and the scalars of an array under it, but objects are
70/// always classified again key by key, so an exemption never spreads over a subtree.
71///
72/// The scan only removes email addresses and student-number-shaped digit runs. A name in free text
73/// is kept: no pattern separates it from error prose, and the study registry holds it anyway.
74pub fn scrub_suotar_body(value: &Value) -> Value {
75    match value {
76        Value::Object(map) => Value::Object(
77            map.iter()
78                .map(|(key, child)| {
79                    let scrubbed = match key_policy(key) {
80                        KeyPolicy::FullyRedact => json!(REDACTED),
81                        KeyPolicy::NeverScan => keep_scalars(child),
82                        KeyPolicy::ScanFreeText => scrub_suotar_body(child),
83                    };
84                    (key.clone(), scrubbed)
85                })
86                .collect(),
87        ),
88        Value::Array(items) => Value::Array(items.iter().map(scrub_suotar_body).collect()),
89        Value::String(text) => Value::String(scrub_text(text)),
90        other => other.clone(),
91    }
92}
93
94enum KeyPolicy {
95    /// Replaced by [`REDACTED`], subtree and all.
96    FullyRedact,
97    /// Passed through verbatim, except for objects, which are classified by their own keys.
98    NeverScan,
99    /// The default.
100    ScanFreeText,
101}
102
103fn key_policy(key: &str) -> KeyPolicy {
104    let normalized = normalize_key(key);
105    if REDACTED_KEYS.contains(&normalized.as_str()) {
106        KeyPolicy::FullyRedact
107    } else if NEVER_SCANNED_KEYS.contains(&normalized.as_str()) {
108        KeyPolicy::NeverScan
109    } else {
110        KeyPolicy::ScanFreeText
111    }
112}
113
114/// Keeps bare ids and lists of ids, but hands objects back to [`scrub_suotar_body`] so an exemption
115/// cannot smuggle a nested error message past the value scan.
116fn keep_scalars(value: &Value) -> Value {
117    match value {
118        Value::Object(_) => scrub_suotar_body(value),
119        Value::Array(items) => Value::Array(items.iter().map(keep_scalars).collect()),
120        other => other.clone(),
121    }
122}
123
124fn normalize_key(key: &str) -> String {
125    key.chars()
126        .filter(|c| c.is_ascii_alphanumeric())
127        .flat_map(|c| c.to_lowercase())
128        .collect()
129}
130
131/// Scrubs a bare error message with the same rules as a JSON body's free text.
132pub fn scrub_text(text: &str) -> String {
133    let without_emails = EMAIL_RE.replace_all(text, REDACTED);
134    STUDENT_NUMBER_RE
135        .replace_all(&without_emails, |captures: &Captures| {
136            // The id branches exist to beat the digit branch to the match; put them back unchanged.
137            if captures.name("digits").is_some() {
138                REDACTED.to_string()
139            } else {
140                captures[0].to_string()
141            }
142        })
143        .into_owned()
144}
145
146/// Both sides of a Suotar exchange, scrubbed on construction so there is no way to build an
147/// unscrubbed `details`. The request is kept because the ledger row no longer reflects it after a
148/// retry.
149pub fn suotar_exchange_details(request: Option<&Value>, response: Option<&Value>) -> Value {
150    let mut details = serde_json::Map::new();
151    if let Some(request) = request {
152        details.insert("request".to_string(), scrub_suotar_body(request));
153    }
154    if let Some(response) = response {
155        details.insert("response".to_string(), scrub_suotar_body(response));
156    }
157    Value::Object(details)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn redacts_by_key_name_but_keeps_the_key() {
166        let scrubbed = scrub_suotar_body(&json!({
167            "studentNumber": "012345678",
168            "firstNames": "Aada Maria",
169            "lastName": "Virtanen",
170            "primaryEmail": "aada@example.com",
171            "accessToken": "abc123",
172            "personId": "hy-hlo-1",
173        }));
174        assert_eq!(
175            scrubbed,
176            json!({
177                "studentNumber": REDACTED,
178                "firstNames": REDACTED,
179                "lastName": REDACTED,
180                "primaryEmail": REDACTED,
181                "accessToken": REDACTED,
182                "personId": REDACTED,
183            })
184        );
185    }
186
187    #[test]
188    fn keeps_the_fields_debugging_needs() {
189        // These ids carry student-number-shaped digit runs the value scan must not touch.
190        let body = json!({
191            "requestItemId": "2a4b0d6e-0000-4000-8000-000000000001",
192            "code": "sent",
193            "status": "ok",
194            "courseCode": "AYTKT21018",
195            "enrolmentId": "hy-CUR-135176012",
196            "gradeScaleId": "sis-0-5",
197            "gradeId": "4",
198            "credits": 5.0,
199            "attainmentDate": "2026-07-30",
200            "attainmentLanguage": "fi",
201            "submittedAttainmentId": "hy-att-1",
202        });
203        assert_eq!(scrub_suotar_body(&body), body);
204    }
205
206    #[test]
207    fn redacts_recursively_through_objects_and_arrays() {
208        let scrubbed = scrub_suotar_body(&json!({
209            "items": [
210                { "person": { "studentNumber": "012345678" }, "code": "ok" },
211                { "person": { "studentNumber": "012345679" }, "code": "ok" },
212            ]
213        }));
214        assert_eq!(
215            scrubbed,
216            json!({
217                "items": [
218                    { "person": { "studentNumber": REDACTED }, "code": "ok" },
219                    { "person": { "studentNumber": REDACTED }, "code": "ok" },
220                ]
221            })
222        );
223    }
224
225    #[test]
226    fn matches_keys_case_insensitively_and_across_naming_styles() {
227        let scrubbed = scrub_suotar_body(&json!({
228            "STUDENT_NUMBER": "012345678",
229            "Student-Number": "012345678",
230            "emailedTo": "aada@example.com",
231        }));
232        assert_eq!(
233            scrubbed,
234            json!({
235                "STUDENT_NUMBER": REDACTED,
236                "Student-Number": REDACTED,
237                "emailedTo": REDACTED,
238            })
239        );
240    }
241
242    #[test]
243    fn value_scan_catches_identifiers_quoted_in_free_text() {
244        // Suotar error messages quote the input, so key-based redaction alone would leak here.
245        let scrubbed = scrub_suotar_body(&json!({
246            "message": "Person 012345678 (aada@example.com) has no accepted enrolment",
247        }));
248        assert_eq!(
249            scrubbed,
250            json!({
251                "message": format!("Person {REDACTED} ({REDACTED}) has no accepted enrolment"),
252            })
253        );
254    }
255
256    #[test]
257    fn a_never_scanned_key_covers_the_ids_in_a_list_under_it() {
258        let body = json!({ "courseUnitRealisationId": ["hy-CUR-135176012", "hy-CUR-135176013"] });
259        assert_eq!(scrub_suotar_body(&body), body);
260
261        // An object in that list is classified by its own keys, so the exemption stops there.
262        let mixed = json!({
263            "code": ["hy-CUR-135176012", { "message": "Person 012345678 not found" }],
264        });
265        assert_eq!(
266            scrub_suotar_body(&mixed),
267            json!({
268                "code": [
269                    "hy-CUR-135176012",
270                    { "message": format!("Person {REDACTED} not found") },
271                ],
272            })
273        );
274    }
275
276    #[test]
277    fn a_redacted_key_takes_its_whole_value_with_it() {
278        // Over-redacting a person field costs debuggability; under-redacting is a permanent leak.
279        let scrubbed = scrub_suotar_body(&json!({
280            "firstNames": ["Aada", "Maria"],
281            "personId": { "value": "hy-hlo-1" },
282        }));
283        assert_eq!(
284            scrubbed,
285            json!({ "firstNames": REDACTED, "personId": REDACTED })
286        );
287    }
288
289    #[test]
290    fn value_scan_reaches_inside_a_never_scanned_key() {
291        // Suotar hangs per-item errors off an exempt key; the exemption covers only its own scalar.
292        let scrubbed = scrub_suotar_body(&json!({
293            "items": [{
294                "status": {
295                    "code": "personNotFound",
296                    "message": "Person 012345678 (aada@example.com) not found",
297                },
298            }],
299        }));
300        assert_eq!(
301            scrubbed,
302            json!({
303                "items": [{
304                    "status": {
305                        "code": "personNotFound",
306                        "message": format!("Person {REDACTED} ({REDACTED}) not found"),
307                    },
308                }],
309            })
310        );
311    }
312
313    #[test]
314    fn value_scan_keeps_request_item_ids_quoted_in_free_text_whole() {
315        // The last UUID group is 12 digits, so a naive digit-run scan would eat it.
316        let body = json!({ "message": "item cr-2a4b0d6e-0000-4000-8000-000000000001 rejected" });
317        assert_eq!(scrub_suotar_body(&body), body);
318
319        // An all-digit first group must not tip the alternation into the digit branch.
320        let numeric = json!({ "message": "item 12345678-1234-4321-8765-123456789012 rejected" });
321        assert_eq!(scrub_suotar_body(&numeric), numeric);
322
323        // A Sisu id is kept whole for the same reason, even though its tail is digits only.
324        let sisu = json!({ "message": "enrolment hy-CUR-135176012 rejected" });
325        assert_eq!(scrub_suotar_body(&sisu), sisu);
326
327        // Accepted cost: a student number hyphen-joined to a word reads as a prefixed id and
328        // survives. A bare run still goes, which is the shape Suotar sends.
329        let adjacent = json!({ "message": "person-012345678 and 012345678 not found" });
330        assert_eq!(
331            scrub_suotar_body(&adjacent),
332            json!({ "message": format!("person-012345678 and {REDACTED} not found") })
333        );
334    }
335
336    #[test]
337    fn free_text_names_are_deliberately_kept() {
338        // No pattern separates a name from error prose; only the known person keys are redacted.
339        let scrubbed = scrub_suotar_body(&json!({
340            "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
341            "fullName": "Aada Maria Virtanen",
342        }));
343        assert_eq!(
344            scrubbed,
345            json!({
346                "errors": [{ "code": "personNotFound", "message": "No person matching Aada Maria Virtanen" }],
347                "fullName": REDACTED,
348            })
349        );
350    }
351
352    #[test]
353    fn value_scan_leaves_short_and_long_digit_runs_alone() {
354        let body = json!({ "message": "code 404 after 1234567890123 ms" });
355        assert_eq!(scrub_suotar_body(&body), body);
356    }
357
358    #[test]
359    fn scrubbing_is_idempotent() {
360        let body = json!({
361            "studentNumber": "012345678",
362            "message": "Person 012345678 not found",
363        });
364        let once = scrub_suotar_body(&body);
365        assert_eq!(scrub_suotar_body(&once), once);
366    }
367
368    #[test]
369    fn exchange_details_scrub_both_sides_and_omit_missing_ones() {
370        let details = suotar_exchange_details(
371            Some(&json!({ "studentNumber": "012345678" })),
372            Some(&json!({ "code": "sent", "fullName": "Aada Virtanen" })),
373        );
374        assert_eq!(
375            details,
376            json!({
377                "request": { "studentNumber": REDACTED },
378                "response": { "code": "sent", "fullName": REDACTED },
379            })
380        );
381
382        let request_only = suotar_exchange_details(Some(&json!({ "code": "x" })), None);
383        assert_eq!(request_only, json!({ "request": { "code": "x" } }));
384    }
385}