headless_lms_models/library/credit_registration/
scrub.rs1use std::sync::LazyLock;
5
6use regex::{Captures, Regex};
7use serde_json::{Value, json};
8
9pub const REDACTED: &str = "[redacted]";
11
12const 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
28const 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
51static 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
64pub 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 FullyRedact,
97 NeverScan,
99 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
114fn 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
131pub 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 if captures.name("digits").is_some() {
138 REDACTED.to_string()
139 } else {
140 captures[0].to_string()
141 }
142 })
143 .into_owned()
144}
145
146pub 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 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 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 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 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 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 let body = json!({ "message": "item cr-2a4b0d6e-0000-4000-8000-000000000001 rejected" });
317 assert_eq!(scrub_suotar_body(&body), body);
318
319 let numeric = json!({ "message": "item 12345678-1234-4321-8765-123456789012 rejected" });
321 assert_eq!(scrub_suotar_body(&numeric), numeric);
322
323 let sisu = json!({ "message": "enrolment hy-CUR-135176012 rejected" });
325 assert_eq!(scrub_suotar_body(&sisu), sisu);
326
327 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 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}