diff --git a/backend/vkt/db/1_tables.sql b/backend/vkt/db/1_tables.sql index 427064f6e..2cc5f10fa 100644 --- a/backend/vkt/db/1_tables.sql +++ b/backend/vkt/db/1_tables.sql @@ -3,7 +3,7 @@ -- -- Dumped from database version 12.9 (Debian 12.9-1.pgdg110+1) --- Dumped by pg_dump version 14.7 (Homebrew) +-- Dumped by pg_dump version 12.9 (Debian 12.9-1.pgdg110+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -139,7 +139,8 @@ CREATE TABLE public.enrollment ( town text, country text, payment_link_hash character varying(255), - payment_link_expires_at timestamp with time zone + payment_link_expires_at timestamp with time zone, + is_anonymized boolean NOT NULL ); @@ -250,7 +251,8 @@ CREATE TABLE public.payment ( transaction_id text, reference text, payment_url text, - payment_status text + payment_status text, + refunded_at timestamp with time zone ); diff --git a/backend/vkt/db/2_tables_data.sql b/backend/vkt/db/2_tables_data.sql index b96e48ef1..4a39c5a7d 100644 --- a/backend/vkt/db/2_tables_data.sql +++ b/backend/vkt/db/2_tables_data.sql @@ -3,7 +3,7 @@ -- -- Dumped from database version 12.9 (Debian 12.9-1.pgdg110+1) --- Dumped by pg_dump version 14.7 (Homebrew) +-- Dumped by pg_dump version 12.9 (Debian 12.9-1.pgdg110+1) SET statement_timeout = 0; SET lock_timeout = 0; diff --git a/backend/vkt/db/3_liquibase.sql b/backend/vkt/db/3_liquibase.sql index d7ee01675..c4721f121 100644 --- a/backend/vkt/db/3_liquibase.sql +++ b/backend/vkt/db/3_liquibase.sql @@ -3,7 +3,7 @@ -- -- Dumped from database version 12.9 (Debian 12.9-1.pgdg110+1) --- Dumped by pg_dump version 14.7 (Homebrew) +-- Dumped by pg_dump version 12.9 (Debian 12.9-1.pgdg110+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -77,17 +77,19 @@ COPY public.databasechangelog (id, author, filename, dateexecuted, orderexecuted 2022-12-06-add-enum-email_type terova migrations.xml 2022-12-06 14:21:45.065383 12 EXECUTED 8:9d2dd6c5fb47e67ba50c6cf0db4edd47 createTable tableName=email_type; insert tableName=email_type \N 4.9.1 \N \N 0336504848 2022-12-06-create-email-table terova migrations.xml 2022-12-06 14:21:45.096787 13 EXECUTED 8:fc290ff4700b729ac568057c7dd6c211 createTable tableName=email; addForeignKeyConstraint baseTableName=email, constraintName=fk_email_email_type, referencedTableName=email_type \N 4.9.1 \N \N 0336504848 2022-12-06-create-email_attachment-table terova migrations.xml 2022-12-06 18:42:00.87481 14 EXECUTED 8:27ead2667c986a4fb6325d9d93238151 createTable tableName=email_attachment; addForeignKeyConstraint baseTableName=email_attachment, constraintName=fk_email_attachment_email, referencedTableName=email \N 4.9.1 \N \N 0352120682 -2023-01-18-modify_enrollment-table_previous-enrollment-date mikhuttu migrations.xml 2023-05-29 09:17:57.498963 15 EXECUTED 8:b5bb9828cdc9f6349c1e92af6da50bcd modifyDataType columnName=previous_enrollment_date, tableName=enrollment; renameColumn newColumnName=previous_enrollment, oldColumnName=previous_enrollment_date, tableName=enrollment \N 4.9.1 \N \N 5351877372 -2023-02-03-add_reservation-table_renewed_at jrkkp migrations.xml 2023-05-29 09:17:57.503008 16 EXECUTED 8:e9cd840a8006f4a136fac6e259048628 addColumn tableName=reservation \N 4.9.1 \N \N 5351877372 -2023-03-20-add_spring_session_table jrkkp migrations.xml 2023-05-29 09:17:57.520006 17 EXECUTED 8:74529b81aca9ec770312c7017e0c594d createTable tableName=spring_session; createIndex indexName=spring_session_expires_idx, tableName=spring_session; createIndex indexName=spring_session_principal_idx, tableName=spring_session; createTable tableName=spring_session_attributes; addPri... \N 4.9.1 \N \N 5351877372 -2023-04-11-add_person_oid jrkkp migrations.xml 2023-05-29 09:17:57.530923 18 EXECUTED 8:5b0623c9012a91e8f39b129672804bde addColumn tableName=person; dropNotNullConstraint columnName=identity_number, tableName=person \N 4.9.1 \N \N 5351877372 -2023-05-03-add-enrollment-to-queue-confirmation-email_type mikhuttu migrations.xml 2023-05-29 09:17:57.534001 19 EXECUTED 8:4502d8b0afd0a1b88b7649fa2db135f4 insert tableName=email_type \N 4.9.1 \N \N 5351877372 -2023-05-25-payment-table jrkkp migrations.xml 2023-05-29 09:17:57.545296 20 EXECUTED 8:1869b55cb1a464dee967a5b832c80003 createTable tableName=payment; insert tableName=enrollment_status; insert tableName=enrollment_status; addForeignKeyConstraint baseTableName=payment, constraintName=fk_payment_enrollment, referencedTableName=enrollment \N 4.9.1 \N \N 5351877372 -2023-05-30-modify_payment-table_amount mikhuttu migrations.xml 2023-05-30 13:31:02.784706 21 EXECUTED 8:a1e8d1377da2bc6360f9971c8b052cb4 modifyDataType columnName=amount, tableName=payment \N 4.9.1 \N \N 5453462712 -2023-06-01-enrollment-payment-link-hash jrkkp migrations.xml 2023-06-01 13:53:01.472105 22 EXECUTED 8:0fc928a86fa41527372d8e8af21e813b addColumn tableName=enrollment \N 4.9.1 \N \N 5627581378 -2023-06-02-rename-enrollment-status-EXPECTING_PAYMENT mikhuttu migrations.xml 2023-06-02 08:38:57.704377 23 EXECUTED 8:a35dc4c9e0d0d241f4293f0b2ba16224 insert tableName=enrollment_status; sql; sql \N 4.9.1 \N \N 5695137627 -2023-06-16-person-latest-identified-at mikhuttu migrations.xml 2023-06-16 09:46:36.462511 24 EXECUTED 8:51d5c16082a3fd83108e9c40e7ae78e6 addColumn tableName=person; sql; addNotNullConstraint columnName=latest_identified_at, tableName=person \N 4.20.0 \N \N 6908796433 -2023-06-29-remove-person-identity_number mikhuttu migrations.xml 2023-06-29 09:32:48.572192 25 EXECUTED 8:5b23bce4f54b5583b757ad6bb81c612d dropColumn columnName=identity_number, tableName=person; dropColumn columnName=date_of_birth, tableName=person \N 4.20.0 \N \N 8031168558 +2023-01-18-modify_enrollment-table_previous-enrollment-date mikhuttu migrations.xml 2023-10-05 12:40:24.529359 15 EXECUTED 8:b5bb9828cdc9f6349c1e92af6da50bcd modifyDataType columnName=previous_enrollment_date, tableName=enrollment; renameColumn newColumnName=previous_enrollment, oldColumnName=previous_enrollment_date, tableName=enrollment \N 4.20.0 \N \N 6498824505 +2023-02-03-add_reservation-table_renewed_at jrkkp migrations.xml 2023-10-05 12:40:24.534427 16 EXECUTED 8:e9cd840a8006f4a136fac6e259048628 addColumn tableName=reservation \N 4.20.0 \N \N 6498824505 +2023-03-20-add_spring_session_table jrkkp migrations.xml 2023-10-05 12:40:24.548449 17 EXECUTED 8:74529b81aca9ec770312c7017e0c594d createTable tableName=spring_session; createIndex indexName=spring_session_expires_idx, tableName=spring_session; createIndex indexName=spring_session_principal_idx, tableName=spring_session; createTable tableName=spring_session_attributes; addPri... \N 4.20.0 \N \N 6498824505 +2023-04-11-add_person_oid jrkkp migrations.xml 2023-10-05 12:40:24.558025 18 EXECUTED 8:5b0623c9012a91e8f39b129672804bde addColumn tableName=person; dropNotNullConstraint columnName=identity_number, tableName=person \N 4.20.0 \N \N 6498824505 +2023-05-03-add-enrollment-to-queue-confirmation-email_type mikhuttu migrations.xml 2023-10-05 12:40:24.561463 19 EXECUTED 8:4502d8b0afd0a1b88b7649fa2db135f4 insert tableName=email_type \N 4.20.0 \N \N 6498824505 +2023-05-25-payment-table jrkkp migrations.xml 2023-10-05 12:40:24.571098 20 EXECUTED 8:1869b55cb1a464dee967a5b832c80003 createTable tableName=payment; insert tableName=enrollment_status; insert tableName=enrollment_status; addForeignKeyConstraint baseTableName=payment, constraintName=fk_payment_enrollment, referencedTableName=enrollment \N 4.20.0 \N \N 6498824505 +2023-05-30-modify_payment-table_amount mikhuttu migrations.xml 2023-10-05 12:40:24.577595 21 EXECUTED 8:a1e8d1377da2bc6360f9971c8b052cb4 modifyDataType columnName=amount, tableName=payment \N 4.20.0 \N \N 6498824505 +2023-06-01-enrollment-payment-link-hash jrkkp migrations.xml 2023-10-05 12:40:24.583222 22 EXECUTED 8:0fc928a86fa41527372d8e8af21e813b addColumn tableName=enrollment \N 4.20.0 \N \N 6498824505 +2023-06-02-rename-enrollment-status-EXPECTING_PAYMENT mikhuttu migrations.xml 2023-10-05 12:40:24.591858 23 EXECUTED 8:a35dc4c9e0d0d241f4293f0b2ba16224 insert tableName=enrollment_status; sql; sql \N 4.20.0 \N \N 6498824505 +2023-06-16-person-latest-identified-at mikhuttu migrations.xml 2023-10-05 12:40:24.596771 24 EXECUTED 8:51d5c16082a3fd83108e9c40e7ae78e6 addColumn tableName=person; sql; addNotNullConstraint columnName=latest_identified_at, tableName=person \N 4.20.0 \N \N 6498824505 +2023-06-29-remove-person-identity_number mikhuttu migrations.xml 2023-10-05 12:40:24.600916 25 EXECUTED 8:5b23bce4f54b5583b757ad6bb81c612d dropColumn columnName=identity_number, tableName=person; dropColumn columnName=date_of_birth, tableName=person \N 4.20.0 \N \N 6498824505 +2023-08-03-payment-add-refunded jrkkp migrations.xml 2023-10-05 12:40:24.60417 26 EXECUTED 8:fb56e2324ab73d52aefd19c907295aa3 addColumn tableName=payment \N 4.20.0 \N \N 6498824505 +2023-09-07-enrollment-is-anonymized mikhuttu migrations.xml 2023-10-05 12:40:24.608176 27 EXECUTED 8:3a3c0039e1c2c3e5a79b4e62612246c1 addColumn tableName=enrollment; dropDefaultValue columnName=is_anonymized, tableName=enrollment \N 4.20.0 \N \N 6498824505 \. diff --git a/backend/vkt/db/4_init.sql b/backend/vkt/db/4_init.sql index a0315efbe..40885f7c7 100644 --- a/backend/vkt/db/4_init.sql +++ b/backend/vkt/db/4_init.sql @@ -191,7 +191,7 @@ FROM generate_series(1, 22) i, INSERT INTO enrollment(exam_event_id, person_id, skill_oral, skill_textual, skill_understanding, partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension, - status, digital_certificate_consent, email, phone_number, street, postal_code, town, country) + status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, is_anonymized) SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 1), person_id, true, true, true, true, true, true, true, @@ -209,7 +209,8 @@ SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 END, CASE mod(person_id, 5) WHEN 0 THEN countries[mod(person_id / 5 - 1, array_length(countries, 1)) + 1] - END + END, + false FROM person, (SELECT ('{Erottajankatu 1, Mäkelänkatu 70, Postikatu 2, Hamngatan 4}')::text[] AS streets) AS street_table, (SELECT ('{00130, 00610, 33100, 111 47}')::text[] AS postal_codes) AS postal_code_table, @@ -221,7 +222,7 @@ ORDER BY person_id LIMIT (SELECT max_participants FROM exam_event ORDER BY exam_ INSERT INTO enrollment(exam_event_id, person_id, skill_oral, skill_textual, skill_understanding, partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension, - status, digital_certificate_consent, email, phone_number, street, postal_code, town, country) + status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, is_anonymized) SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 2), person_id, true, true, true, true, true, true, true, @@ -239,7 +240,8 @@ SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 END, CASE mod(person_id, 5) WHEN 0 THEN countries[mod(person_id / 5 - 1, array_length(countries, 1)) + 1] - END + END, + false FROM person, (SELECT ('{Erottajankatu 1, Mäkelänkatu 70, Postikatu 2, Hamngatan 4}')::text[] AS streets) AS street_table, (SELECT ('{00130, 00610, 33100, 111 47}')::text[] AS postal_codes) AS postal_code_table, @@ -249,7 +251,7 @@ ORDER BY person_id LIMIT (SELECT max_participants FROM exam_event ORDER BY exam_ INSERT INTO enrollment(exam_event_id, person_id, skill_oral, skill_textual, skill_understanding, partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension, - status, digital_certificate_consent, email, phone_number, street, postal_code, town, country) + status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, is_anonymized) SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 2), person_id, true, true, true, true, true, true, true, @@ -267,7 +269,8 @@ SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 END, CASE mod(person_id, 5) WHEN 0 THEN countries[mod(person_id / 5 - 1, array_length(countries, 1)) + 1] - END + END, + false FROM person, (SELECT ('{Erottajankatu 1, Mäkelänkatu 70, Postikatu 2, Hamngatan 4}')::text[] AS streets) AS street_table, (SELECT ('{00130, 00610, 33100, 111 47}')::text[] AS postal_codes) AS postal_code_table, @@ -281,7 +284,7 @@ ORDER BY person_id INSERT INTO enrollment(exam_event_id, person_id, skill_oral, skill_textual, skill_understanding, partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension, - status, digital_certificate_consent, email, phone_number, street, postal_code, town, country) + status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, is_anonymized) SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 3), person_id, true, true, true, true, true, true, true, @@ -299,7 +302,8 @@ SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 END, CASE mod(person_id, 5) WHEN 0 THEN countries[mod(person_id / 5 - 1, array_length(countries, 1)) + 1] - END + END, + false FROM person, (SELECT ('{Erottajankatu 1, Mäkelänkatu 70, Postikatu 2, Hamngatan 4}')::text[] AS streets) AS street_table, (SELECT ('{00130, 00610, 33100, 111 47}')::text[] AS postal_codes) AS postal_code_table, @@ -308,7 +312,7 @@ FROM person, INSERT INTO enrollment(exam_event_id, person_id, skill_oral, skill_textual, skill_understanding, partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension, - status, digital_certificate_consent, email, phone_number, street, postal_code, town, country) + status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, is_anonymized) SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 3), person_id, true, true, true, true, true, true, true, @@ -326,7 +330,8 @@ SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 END, CASE mod(person_id, 5) WHEN 0 THEN countries[mod(person_id / 5 - 1, array_length(countries, 1)) + 1] - END + END, + false FROM person, (SELECT ('{Erottajankatu 1, Mäkelänkatu 70, Postikatu 2, Hamngatan 4}')::text[] AS streets) AS street_table, (SELECT ('{00130, 00610, 33100, 111 47}')::text[] AS postal_codes) AS postal_code_table, @@ -339,7 +344,7 @@ FROM person, INSERT INTO enrollment(exam_event_id, person_id, skill_oral, skill_textual, skill_understanding, partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension, - status, digital_certificate_consent, email, phone_number, street, postal_code, town, country) + status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, is_anonymized) SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 OFFSET 4), person_id, true, true, true, true, true, true, true, @@ -357,7 +362,8 @@ SELECT (SELECT exam_event_id FROM exam_event ORDER BY exam_event_id DESC LIMIT 1 END, CASE mod(person_id, 5) WHEN 0 THEN countries[mod(person_id / 5 - 1, array_length(countries, 1)) + 1] - END + END, + false FROM person, (SELECT ('{Erottajankatu 1, Mäkelänkatu 70, Postikatu 2, Hamngatan 4}')::text[] AS streets) AS street_table, (SELECT ('{00130, 00610, 33100, 111 47}')::text[] AS postal_codes) AS postal_code_table, @@ -368,10 +374,10 @@ FROM person, INSERT INTO enrollment(exam_event_id, person_id, skill_oral, skill_textual, skill_understanding, partial_exam_speaking, partial_exam_speech_comprehension, partial_exam_writing, partial_exam_reading_comprehension, - status, digital_certificate_consent, email, phone_number, street, postal_code, town, country) + status, digital_certificate_consent, email, phone_number, street, postal_code, town, country, is_anonymized) SELECT exam_event_id, (SELECT max(person_id) FROM person), true, true, true, true, true, true, true, 'CANCELED', true, - 'foo@bar.invalid', '0404040404', null, null, null, null + 'foo@bar.invalid', '0404040404', null, null, null, null, false FROM exam_event; diff --git a/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java b/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java index c05e318c3..e355868a2 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/config/Constants.java @@ -14,4 +14,6 @@ public class Constants { public static final String DELETE_EXPIRED_RESERVATIONS_CRON = "0 30 9 * * *"; // Daily at 10:00 public static final String DELETE_OBSOLETE_PERSONS_CRON = "0 0 10 * * *"; + // Daily at 10:30 + public static final String ANONYMIZE_ENROLLMENTS_CRON = "0 30 10 * * *"; } diff --git a/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java b/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java index 7184ecd38..360310030 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/model/Enrollment.java @@ -87,6 +87,9 @@ public class Enrollment extends BaseEntity { @Column(name = "payment_link_expires_at") private LocalDateTime paymentLinkExpiresAt; + @Column(name = "is_anonymized", nullable = false) + private boolean isAnonymized; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "exam_event_id", referencedColumnName = "exam_event_id", nullable = false) private ExamEvent examEvent; diff --git a/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentRepository.java b/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentRepository.java index 5386ee6c7..794aa4598 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentRepository.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/repository/EnrollmentRepository.java @@ -4,12 +4,17 @@ import fi.oph.vkt.model.ExamEvent; import fi.oph.vkt.model.Person; import fi.oph.vkt.model.type.EnrollmentStatus; +import java.time.LocalDate; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface EnrollmentRepository extends BaseRepository { + @Query("SELECT e FROM Enrollment e JOIN e.examEvent ee WHERE ee.date < ?1 AND NOT e.isAnonymized") + List findAllToAnonymize(final LocalDate examDateBefore); + List findAllByStatus(final EnrollmentStatus enrollmentStatus); Optional findByExamEventAndPerson(final ExamEvent examEvent, final Person person); Optional findByExamEventAndPaymentLinkHash(final ExamEvent examEvent, final String paymentLinkHash); diff --git a/backend/vkt/src/main/java/fi/oph/vkt/scheduled/AnonymizeEnrollments.java b/backend/vkt/src/main/java/fi/oph/vkt/scheduled/AnonymizeEnrollments.java new file mode 100644 index 000000000..06db41e39 --- /dev/null +++ b/backend/vkt/src/main/java/fi/oph/vkt/scheduled/AnonymizeEnrollments.java @@ -0,0 +1,36 @@ +package fi.oph.vkt.scheduled; + +import fi.oph.vkt.config.Constants; +import fi.oph.vkt.service.ClerkEnrollmentService; +import fi.oph.vkt.util.SchedulingUtil; +import jakarta.annotation.Resource; +import lombok.RequiredArgsConstructor; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AnonymizeEnrollments { + + private static final Logger LOG = LoggerFactory.getLogger(AnonymizeEnrollments.class); + + private static final String LOCK_AT_LEAST = "PT1S"; + + private static final String LOCK_AT_MOST = "PT1H"; + + @Resource + private final ClerkEnrollmentService clerkEnrollmentService; + + @Scheduled(cron = Constants.ANONYMIZE_ENROLLMENTS_CRON) + @SchedulerLock(name = "anonymizeEnrollments", lockAtLeastFor = LOCK_AT_LEAST, lockAtMostFor = LOCK_AT_MOST) + public void action() { + SchedulingUtil.runWithScheduledUser(() -> { + LOG.info("anonymizeEnrollments"); + + clerkEnrollmentService.anonymizeEnrollments(); + }); + } +} diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java index 6bca4c2ef..2087dad6f 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/service/ClerkEnrollmentService.java @@ -11,12 +11,15 @@ import fi.oph.vkt.model.Enrollment; import fi.oph.vkt.model.ExamEvent; import fi.oph.vkt.model.Payment; +import fi.oph.vkt.model.Person; import fi.oph.vkt.model.type.EnrollmentStatus; import fi.oph.vkt.model.type.PaymentStatus; import fi.oph.vkt.repository.EnrollmentRepository; import fi.oph.vkt.repository.ExamEventRepository; import fi.oph.vkt.repository.PaymentRepository; +import fi.oph.vkt.repository.PersonRepository; import fi.oph.vkt.util.ClerkEnrollmentUtil; +import fi.oph.vkt.util.StringUtil; import fi.oph.vkt.util.UUIDSource; import fi.oph.vkt.util.exception.APIException; import fi.oph.vkt.util.exception.APIExceptionType; @@ -41,6 +44,7 @@ public class ClerkEnrollmentService extends AbstractEnrollmentService { private final EnrollmentRepository enrollmentRepository; private final ExamEventRepository examEventRepository; private final PaymentRepository paymentRepository; + private final PersonRepository personRepository; private final AuditService auditService; private final Environment environment; private final UUIDSource uuidSource; @@ -154,4 +158,52 @@ public void deleteCanceledUnfinishedEnrollments() { } }); } + + @Transactional(isolation = Isolation.SERIALIZABLE) + public void anonymizeEnrollments() { + final LocalDateTime expirationDate = LocalDateTime.now().minusDays(180); + enrollmentRepository + .findAllToAnonymize(expirationDate.toLocalDate()) + .forEach(enrollment -> { + anonymizeEnrollment(enrollment); + + final Person person = enrollment.getPerson(); + if (person.getLatestIdentifiedAt().isBefore(expirationDate)) { + anonymizePerson(person); + } + }); + } + + private void anonymizeEnrollment(final Enrollment enrollment) { + enrollment.setEmail("anonymisoitu.ilmoittautuja@vkt.vkt"); + enrollment.setPhoneNumber("+0000000"); + + if (enrollment.getStreet() != null) { + enrollment.setStreet("Testitie 1"); + } + if (enrollment.getPostalCode() != null) { + enrollment.setPostalCode("00000"); + } + if (enrollment.getTown() != null) { + enrollment.setTown("Kaupunki"); + } + if (enrollment.getCountry() != null) { + enrollment.setCountry("Maa"); + } + + enrollment.setAnonymized(true); + enrollmentRepository.saveAndFlush(enrollment); + } + + private void anonymizePerson(final Person person) { + final String salt = environment.getRequiredProperty("salt"); + person.setLastName("Ilmoittautuja"); + person.setFirstName("Anonymisoitu"); + + if (person.getOtherIdentifier() != null && !person.getOtherIdentifier().isEmpty()) { + person.setOtherIdentifier(StringUtil.getHash(person.getOtherIdentifier(), salt)); + } + + personRepository.saveAndFlush(person); + } } diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java index ef34beb4a..55ffcbd96 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicAuthService.java @@ -5,7 +5,7 @@ import fi.oph.vkt.model.type.EnrollmentType; import fi.oph.vkt.repository.PersonRepository; import fi.oph.vkt.service.auth.CasTicketValidationService; -import fi.oph.vkt.util.UIRouteUtil; +import fi.oph.vkt.util.StringUtil; import fi.oph.vkt.util.exception.APIException; import fi.oph.vkt.util.exception.APIExceptionType; import java.net.URLEncoder; @@ -70,9 +70,13 @@ public Person createPersonFromTicket(final String ticket, final long examEventId } } + final String salt = environment.getRequiredProperty("salt"); + final String hashedOtherIdentifier = StringUtil.getHash(otherIdentifier, salt); final Optional optionalExistingPerson = oid != null && !oid.isEmpty() ? personRepository.findByOid(oid) - : personRepository.findByOtherIdentifier(otherIdentifier); + : personRepository + .findByOtherIdentifier(otherIdentifier) + .or(() -> personRepository.findByOtherIdentifier(hashedOtherIdentifier)); final Person person = optionalExistingPerson.orElse(new Person()); person.setLastName(lastName); diff --git a/backend/vkt/src/main/java/fi/oph/vkt/util/StringUtil.java b/backend/vkt/src/main/java/fi/oph/vkt/util/StringUtil.java index 209df1866..d18ca8bbb 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/util/StringUtil.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/util/StringUtil.java @@ -1,5 +1,6 @@ package fi.oph.vkt.util; +import org.apache.commons.codec.digest.DigestUtils; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.safety.Safelist; @@ -14,4 +15,8 @@ public static String sanitize(final String nullable) { .trim() .replaceAll("^=*", ""); } + + public static String getHash(final String value, final String salt) { + return DigestUtils.sha256Hex(value + salt); + } } diff --git a/backend/vkt/src/main/resources/application.yaml b/backend/vkt/src/main/resources/application.yaml index 03ae85ec9..3e8ac04bd 100644 --- a/backend/vkt/src/main/resources/application.yaml +++ b/backend/vkt/src/main/resources/application.yaml @@ -66,6 +66,7 @@ cas: url: ${virkailija.cas.url:http://localhost:${server.port}/vkt} login-url: ${virkailija.cas.login-url:http://localhost:${server.port}/login} app: + salt: ${salt:null} base-url: public: ${public-base-url:http://localhost:4002}/vkt api: ${public-base-url:http://localhost:${server.port}}/vkt/api/v1 diff --git a/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml b/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml index d04a5d661..9a8a147de 100644 --- a/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml +++ b/backend/vkt/src/main/resources/db/changelog/db.changelog-1.0.xml @@ -634,4 +634,14 @@ + + + + + + + + + + diff --git a/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template b/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template index 759dd248b..303b55993 100644 --- a/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template +++ b/backend/vkt/src/main/resources/oph-configuration/vkt.properties.template @@ -19,6 +19,7 @@ email.service-url=${virkailija.host.alb}/ryhmasahkoposti-service/email/firewall reservation.duration=PT30M public-base-url={{vkt_app_url}} +salt={{vkt_salt}} cas-oppija.login-url={{opintopolku_baseurl}}/cas-oppija/login cas-oppija.logout-url={{opintopolku_baseurl}}/cas-oppija/logout diff --git a/backend/vkt/src/test/java/fi/oph/vkt/Factory.java b/backend/vkt/src/test/java/fi/oph/vkt/Factory.java index 94bbae15b..0759e7b92 100644 --- a/backend/vkt/src/test/java/fi/oph/vkt/Factory.java +++ b/backend/vkt/src/test/java/fi/oph/vkt/Factory.java @@ -57,6 +57,7 @@ public static Enrollment enrollment(final ExamEvent examEvent, final Person pers enrollment.setDigitalCertificateConsent(true); enrollment.setEmail("foo.tester@invalid"); enrollment.setPhoneNumber("+10001234567"); + enrollment.setAnonymized(false); enrollment.setExamEvent(examEvent); enrollment.setPerson(person); diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java index b0417cd27..65c869b63 100644 --- a/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java +++ b/backend/vkt/src/test/java/fi/oph/vkt/service/ClerkEnrollmentServiceTest.java @@ -1,6 +1,8 @@ package fi.oph.vkt.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -28,11 +30,14 @@ import fi.oph.vkt.repository.EnrollmentRepository; import fi.oph.vkt.repository.ExamEventRepository; import fi.oph.vkt.repository.PaymentRepository; +import fi.oph.vkt.repository.PersonRepository; import fi.oph.vkt.util.ClerkEnrollmentUtil; +import fi.oph.vkt.util.StringUtil; import fi.oph.vkt.util.UUIDSource; import fi.oph.vkt.util.exception.APIException; import fi.oph.vkt.util.exception.APIExceptionType; import jakarta.annotation.Resource; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; import org.junit.jupiter.api.BeforeEach; @@ -56,6 +61,9 @@ class ClerkEnrollmentServiceTest { @Resource private PaymentRepository paymentRepository; + @Resource + private PersonRepository personRepository; + @MockBean private AuditService auditService; @@ -64,9 +72,12 @@ class ClerkEnrollmentServiceTest { @Resource private TestEntityManager entityManager; + private final String salt = "saltysalt"; + @BeforeEach public void setup() { final Environment environment = mock(Environment.class); + when(environment.getRequiredProperty("salt")).thenReturn(salt); when(environment.getRequiredProperty("app.base-url.api")).thenReturn("http://localhost"); final UUIDSource uuidSource = mock(UUIDSource.class); @@ -77,6 +88,7 @@ public void setup() { enrollmentRepository, examEventRepository, paymentRepository, + personRepository, auditService, environment, uuidSource @@ -293,4 +305,115 @@ public void testCreatePaymentLink() { verify(auditService) .logUpdate(VktOperation.UPDATE_ENROLLMENT_PAYMENT_LINK, enrollment.getId(), oldAuditDto, newAuditDto); } + + @Test + public void testAnonymizeEnrollments() { + final ExamEvent examEvent1 = Factory.examEvent(); + examEvent1.setRegistrationCloses(LocalDate.now().minusDays(191)); + examEvent1.setDate(LocalDate.now().minusDays(181)); + entityManager.persist(examEvent1); + + final ExamEvent examEvent2 = Factory.examEvent(); + examEvent2.setRegistrationCloses(LocalDate.now().minusDays(190)); + examEvent2.setDate(LocalDate.now().minusDays(180)); + entityManager.persist(examEvent2); + + final Person person1 = Factory.person(); + person1.setLatestIdentifiedAt(LocalDateTime.now().minusDays(190)); + person1.setOtherIdentifier("bar"); + entityManager.persist(person1); + final Person person2 = Factory.person(); + person2.setOtherIdentifier("foo"); + entityManager.persist(person2); + final Person person3 = Factory.person(); + person3.setOtherIdentifier("foobar"); + entityManager.persist(person3); + + final Enrollment enrollment11 = createEnrollment(person1, examEvent1, true); + final Enrollment enrollment21 = createEnrollment(person2, examEvent1, false); + final Enrollment enrollment22 = createEnrollment(person2, examEvent2, false); + final Enrollment enrollment31 = createEnrollment(person3, examEvent2, false); + + clerkEnrollmentService.anonymizeEnrollments(); + clerkEnrollmentService.anonymizeEnrollments(); // ensure second run doesn't cause side effects + + final int originalVersion = 0; + + assertAnonymizedEnrollment(enrollment11, originalVersion + 1, true); + assertAnonymizedPerson(person1, originalVersion + 1, "bar"); + + assertAnonymizedEnrollment(enrollment21, originalVersion + 1, false); + assertNotAnonymizedEnrollment(enrollment22, originalVersion); + assertNotAnonymizedPerson(person2, originalVersion, "foo"); + + assertNotAnonymizedEnrollment(enrollment31, originalVersion); + assertNotAnonymizedPerson(person3, originalVersion, "foobar"); + } + + private Enrollment createEnrollment(final Person person, final ExamEvent examEvent, final boolean includeAddress) { + final Enrollment enrollment = Factory.enrollment(examEvent, person); + + if (includeAddress) { + enrollment.setStreet("5300 NEVELS AVE"); + enrollment.setPostalCode("35022-6186"); + enrollment.setTown("BESSEMER AL"); + enrollment.setCountry("USA"); + } + + entityManager.persist(enrollment); + return enrollment; + } + + private void assertAnonymizedEnrollment( + final Enrollment enrollment, + final int expectedVersion, + final boolean expectAddressToExist + ) { + assertEquals(expectedVersion, enrollment.getVersion()); + assertTrue(enrollment.isAnonymized()); + + assertEquals("anonymisoitu.ilmoittautuja@vkt.vkt", enrollment.getEmail()); + assertEquals("+0000000", enrollment.getPhoneNumber()); + + if (expectAddressToExist) { + assertEquals("Testitie 1", enrollment.getStreet()); + assertEquals("00000", enrollment.getPostalCode()); + assertEquals("Kaupunki", enrollment.getTown()); + assertEquals("Maa", enrollment.getCountry()); + } else { + assertNull(enrollment.getStreet()); + assertNull(enrollment.getPostalCode()); + assertNull(enrollment.getTown()); + assertNull(enrollment.getCountry()); + } + } + + private void assertNotAnonymizedEnrollment(final Enrollment enrollment, final int expectedVersion) { + final Enrollment defaultEnrollment = Factory.enrollment(Factory.examEvent(), Factory.person()); + + assertEquals(expectedVersion, enrollment.getVersion()); + assertEquals(defaultEnrollment.getEmail(), enrollment.getEmail()); + assertEquals(defaultEnrollment.getPhoneNumber(), enrollment.getPhoneNumber()); + assertFalse(enrollment.isAnonymized()); + } + + private void assertAnonymizedPerson(final Person person, final int expectedVersion, final String expectedIdentifier) { + assertEquals(expectedVersion, person.getVersion()); + assertEquals("Ilmoittautuja", person.getLastName()); + assertEquals("Anonymisoitu", person.getFirstName()); + assertEquals(StringUtil.getHash(expectedIdentifier, salt), person.getOtherIdentifier()); + } + + private void assertNotAnonymizedPerson( + final Person person, + final int expectedVersion, + final String expectedIdentifier + ) { + final Person defaultPerson = Factory.person(); + + assertEquals(expectedVersion, person.getVersion()); + assertEquals(defaultPerson.getLastName(), person.getLastName()); + assertEquals(defaultPerson.getFirstName(), person.getFirstName()); + assertEquals(expectedIdentifier, person.getOtherIdentifier()); + } } diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/PublicAuthServicePseudonymTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicAuthServicePseudonymTest.java new file mode 100644 index 000000000..521ebad7b --- /dev/null +++ b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicAuthServicePseudonymTest.java @@ -0,0 +1,89 @@ +package fi.oph.vkt.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import fi.oph.vkt.model.Person; +import fi.oph.vkt.model.type.AppLocale; +import fi.oph.vkt.model.type.EnrollmentType; +import fi.oph.vkt.repository.PersonRepository; +import fi.oph.vkt.service.auth.CasTicketValidationService; +import fi.oph.vkt.service.auth.ticketValidator.TicketValidator; +import fi.oph.vkt.util.StringUtil; +import jakarta.annotation.Resource; +import java.time.LocalDateTime; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.env.Environment; +import org.springframework.security.test.context.support.WithMockUser; + +@WithMockUser +@DataJpaTest +public class PublicAuthServicePseudonymTest { + + @Resource + private PersonRepository personRepository; + + @MockBean + private TicketValidator ticketValidatorMock; + + private PublicAuthService publicAuthService; + + @Resource + private TestEntityManager entityManager; + + @BeforeEach + public void setup() { + final Environment environment = mock(Environment.class); + + when(environment.getRequiredProperty("salt")).thenReturn("foobar"); + when(environment.getRequiredProperty("app.cas-oppija.login-url")).thenReturn("https://foo.bar"); + when(environment.getRequiredProperty("app.cas-oppija.service-url")) + .thenReturn("https://foo/vkt/api/v1/auth/validate/%s/%s"); + + final CasTicketValidationService casTicketValidationService = new CasTicketValidationService(ticketValidatorMock); + + final Map personDetails = Map.ofEntries( + Map.entry("firstName", "Tessa"), + Map.entry("lastName", "Testilä"), + Map.entry("otherIdentifier", "10000") + ); + when(casTicketValidationService.validate(anyString(), anyLong(), eq(EnrollmentType.RESERVATION))) + .thenReturn(personDetails); + + publicAuthService = new PublicAuthService(casTicketValidationService, personRepository, environment); + } + + @Test + public void testCreatePersonFromTicketForPseudonymPerson() { + final Person person = publicAuthService.createPersonFromTicket("ticket-123", 1L, EnrollmentType.RESERVATION); + final int originalVersion = person.getVersion(); + + person.setOtherIdentifier(StringUtil.getHash(person.getOtherIdentifier(), "foobar")); + entityManager.persist(person); + + final LocalDateTime originalLatestIdentifiedAt = person.getLatestIdentifiedAt(); + final Person updatedPerson = publicAuthService.createPersonFromTicket("ticket-123", 1L, EnrollmentType.RESERVATION); + + assertEquals(person.getId(), updatedPerson.getId()); + assertEquals(originalVersion + 2, updatedPerson.getVersion()); + assertTrue(originalLatestIdentifiedAt.isBefore(updatedPerson.getLatestIdentifiedAt())); + assertPersonDetails(updatedPerson); + assertEquals(1, personRepository.count()); + } + + private void assertPersonDetails(final Person person) { + assertEquals("Testilä", person.getLastName()); + assertEquals("Tessa", person.getFirstName()); + assertEquals("10000", person.getOtherIdentifier()); + } +} diff --git a/backend/vkt/src/test/java/fi/oph/vkt/service/PublicAuthServiceTest.java b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicAuthServiceTest.java index f2ef7edc9..c3f58ef2a 100644 --- a/backend/vkt/src/test/java/fi/oph/vkt/service/PublicAuthServiceTest.java +++ b/backend/vkt/src/test/java/fi/oph/vkt/service/PublicAuthServiceTest.java @@ -14,12 +14,14 @@ import fi.oph.vkt.repository.PersonRepository; import fi.oph.vkt.service.auth.CasTicketValidationService; import fi.oph.vkt.service.auth.ticketValidator.TicketValidator; +import fi.oph.vkt.util.StringUtil; import jakarta.annotation.Resource; import java.time.LocalDateTime; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.core.env.Environment; import org.springframework.security.test.context.support.WithMockUser; @@ -40,6 +42,7 @@ public class PublicAuthServiceTest { public void setup() { final Environment environment = mock(Environment.class); + when(environment.getRequiredProperty("salt")).thenReturn("foobar"); when(environment.getRequiredProperty("app.cas-oppija.login-url")).thenReturn("https://foo.bar"); when(environment.getRequiredProperty("app.cas-oppija.service-url")) .thenReturn("https://foo/vkt/api/v1/auth/validate/%s/%s");