1use 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
14pub const REDACTED: &str = "[redacted]";
16
17const 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
33const 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
58static 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
71pub fn scrub_suotar_body(value: &Value) -> Value {
82 scrub(value)
83}
84
85enum KeyPolicy {
86 FullyRedact,
88 NeverScan,
90 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
125fn 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 if captures.name("digits").is_some() {
148 REDACTED.to_string()
149 } else {
150 captures[0].to_string()
151 }
152 })
153 .into_owned()
154}
155
156pub 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 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
233pub 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
270pub 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 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 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 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 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 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 let body = json!({ "message": "item cr-2a4b0d6e-0000-4000-8000-000000000001 rejected" });
471 assert_eq!(scrub_suotar_body(&body), body);
472
473 let numeric = json!({ "message": "item 12345678-1234-4321-8765-123456789012 rejected" });
475 assert_eq!(scrub_suotar_body(&numeric), numeric);
476
477 let sisu = json!({ "message": "enrolment hy-CUR-135176012 rejected" });
479 assert_eq!(scrub_suotar_body(&sisu), sisu);
480
481 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 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}