From 215b5553d2c241642005ef7e418afebb15ec18bc Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 1 Feb 2026 18:19:12 -0700 Subject: [PATCH 01/16] feat(audit): add audit log query infrastructure for GDPR export Add the ability to query audit logs, which is essential for GDPR data export functionality. The implementation uses an interface-based design allowing different backends (file, database, Elasticsearch). - AuditEventDTO: DTO for returning audit event data - AuditLogQueryService: Interface for querying audit events by user - FileAuditLogQueryService: Default implementation parsing pipe-delimited logs The file-based implementation parses the existing audit log format and supports filtering by user, timestamp, and action type. Closes #250 --- .../spring/user/audit/AuditEventDTO.java | 73 +++++ .../user/audit/AuditLogQueryService.java | 56 ++++ .../user/audit/FileAuditLogQueryService.java | 267 ++++++++++++++++++ .../audit/FileAuditLogQueryServiceTest.java | 247 ++++++++++++++++ 4 files changed, 643 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventDTO.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/audit/AuditLogQueryService.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventDTO.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventDTO.java new file mode 100644 index 0000000..1186135 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditEventDTO.java @@ -0,0 +1,73 @@ +package com.digitalsanctuary.spring.user.audit; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object representing an audit event for query results. + * Used by {@link AuditLogQueryService} to return audit event data + * in a structured format, particularly for GDPR data export. + * + * @see AuditLogQueryService + * @see AuditEvent + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuditEventDTO { + + /** + * The timestamp when the audit event occurred. + */ + private Instant timestamp; + + /** + * The action that was performed (e.g., "Login", "Registration", "PasswordUpdate"). + */ + private String action; + + /** + * The status of the action (e.g., "Success", "Failure"). + */ + private String actionStatus; + + /** + * The user ID associated with the event. + */ + private String userId; + + /** + * The email address of the user associated with the event. + */ + private String userEmail; + + /** + * The IP address from which the action was performed. + */ + private String ipAddress; + + /** + * The session ID associated with the event. + */ + private String sessionId; + + /** + * A descriptive message about the event. + */ + private String message; + + /** + * The user agent string of the client. + */ + private String userAgent; + + /** + * Additional data associated with the event in JSON format. + */ + private String extraData; + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditLogQueryService.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditLogQueryService.java new file mode 100644 index 0000000..3585e96 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditLogQueryService.java @@ -0,0 +1,56 @@ +package com.digitalsanctuary.spring.user.audit; + +import java.time.Instant; +import java.util.List; +import com.digitalsanctuary.spring.user.persistence.model.User; + +/** + * Service interface for querying audit log entries. + * + *

This interface provides a pluggable abstraction for querying audit events, + * allowing different implementations based on the audit storage backend + * (file-based, database, Elasticsearch, etc.). + * + *

The default implementation {@link FileAuditLogQueryService} parses the + * pipe-delimited log file created by {@link FileAuditLogWriter}. Applications + * requiring more efficient queries for large volumes can provide their own + * implementation backed by a database or log aggregation system. + * + *

Primary use case is GDPR data export, where all audit events for a user + * must be retrievable. + * + * @see FileAuditLogQueryService + * @see AuditEventDTO + */ +public interface AuditLogQueryService { + + /** + * Find all audit events for a specific user. + * + * @param user the user whose audit events to retrieve + * @return a list of audit events for the user, ordered by timestamp descending; + * empty list if no events found + */ + List findByUser(User user); + + /** + * Find audit events for a user since a given timestamp. + * + * @param user the user whose audit events to retrieve + * @param since only return events after this timestamp + * @return a list of audit events for the user since the given timestamp, + * ordered by timestamp descending; empty list if no events found + */ + List findByUserSince(User user, Instant since); + + /** + * Find audit events for a user filtered by action type. + * + * @param user the user whose audit events to retrieve + * @param action the action type to filter by (e.g., "CONSENT_GRANTED", "Login") + * @return a list of audit events matching the action type, + * ordered by timestamp descending; empty list if no events found + */ + List findByUserAndAction(User user, String action); + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java new file mode 100644 index 0000000..3483f00 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java @@ -0,0 +1,267 @@ +package com.digitalsanctuary.spring.user.audit; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import org.springframework.stereotype.Service; +import com.digitalsanctuary.spring.user.persistence.model.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * File-based implementation of {@link AuditLogQueryService} that parses the + * pipe-delimited audit log file created by {@link FileAuditLogWriter}. + * + *

This implementation reads and parses the entire log file for each query, + * filtering results by user email or ID. While suitable for small to medium + * audit volumes, applications with high audit volumes should consider implementing + * a database-backed query service. + * + *

The log file format is: + * {@code Date|Action|ActionStatus|UserId|Email|IPAddress|SessionId|Message|UserAgent|ExtraData} + * + * @see AuditLogQueryService + * @see FileAuditLogWriter + * @see AuditConfig + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FileAuditLogQueryService implements AuditLogQueryService { + + private final AuditConfig auditConfig; + + /** + * DateTimeFormatter patterns to try when parsing dates from the log file. + * MessageFormat produces dates like "Jan 15, 2025, 3:45:30 PM" or similar. + */ + private static final DateTimeFormatter[] DATE_FORMATTERS = { + DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.US), + DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a", Locale.US), + DateTimeFormatter.ofPattern("MMM dd, yyyy, h:mm:ss a", Locale.US), + DateTimeFormatter.ISO_INSTANT, + DateTimeFormatter.ISO_DATE_TIME + }; + + @Override + public List findByUser(User user) { + return findByUser(user, null, null); + } + + @Override + public List findByUserSince(User user, Instant since) { + return findByUser(user, since, null); + } + + @Override + public List findByUserAndAction(User user, String action) { + return findByUser(user, null, action); + } + + /** + * Internal method to find audit events with optional filtering. + * + * @param user the user to filter by + * @param since optional timestamp filter + * @param action optional action filter + * @return filtered list of audit events + */ + private List findByUser(User user, Instant since, String action) { + if (user == null) { + return Collections.emptyList(); + } + + Path logPath = getLogFilePath(); + if (logPath == null || !Files.exists(logPath)) { + log.debug("FileAuditLogQueryService.findByUser: Audit log file not found"); + return Collections.emptyList(); + } + + List results = new ArrayList<>(); + String userEmail = user.getEmail(); + String userId = user.getId() != null ? user.getId().toString() : null; + + try (BufferedReader reader = Files.newBufferedReader(logPath)) { + String line; + boolean isFirstLine = true; + + while ((line = reader.readLine()) != null) { + // Skip header line + if (isFirstLine) { + isFirstLine = false; + if (line.startsWith("Date|Action")) { + continue; + } + } + + AuditEventDTO event = parseLine(line); + if (event == null) { + continue; + } + + // Filter by user + if (!matchesUser(event, userEmail, userId)) { + continue; + } + + // Filter by timestamp if specified + if (since != null && event.getTimestamp() != null && event.getTimestamp().isBefore(since)) { + continue; + } + + // Filter by action if specified + if (action != null && !action.equals(event.getAction())) { + continue; + } + + results.add(event); + } + } catch (IOException e) { + log.error("FileAuditLogQueryService.findByUser: Error reading audit log file", e); + return Collections.emptyList(); + } + + // Sort by timestamp descending (most recent first) + results.sort(Comparator.comparing(AuditEventDTO::getTimestamp, + Comparator.nullsLast(Comparator.reverseOrder()))); + + return results; + } + + /** + * Gets the path to the audit log file, checking both configured path and fallback. + * + * @return the path to the log file, or null if not found + */ + private Path getLogFilePath() { + if (auditConfig == null || auditConfig.getLogFilePath() == null) { + return null; + } + + Path configuredPath = Path.of(auditConfig.getLogFilePath()); + if (Files.exists(configuredPath)) { + return configuredPath; + } + + // Check fallback temp directory location + Path tempPath = Path.of(System.getProperty("java.io.tmpdir") + File.separator + "user-audit.log"); + if (Files.exists(tempPath)) { + return tempPath; + } + + return null; + } + + /** + * Parses a single line from the audit log file. + * + * @param line the line to parse + * @return the parsed AuditEventDTO, or null if parsing fails + */ + private AuditEventDTO parseLine(String line) { + if (line == null || line.isBlank()) { + return null; + } + + String[] parts = line.split("\\|", -1); // -1 to keep trailing empty strings + if (parts.length < 10) { + log.debug("FileAuditLogQueryService.parseLine: Invalid line format, expected 10 fields: {}", line); + return null; + } + + try { + return AuditEventDTO.builder() + .timestamp(parseTimestamp(parts[0])) + .action(nullIfEmpty(parts[1])) + .actionStatus(nullIfEmpty(parts[2])) + .userId(nullIfEmpty(parts[3])) + .userEmail(nullIfEmpty(parts[4])) + .ipAddress(nullIfEmpty(parts[5])) + .sessionId(nullIfEmpty(parts[6])) + .message(nullIfEmpty(parts[7])) + .userAgent(nullIfEmpty(parts[8])) + .extraData(nullIfEmpty(parts[9])) + .build(); + } catch (Exception e) { + log.debug("FileAuditLogQueryService.parseLine: Error parsing line: {}", line, e); + return null; + } + } + + /** + * Parses a timestamp string from the log file. + * + * @param dateStr the date string to parse + * @return the parsed Instant, or null if parsing fails + */ + private Instant parseTimestamp(String dateStr) { + if (dateStr == null || dateStr.isBlank() || "null".equals(dateStr)) { + return null; + } + + // Try each formatter + for (DateTimeFormatter formatter : DATE_FORMATTERS) { + try { + ZonedDateTime zdt = ZonedDateTime.parse(dateStr.trim(), formatter); + return zdt.toInstant(); + } catch (DateTimeParseException e) { + // Try next formatter + } + } + + // Try parsing as epoch millis + try { + long epochMillis = Long.parseLong(dateStr.trim()); + return Instant.ofEpochMilli(epochMillis); + } catch (NumberFormatException e) { + // Not a number + } + + log.debug("FileAuditLogQueryService.parseTimestamp: Could not parse date: {}", dateStr); + return null; + } + + /** + * Checks if an audit event matches the given user. + * + * @param event the event to check + * @param userEmail the user's email + * @param userId the user's ID + * @return true if the event matches the user + */ + private boolean matchesUser(AuditEventDTO event, String userEmail, String userId) { + // Match by email + if (userEmail != null && userEmail.equalsIgnoreCase(event.getUserEmail())) { + return true; + } + + // Match by user ID + if (userId != null && userId.equals(event.getUserId())) { + return true; + } + + return false; + } + + /** + * Returns null if the string is empty or "null". + */ + private String nullIfEmpty(String value) { + if (value == null || value.isBlank() || "null".equals(value)) { + return null; + } + return value.trim(); + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java new file mode 100644 index 0000000..eaedb08 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryServiceTest.java @@ -0,0 +1,247 @@ +package com.digitalsanctuary.spring.user.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +@ServiceTest +@DisplayName("FileAuditLogQueryService Tests") +class FileAuditLogQueryServiceTest { + + @Mock + private AuditConfig auditConfig; + + @InjectMocks + private FileAuditLogQueryService queryService; + + @TempDir + Path tempDir; + + private User testUser; + private Path logFile; + + @BeforeEach + void setUp() throws IOException { + testUser = UserTestDataBuilder.aVerifiedUser() + .withId(1L) + .withEmail("test@example.com") + .build(); + + logFile = tempDir.resolve("test-audit.log"); + } + + private void setupLogFilePath() { + when(auditConfig.getLogFilePath()).thenReturn(logFile.toString()); + } + + @Nested + @DisplayName("findByUser") + class FindByUser { + + @Test + @DisplayName("returns empty list when user is null") + void returnsEmptyList_whenUserIsNull() { + // No need to set up log file - should return empty immediately + List result = queryService.findByUser(null); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("returns empty list when log file does not exist") + void returnsEmptyList_whenLogFileDoesNotExist() { + // Given + setupLogFilePath(); + // Log file not created + + // When + List result = queryService.findByUser(testUser); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("parses log file and returns matching events") + void parsesLogFile_returnsMatchingEvents() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + Thu Jan 15 10:30:00 EST 2025|Login|Success|1|test@example.com|127.0.0.1|sess123|User logged in|Mozilla/5.0|null + Thu Jan 15 10:35:00 EST 2025|Registration|Success|2|other@example.com|127.0.0.1|sess456|User registered|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getAction()).isEqualTo("Login"); + assertThat(result.get(0).getUserEmail()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("matches by user ID") + void matchesByUserId() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + Thu Jan 15 10:30:00 EST 2025|PasswordUpdate|Success|1|null|127.0.0.1|sess123|Password updated|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getAction()).isEqualTo("PasswordUpdate"); + } + + @Test + @DisplayName("returns events sorted by timestamp descending") + void returnsEvents_sortedByTimestampDescending() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + Thu Jan 15 08:00:00 EST 2025|Login|Success|1|test@example.com|127.0.0.1|sess1|First|Mozilla/5.0|null + Thu Jan 15 12:00:00 EST 2025|Logout|Success|1|test@example.com|127.0.0.1|sess2|Third|Mozilla/5.0|null + Thu Jan 15 10:00:00 EST 2025|PasswordUpdate|Success|1|test@example.com|127.0.0.1|sess3|Second|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then - should be sorted newest first + assertThat(result).hasSize(3); + // The order should be: Logout (12:00), PasswordUpdate (10:00), Login (08:00) + } + } + + @Nested + @DisplayName("findByUserAndAction") + class FindByUserAndAction { + + @Test + @DisplayName("filters by action type") + void filtersByActionType() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + Thu Jan 15 10:30:00 EST 2025|Login|Success|1|test@example.com|127.0.0.1|sess1|Logged in|Mozilla/5.0|null + Thu Jan 15 10:35:00 EST 2025|CONSENT_GRANTED|Success|1|test@example.com|127.0.0.1|sess2|Consent|Mozilla/5.0|{"consentType":"privacy_policy"} + Thu Jan 15 10:40:00 EST 2025|Login|Success|1|test@example.com|127.0.0.1|sess3|Logged in again|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUserAndAction(testUser, "CONSENT_GRANTED"); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getAction()).isEqualTo("CONSENT_GRANTED"); + assertThat(result.get(0).getExtraData()).contains("privacy_policy"); + } + + @Test + @DisplayName("returns empty list when no matching actions") + void returnsEmptyList_whenNoMatchingActions() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + Thu Jan 15 10:30:00 EST 2025|Login|Success|1|test@example.com|127.0.0.1|sess1|Logged in|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUserAndAction(testUser, "CONSENT_GRANTED"); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Line parsing") + class LineParsing { + + @Test + @DisplayName("handles empty extra data") + void handlesEmptyExtraData() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent| + Thu Jan 15 10:30:00 EST 2025|Login|Success|1|test@example.com|127.0.0.1|sess1|Message|Mozilla/5.0| + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getExtraData()).isNull(); + } + + @Test + @DisplayName("handles null values in fields") + void handlesNullValues() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + Thu Jan 15 10:30:00 EST 2025|Login|Success|1|test@example.com|null|null|Message|null|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getIpAddress()).isNull(); + assertThat(result.get(0).getSessionId()).isNull(); + } + + @Test + @DisplayName("skips malformed lines") + void skipsMalformedLines() throws IOException { + // Given + setupLogFilePath(); + String logContent = """ + Date|Action|Action Status|User ID|Email|IP Address|SessionId|Message|User Agent|Extra Data + Thu Jan 15 10:30:00 EST 2025|Login|Success|1|test@example.com|127.0.0.1|sess1|Message|Mozilla/5.0|null + This is not a valid log line + Thu Jan 15 10:35:00 EST 2025|Logout|Success|1|test@example.com|127.0.0.1|sess2|Message|Mozilla/5.0|null + """; + Files.writeString(logFile, logContent); + + // When + List result = queryService.findByUser(testUser); + + // Then + assertThat(result).hasSize(2); + } + } + +} From 94bc1558b195951b1e0bcc2ff0bd7efe9afa843b Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 1 Feb 2026 18:19:18 -0700 Subject: [PATCH 02/16] feat(event): add GDPR-related application events Add events for GDPR lifecycle operations following the existing event pattern used by UserPreDeleteEvent and OnRegistrationCompleteEvent. - UserDataExportedEvent: Published after successful data export - UserDeletedEvent: Published after user deletion (post-transaction) - ConsentChangedEvent: Published when consent is granted or withdrawn These events enable consuming applications to react to GDPR operations, such as updating external systems or triggering notifications. --- .../user/event/ConsentChangedEvent.java | 133 +++++++++++++++ .../user/event/UserDataExportedEvent.java | 90 +++++++++++ .../spring/user/event/UserDeletedEvent.java | 91 +++++++++++ .../user/event/ConsentChangedEventTest.java | 152 ++++++++++++++++++ .../user/event/UserDeletedEventTest.java | 92 +++++++++++ 5 files changed, 558 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/event/ConsentChangedEvent.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/event/UserDataExportedEvent.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/event/UserDeletedEventTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/ConsentChangedEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/ConsentChangedEvent.java new file mode 100644 index 0000000..09353a0 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/ConsentChangedEvent.java @@ -0,0 +1,133 @@ +package com.digitalsanctuary.spring.user.event; + +import org.springframework.context.ApplicationEvent; +import com.digitalsanctuary.spring.user.gdpr.ConsentRecord; +import com.digitalsanctuary.spring.user.gdpr.ConsentType; +import com.digitalsanctuary.spring.user.persistence.model.User; + +/** + * Event published when a user's consent status changes. + * + *

This event is published when consent is granted or withdrawn through + * the {@link com.digitalsanctuary.spring.user.gdpr.ConsentAuditService}. + * Listeners can use this event to trigger additional actions like + * updating mailing lists, disabling features, or synchronizing with + * external consent management systems. + * + * @see ConsentRecord + * @see ConsentType + * @see com.digitalsanctuary.spring.user.gdpr.ConsentAuditService + */ +public class ConsentChangedEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + /** + * The type of consent change. + */ + public enum ChangeType { + /** + * Consent was granted. + */ + GRANTED, + + /** + * Consent was withdrawn. + */ + WITHDRAWN + } + + /** + * The user whose consent changed. + */ + private final User user; + + /** + * The consent record with details of the change. + */ + private final ConsentRecord consentRecord; + + /** + * The type of change (granted or withdrawn). + */ + private final ChangeType changeType; + + /** + * Creates a new ConsentChangedEvent. + * + * @param source the object on which the event initially occurred + * @param user the user whose consent changed + * @param consentRecord the consent record with details + * @param changeType whether consent was granted or withdrawn + */ + public ConsentChangedEvent(Object source, User user, ConsentRecord consentRecord, ChangeType changeType) { + super(source); + this.user = user; + this.consentRecord = consentRecord; + this.changeType = changeType; + } + + /** + * Gets the user whose consent changed. + * + * @return the user + */ + public User getUser() { + return user; + } + + /** + * Gets the ID of the user whose consent changed. + * + * @return the user ID + */ + public Long getUserId() { + return user != null ? user.getId() : null; + } + + /** + * Gets the consent record with details of the change. + * + * @return the consent record + */ + public ConsentRecord getConsentRecord() { + return consentRecord; + } + + /** + * Gets the type of consent that changed. + * + * @return the consent type + */ + public ConsentType getConsentType() { + return consentRecord != null ? consentRecord.getType() : null; + } + + /** + * Gets whether consent was granted or withdrawn. + * + * @return the change type + */ + public ChangeType getChangeType() { + return changeType; + } + + /** + * Convenience method to check if consent was granted. + * + * @return true if consent was granted + */ + public boolean isGranted() { + return changeType == ChangeType.GRANTED; + } + + /** + * Convenience method to check if consent was withdrawn. + * + * @return true if consent was withdrawn + */ + public boolean isWithdrawn() { + return changeType == ChangeType.WITHDRAWN; + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/UserDataExportedEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/UserDataExportedEvent.java new file mode 100644 index 0000000..b4454f3 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/UserDataExportedEvent.java @@ -0,0 +1,90 @@ +package com.digitalsanctuary.spring.user.event; + +import org.springframework.context.ApplicationEvent; +import com.digitalsanctuary.spring.user.dto.GdprExportDTO; +import com.digitalsanctuary.spring.user.persistence.model.User; + +/** + * Event published after a user's data has been successfully exported for GDPR compliance. + * + *

This event can be used by listeners to perform additional actions after data export, + * such as logging, notification, or triggering downstream processes. + * + * @see User + * @see GdprExportDTO + * @see com.digitalsanctuary.spring.user.gdpr.GdprExportService + */ +public class UserDataExportedEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + /** + * The user whose data was exported. + */ + private final User user; + + /** + * The exported data (may be null if not retained in the event). + */ + private final GdprExportDTO exportedData; + + /** + * Creates a new UserDataExportedEvent. + * + * @param source the object on which the event initially occurred + * @param user the user whose data was exported + * @param exportedData the exported data + */ + public UserDataExportedEvent(Object source, User user, GdprExportDTO exportedData) { + super(source); + this.user = user; + this.exportedData = exportedData; + } + + /** + * Creates a new UserDataExportedEvent without retaining the export data. + * + * @param source the object on which the event initially occurred + * @param user the user whose data was exported + */ + public UserDataExportedEvent(Object source, User user) { + this(source, user, null); + } + + /** + * Gets the user whose data was exported. + * + * @return the user + */ + public User getUser() { + return user; + } + + /** + * Gets the ID of the user whose data was exported. + * + * @return the user ID + */ + public Long getUserId() { + return user != null ? user.getId() : null; + } + + /** + * Gets the email of the user whose data was exported. + * + * @return the user email + */ + public String getUserEmail() { + return user != null ? user.getEmail() : null; + } + + /** + * Gets the exported data, if retained in the event. + * + * @return the exported data, or null if not retained + */ + public GdprExportDTO getExportedData() { + return exportedData; + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java new file mode 100644 index 0000000..3b0a4f9 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/UserDeletedEvent.java @@ -0,0 +1,91 @@ +package com.digitalsanctuary.spring.user.event; + +import org.springframework.context.ApplicationEvent; + +/** + * Event published after a user entity has been successfully deleted. + * + *

Unlike {@link UserPreDeleteEvent} which is published before deletion and allows + * cleanup operations within the transaction, this event is published after the + * deletion has been committed. Use this event for post-deletion notifications, + * external system updates, or logging that should only occur after successful deletion. + * + *

Note: Since the user entity has been deleted by the time this event is published, + * only the user's ID and email are retained in this event. + * + * @see UserPreDeleteEvent + * @see com.digitalsanctuary.spring.user.gdpr.GdprDeletionService + */ +public class UserDeletedEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + /** + * The ID of the deleted user. + */ + private final Long userId; + + /** + * The email of the deleted user. + */ + private final String userEmail; + + /** + * Whether data was exported before deletion. + */ + private final boolean dataExported; + + /** + * Creates a new UserDeletedEvent. + * + * @param source the object on which the event initially occurred + * @param userId the ID of the deleted user + * @param userEmail the email of the deleted user + * @param dataExported whether data was exported before deletion + */ + public UserDeletedEvent(Object source, Long userId, String userEmail, boolean dataExported) { + super(source); + this.userId = userId; + this.userEmail = userEmail; + this.dataExported = dataExported; + } + + /** + * Creates a new UserDeletedEvent without export flag. + * + * @param source the object on which the event initially occurred + * @param userId the ID of the deleted user + * @param userEmail the email of the deleted user + */ + public UserDeletedEvent(Object source, Long userId, String userEmail) { + this(source, userId, userEmail, false); + } + + /** + * Gets the ID of the deleted user. + * + * @return the user ID + */ + public Long getUserId() { + return userId; + } + + /** + * Gets the email of the deleted user. + * + * @return the user email + */ + public String getUserEmail() { + return userEmail; + } + + /** + * Returns whether data was exported before deletion. + * + * @return true if data was exported + */ + public boolean isDataExported() { + return dataExported; + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java new file mode 100644 index 0000000..3735b69 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/event/ConsentChangedEventTest.java @@ -0,0 +1,152 @@ +package com.digitalsanctuary.spring.user.event; + +import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import com.digitalsanctuary.spring.user.gdpr.ConsentRecord; +import com.digitalsanctuary.spring.user.gdpr.ConsentType; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +@DisplayName("ConsentChangedEvent Tests") +class ConsentChangedEventTest { + + private User testUser; + private Object eventSource; + + @BeforeEach + void setUp() { + testUser = UserTestDataBuilder.aUser() + .withId(1L) + .withEmail("test@example.com") + .enabled() + .build(); + eventSource = this; + } + + @Test + @DisplayName("Event creation stores user and consent record") + void eventCreation_storesUserAndConsentRecord() { + // Given + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.PRIVACY_POLICY) + .grantedAt(Instant.now()) + .build(); + + // When + ConsentChangedEvent event = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED); + + // Then + assertThat(event.getUser()).isEqualTo(testUser); + assertThat(event.getConsentRecord()).isEqualTo(record); + assertThat(event.getSource()).isEqualTo(eventSource); + } + + @Test + @DisplayName("getUserId returns user's ID") + void getUserId_returnsUserId() { + // Given + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.MARKETING_EMAILS) + .build(); + + // When + ConsentChangedEvent event = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED); + + // Then + assertThat(event.getUserId()).isEqualTo(1L); + } + + @Test + @DisplayName("getConsentType returns correct type") + void getConsentType_returnsCorrectType() { + // Given + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.ANALYTICS) + .build(); + + // When + ConsentChangedEvent event = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.WITHDRAWN); + + // Then + assertThat(event.getConsentType()).isEqualTo(ConsentType.ANALYTICS); + } + + @Test + @DisplayName("isGranted returns true for GRANTED change type") + void isGranted_returnsTrue_forGrantedChangeType() { + // Given + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.DATA_PROCESSING) + .build(); + + // When + ConsentChangedEvent event = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED); + + // Then + assertThat(event.isGranted()).isTrue(); + assertThat(event.isWithdrawn()).isFalse(); + } + + @Test + @DisplayName("isWithdrawn returns true for WITHDRAWN change type") + void isWithdrawn_returnsTrue_forWithdrawnChangeType() { + // Given + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.THIRD_PARTY_SHARING) + .build(); + + // When + ConsentChangedEvent event = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.WITHDRAWN); + + // Then + assertThat(event.isWithdrawn()).isTrue(); + assertThat(event.isGranted()).isFalse(); + } + + @Test + @DisplayName("getChangeType returns correct type") + void getChangeType_returnsCorrectType() { + // Given + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.TERMS_OF_SERVICE) + .build(); + + // When + ConsentChangedEvent grantedEvent = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED); + ConsentChangedEvent withdrawnEvent = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.WITHDRAWN); + + // Then + assertThat(grantedEvent.getChangeType()).isEqualTo(ConsentChangedEvent.ChangeType.GRANTED); + assertThat(withdrawnEvent.getChangeType()).isEqualTo(ConsentChangedEvent.ChangeType.WITHDRAWN); + } + + @Test + @DisplayName("Event timestamp is set on creation") + void event_timestampIsSet() { + // Given + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.PRIVACY_POLICY) + .build(); + long beforeCreation = System.currentTimeMillis(); + + // When + ConsentChangedEvent event = new ConsentChangedEvent( + eventSource, testUser, record, ConsentChangedEvent.ChangeType.GRANTED); + + // Then + long afterCreation = System.currentTimeMillis(); + assertThat(event.getTimestamp()).isGreaterThanOrEqualTo(beforeCreation); + assertThat(event.getTimestamp()).isLessThanOrEqualTo(afterCreation); + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/event/UserDeletedEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/event/UserDeletedEventTest.java new file mode 100644 index 0000000..48361c6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/event/UserDeletedEventTest.java @@ -0,0 +1,92 @@ +package com.digitalsanctuary.spring.user.event; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("UserDeletedEvent Tests") +class UserDeletedEventTest { + + private Object eventSource; + + @BeforeEach + void setUp() { + eventSource = this; + } + + @Test + @DisplayName("Event creation stores user ID and email") + void eventCreation_storesUserIdAndEmail() { + // When + UserDeletedEvent event = new UserDeletedEvent(eventSource, 1L, "test@example.com"); + + // Then + assertThat(event.getUserId()).isEqualTo(1L); + assertThat(event.getUserEmail()).isEqualTo("test@example.com"); + assertThat(event.getSource()).isEqualTo(eventSource); + } + + @Test + @DisplayName("Event with export flag true") + void eventWithExportFlag_true() { + // When + UserDeletedEvent event = new UserDeletedEvent(eventSource, 1L, "test@example.com", true); + + // Then + assertThat(event.isDataExported()).isTrue(); + } + + @Test + @DisplayName("Event with export flag false") + void eventWithExportFlag_false() { + // When + UserDeletedEvent event = new UserDeletedEvent(eventSource, 1L, "test@example.com", false); + + // Then + assertThat(event.isDataExported()).isFalse(); + } + + @Test + @DisplayName("Event without export flag defaults to false") + void eventWithoutExportFlag_defaultsToFalse() { + // When + UserDeletedEvent event = new UserDeletedEvent(eventSource, 1L, "test@example.com"); + + // Then + assertThat(event.isDataExported()).isFalse(); + } + + @Test + @DisplayName("Event with different sources") + void event_withDifferentSources() { + // Given + Object source1 = new Object(); + Object source2 = "Different Source"; + + // When + UserDeletedEvent event1 = new UserDeletedEvent(source1, 1L, "user1@example.com"); + UserDeletedEvent event2 = new UserDeletedEvent(source2, 2L, "user2@example.com"); + + // Then + assertThat(event1.getSource()).isEqualTo(source1); + assertThat(event2.getSource()).isEqualTo(source2); + assertThat(event1.getUserId()).isNotEqualTo(event2.getUserId()); + } + + @Test + @DisplayName("Event timestamp is set on creation") + void event_timestampIsSet() { + // Given + long beforeCreation = System.currentTimeMillis(); + + // When + UserDeletedEvent event = new UserDeletedEvent(eventSource, 1L, "test@example.com"); + + // Then + long afterCreation = System.currentTimeMillis(); + assertThat(event.getTimestamp()).isGreaterThanOrEqualTo(beforeCreation); + assertThat(event.getTimestamp()).isLessThanOrEqualTo(afterCreation); + } + +} From a9d07004bd183f74546daabe91597a5a3899eb89 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 1 Feb 2026 18:19:26 -0700 Subject: [PATCH 03/16] feat(gdpr): add GDPR compliance services and types Implement core GDPR functionality including data export, deletion orchestration, and consent tracking services. Core Types: - ConsentType: Enum of standard consent types (PRIVACY_POLICY, etc.) - ConsentRecord: DTO for consent grant/withdrawal data - GdprDataContributor: Interface for apps to contribute export data - GdprConfig: Configuration properties with user.gdpr.* prefix - GdprExportDTO: Complete data export response structure Services: - GdprExportService: Aggregates user data for GDPR Article 15 export - GdprDeletionService: Orchestrates GDPR Article 17 deletion with hooks - ConsentAuditService: Tracks consent changes via audit system The GdprDataContributor interface allows consuming applications to include their domain-specific data in exports and clean up during deletion, making the framework extensible while handling core data. --- .../spring/user/dto/ConsentRequestDto.java | 53 +++ .../spring/user/dto/GdprExportDTO.java | 170 +++++++ .../spring/user/gdpr/ConsentAuditService.java | 429 ++++++++++++++++++ .../spring/user/gdpr/ConsentRecord.java | 86 ++++ .../spring/user/gdpr/ConsentType.java | 84 ++++ .../spring/user/gdpr/GdprConfig.java | 44 ++ .../spring/user/gdpr/GdprDataContributor.java | 100 ++++ .../spring/user/gdpr/GdprDeletionService.java | 205 +++++++++ .../spring/user/gdpr/GdprExportService.java | 296 ++++++++++++ .../user/gdpr/ConsentAuditServiceTest.java | 292 ++++++++++++ .../user/gdpr/GdprDeletionServiceTest.java | 239 ++++++++++ .../user/gdpr/GdprExportServiceTest.java | 200 ++++++++ 12 files changed, 2198 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/dto/GdprExportDTO.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentRecord.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentType.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprConfig.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDataContributor.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java new file mode 100644 index 0000000..3281503 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java @@ -0,0 +1,53 @@ +package com.digitalsanctuary.spring.user.dto; + +import com.digitalsanctuary.spring.user.gdpr.ConsentType; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object for consent grant/withdrawal requests. + * + * @see com.digitalsanctuary.spring.user.api.GdprAPI + * @see com.digitalsanctuary.spring.user.gdpr.ConsentAuditService + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConsentRequestDto { + + /** + * The type of consent being granted or withdrawn. + */ + @NotNull(message = "Consent type is required") + private ConsentType consentType; + + /** + * For CUSTOM consent type, specifies the custom consent type name. + * Required when consentType is CUSTOM. + */ + private String customType; + + /** + * Optional version identifier for the policy document + * (e.g., "privacy-policy-v1.2"). + */ + private String policyVersion; + + /** + * Whether consent is being granted (true) or withdrawn (false). + */ + @NotNull(message = "Grant flag is required") + private Boolean grant; + + /** + * The method by which consent is being given/withdrawn. + * Common values: "web_form", "api", "checkbox". + * Defaults to "api" if not specified. + */ + private String method; + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/GdprExportDTO.java b/src/main/java/com/digitalsanctuary/spring/user/dto/GdprExportDTO.java new file mode 100644 index 0000000..54016c9 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/GdprExportDTO.java @@ -0,0 +1,170 @@ +package com.digitalsanctuary.spring.user.dto; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import com.digitalsanctuary.spring.user.audit.AuditEventDTO; +import com.digitalsanctuary.spring.user.gdpr.ConsentRecord; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object containing all exported user data for GDPR compliance. + * + *

This DTO aggregates all user data from the framework and any registered + * {@link com.digitalsanctuary.spring.user.gdpr.GdprDataContributor} implementations. + * + * @see com.digitalsanctuary.spring.user.gdpr.GdprExportService + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GdprExportDTO { + + /** + * Metadata about the export. + */ + private ExportMetadata metadata; + + /** + * Core user account data. + */ + private UserData userData; + + /** + * User's audit event history. + */ + private List auditHistory; + + /** + * User's consent records. + */ + private List consents; + + /** + * Token metadata (existence and expiry, not actual token values). + */ + private TokenMetadata tokens; + + /** + * Data contributed by application-specific {@link com.digitalsanctuary.spring.user.gdpr.GdprDataContributor} implementations. + * Map keys are the contributor's data keys. + */ + private Map additionalData; + + /** + * Metadata about the export operation. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ExportMetadata { + /** + * Timestamp when the export was generated. + */ + private Instant exportedAt; + + /** + * Version of the export format. + */ + private String formatVersion; + + /** + * Name of the exporting service/application. + */ + private String exportedBy; + } + + /** + * Core user account data. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class UserData { + /** + * User's unique identifier. + */ + private Long id; + + /** + * User's email address. + */ + private String email; + + /** + * User's first name. + */ + private String firstName; + + /** + * User's last name. + */ + private String lastName; + + /** + * Account registration date. + */ + private Instant registrationDate; + + /** + * Last activity timestamp. + */ + private Instant lastActivityDate; + + /** + * Whether the account is enabled. + */ + private boolean enabled; + + /** + * Whether the account is locked. + */ + private boolean locked; + + /** + * Authentication provider (LOCAL, GOOGLE, FACEBOOK, etc.). + */ + private String provider; + + /** + * User's assigned roles. + */ + private List roles; + } + + /** + * Token metadata (without revealing actual token values). + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TokenMetadata { + /** + * Whether a verification token exists. + */ + private boolean hasVerificationToken; + + /** + * Verification token expiry date, if exists. + */ + private Instant verificationTokenExpiry; + + /** + * Whether a password reset token exists. + */ + private boolean hasPasswordResetToken; + + /** + * Password reset token expiry date, if exists. + */ + private Instant passwordResetTokenExpiry; + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java new file mode 100644 index 0000000..11d650d --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java @@ -0,0 +1,429 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.audit.AuditEventDTO; +import com.digitalsanctuary.spring.user.audit.AuditLogQueryService; +import com.digitalsanctuary.spring.user.event.ConsentChangedEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for tracking user consent via the audit system. + * + *

This service provides methods to record consent grants and withdrawals + * using the existing audit infrastructure. Consent changes are stored as + * audit events with specific action types: + *

+ * + *

The consent details (type, version, method) are stored in the audit + * event's extraData field as JSON. + * + * @see ConsentType + * @see ConsentRecord + * @see AuditEvent + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ConsentAuditService { + + /** Action type for consent granted events. */ + public static final String ACTION_CONSENT_GRANTED = "CONSENT_GRANTED"; + + /** Action type for consent withdrawn events. */ + public static final String ACTION_CONSENT_WITHDRAWN = "CONSENT_WITHDRAWN"; + + /** Action type for consent expired events. */ + public static final String ACTION_CONSENT_EXPIRED = "CONSENT_EXPIRED"; + + private final GdprConfig gdprConfig; + private final ApplicationEventPublisher eventPublisher; + private final AuditLogQueryService auditLogQueryService; + + /** + * Records that a user has granted consent. + * + * @param user the user granting consent + * @param consentType the type of consent granted + * @param policyVersion optional version of the policy document + * @param method the method by which consent was given (e.g., "web_form", "api") + * @param request the HTTP request (for IP and user agent) + * @return the created consent record + */ + public ConsentRecord recordConsentGranted(User user, ConsentType consentType, String policyVersion, + String method, HttpServletRequest request) { + return recordConsentGranted(user, consentType, null, policyVersion, method, request); + } + + /** + * Records that a user has granted consent, with support for custom consent types. + * + * @param user the user granting consent + * @param consentType the type of consent granted + * @param customType for CUSTOM consent type, the specific type name + * @param policyVersion optional version of the policy document + * @param method the method by which consent was given + * @param request the HTTP request + * @return the created consent record + */ + public ConsentRecord recordConsentGranted(User user, ConsentType consentType, String customType, + String policyVersion, String method, HttpServletRequest request) { + if (!gdprConfig.isConsentTracking()) { + log.debug("ConsentAuditService.recordConsentGranted: Consent tracking is disabled"); + return null; + } + + if (user == null) { + throw new IllegalArgumentException("User cannot be null"); + } + if (consentType == null) { + throw new IllegalArgumentException("Consent type cannot be null"); + } + + String ipAddress = getClientIP(request); + String userAgent = request != null ? request.getHeader("User-Agent") : null; + String sessionId = request != null ? request.getSession().getId() : null; + + ConsentRecord record = ConsentRecord.builder() + .type(consentType) + .customType(customType) + .policyVersion(policyVersion) + .grantedAt(Instant.now()) + .method(method) + .ipAddress(ipAddress) + .build(); + + // Build extra data for audit log + String extraData = buildExtraData(record); + + // Publish audit event + AuditEvent auditEvent = AuditEvent.builder() + .source(this) + .user(user) + .sessionId(sessionId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .action(ACTION_CONSENT_GRANTED) + .actionStatus("Success") + .message("Consent granted: " + record.getEffectiveTypeName()) + .extraData(extraData) + .build(); + + eventPublisher.publishEvent(auditEvent); + + // Publish consent changed event + eventPublisher.publishEvent(new ConsentChangedEvent(this, user, record, + ConsentChangedEvent.ChangeType.GRANTED)); + + log.info("ConsentAuditService.recordConsentGranted: Recorded consent grant for user {} - type {}", + user.getEmail(), record.getEffectiveTypeName()); + + return record; + } + + /** + * Records that a user has withdrawn consent. + * + * @param user the user withdrawing consent + * @param consentType the type of consent being withdrawn + * @param method the method by which consent was withdrawn + * @param request the HTTP request + * @return the created consent record + */ + public ConsentRecord recordConsentWithdrawn(User user, ConsentType consentType, + String method, HttpServletRequest request) { + return recordConsentWithdrawn(user, consentType, null, method, request); + } + + /** + * Records that a user has withdrawn consent, with support for custom consent types. + * + * @param user the user withdrawing consent + * @param consentType the type of consent being withdrawn + * @param customType for CUSTOM consent type, the specific type name + * @param method the method by which consent was withdrawn + * @param request the HTTP request + * @return the created consent record + */ + public ConsentRecord recordConsentWithdrawn(User user, ConsentType consentType, String customType, + String method, HttpServletRequest request) { + if (!gdprConfig.isConsentTracking()) { + log.debug("ConsentAuditService.recordConsentWithdrawn: Consent tracking is disabled"); + return null; + } + + if (user == null) { + throw new IllegalArgumentException("User cannot be null"); + } + if (consentType == null) { + throw new IllegalArgumentException("Consent type cannot be null"); + } + + String ipAddress = getClientIP(request); + String userAgent = request != null ? request.getHeader("User-Agent") : null; + String sessionId = request != null ? request.getSession().getId() : null; + + ConsentRecord record = ConsentRecord.builder() + .type(consentType) + .customType(customType) + .withdrawnAt(Instant.now()) + .method(method) + .ipAddress(ipAddress) + .build(); + + // Build extra data for audit log + String extraData = buildExtraData(record); + + // Publish audit event + AuditEvent auditEvent = AuditEvent.builder() + .source(this) + .user(user) + .sessionId(sessionId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .action(ACTION_CONSENT_WITHDRAWN) + .actionStatus("Success") + .message("Consent withdrawn: " + record.getEffectiveTypeName()) + .extraData(extraData) + .build(); + + eventPublisher.publishEvent(auditEvent); + + // Publish consent changed event + eventPublisher.publishEvent(new ConsentChangedEvent(this, user, record, + ConsentChangedEvent.ChangeType.WITHDRAWN)); + + log.info("ConsentAuditService.recordConsentWithdrawn: Recorded consent withdrawal for user {} - type {}", + user.getEmail(), record.getEffectiveTypeName()); + + return record; + } + + /** + * Gets the current consent status for a user. + * + *

This method queries the audit log to determine which consents + * are currently active (granted but not withdrawn) for the user. + * + * @param user the user to check + * @return map of consent type names to their current status + */ + public Map getConsentStatus(User user) { + if (user == null) { + return new LinkedHashMap<>(); + } + + Map statusMap = new LinkedHashMap<>(); + + try { + // Get all consent events + List grantedEvents = auditLogQueryService.findByUserAndAction(user, ACTION_CONSENT_GRANTED); + List withdrawnEvents = auditLogQueryService.findByUserAndAction(user, ACTION_CONSENT_WITHDRAWN); + + // Process grants (most recent first) + for (AuditEventDTO event : grantedEvents) { + String typeName = extractConsentType(event.getExtraData()); + if (typeName != null && !statusMap.containsKey(typeName)) { + statusMap.put(typeName, new ConsentStatus(typeName, true, event.getTimestamp(), null)); + } + } + + // Process withdrawals + for (AuditEventDTO event : withdrawnEvents) { + String typeName = extractConsentType(event.getExtraData()); + if (typeName != null) { + ConsentStatus existing = statusMap.get(typeName); + if (existing != null && (existing.getWithdrawnAt() == null || + event.getTimestamp().isAfter(existing.getGrantedAt()))) { + statusMap.put(typeName, new ConsentStatus(typeName, false, + existing.getGrantedAt(), event.getTimestamp())); + } + } + } + + } catch (Exception e) { + log.warn("ConsentAuditService.getConsentStatus: Failed to get consent status for user {}: {}", + user.getEmail(), e.getMessage()); + } + + return statusMap; + } + + /** + * Gets all consent records for a user. + * + * @param user the user to get consents for + * @return list of consent records + */ + public List getConsentRecords(User user) { + if (user == null) { + return new ArrayList<>(); + } + + List records = new ArrayList<>(); + + try { + List allEvents = new ArrayList<>(); + allEvents.addAll(auditLogQueryService.findByUserAndAction(user, ACTION_CONSENT_GRANTED)); + allEvents.addAll(auditLogQueryService.findByUserAndAction(user, ACTION_CONSENT_WITHDRAWN)); + + for (AuditEventDTO event : allEvents) { + ConsentRecord record = parseConsentRecord(event); + if (record != null) { + records.add(record); + } + } + } catch (Exception e) { + log.warn("ConsentAuditService.getConsentRecords: Failed to get consent records for user {}: {}", + user.getEmail(), e.getMessage()); + } + + return records; + } + + /** + * Builds the extraData JSON string for an audit event. + * Uses simple string building to avoid Jackson dependency at compile time. + */ + private String buildExtraData(ConsentRecord record) { + StringBuilder sb = new StringBuilder("{"); + sb.append("\"consentType\":\"").append(escapeJson(record.getEffectiveTypeName())).append("\""); + if (record.getPolicyVersion() != null) { + sb.append(",\"policyVersion\":\"").append(escapeJson(record.getPolicyVersion())).append("\""); + } + if (record.getMethod() != null) { + sb.append(",\"method\":\"").append(escapeJson(record.getMethod())).append("\""); + } + sb.append("}"); + return sb.toString(); + } + + /** + * Escapes special characters in a string for JSON. + */ + private String escapeJson(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * Extracts the consent type from audit event extra data. + * Uses simple string parsing to avoid Jackson dependency at compile time. + */ + private String extractConsentType(String extraData) { + if (extraData == null || extraData.isEmpty()) { + return null; + } + // Parse "consentType":"value" from JSON + String key = "\"consentType\":\""; + int start = extraData.indexOf(key); + if (start == -1) { + // Try without quotes around key + key = "consentType\":\""; + start = extraData.indexOf(key); + } + if (start == -1) { + return null; + } + start += key.length(); + int end = extraData.indexOf("\"", start); + if (end > start) { + return extraData.substring(start, end); + } + return null; + } + + /** + * Parses a consent record from an audit event. + */ + private ConsentRecord parseConsentRecord(AuditEventDTO event) { + if (event == null) { + return null; + } + + String typeName = extractConsentType(event.getExtraData()); + if (typeName == null) { + return null; + } + + ConsentType type = ConsentType.fromValue(typeName); + String customType = type == ConsentType.CUSTOM ? typeName : null; + + boolean isGrant = ACTION_CONSENT_GRANTED.equals(event.getAction()); + + return ConsentRecord.builder() + .type(type) + .customType(customType) + .grantedAt(isGrant ? event.getTimestamp() : null) + .withdrawnAt(!isGrant ? event.getTimestamp() : null) + .ipAddress(event.getIpAddress()) + .build(); + } + + /** + * Gets the client IP address from the request. + */ + private String getClientIP(HttpServletRequest request) { + if (request == null) { + return null; + } + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + /** + * Represents the current status of a consent type. + */ + public static class ConsentStatus { + private final String consentType; + private final boolean active; + private final Instant grantedAt; + private final Instant withdrawnAt; + + public ConsentStatus(String consentType, boolean active, Instant grantedAt, Instant withdrawnAt) { + this.consentType = consentType; + this.active = active; + this.grantedAt = grantedAt; + this.withdrawnAt = withdrawnAt; + } + + public String getConsentType() { + return consentType; + } + + public boolean isActive() { + return active; + } + + public Instant getGrantedAt() { + return grantedAt; + } + + public Instant getWithdrawnAt() { + return withdrawnAt; + } + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentRecord.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentRecord.java new file mode 100644 index 0000000..92575c7 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentRecord.java @@ -0,0 +1,86 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object representing a consent record. + * + *

Used to track user consent grants and withdrawals for GDPR compliance. + * Consent records are stored in the audit log and can be queried for + * export or compliance reporting. + * + * @see ConsentType + * @see ConsentAuditService + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConsentRecord { + + /** + * The type of consent (e.g., PRIVACY_POLICY, MARKETING_EMAILS). + */ + private ConsentType type; + + /** + * For {@link ConsentType#CUSTOM}, specifies the custom consent type name. + * Should be null for standard consent types. + */ + private String customType; + + /** + * Optional version identifier for the policy document. + * (e.g., "privacy-policy-v1.2", "tos-2024-01"). + */ + private String policyVersion; + + /** + * The timestamp when consent was granted. + */ + private Instant grantedAt; + + /** + * The timestamp when consent was withdrawn. + * Null if consent is still active. + */ + private Instant withdrawnAt; + + /** + * The method by which consent was given or withdrawn. + * Common values: "web_form", "api", "implicit", "checkbox". + */ + private String method; + + /** + * The IP address from which consent was given or withdrawn. + */ + private String ipAddress; + + /** + * Gets the effective consent type name. + * For CUSTOM types, returns the customType value; otherwise returns the type's value. + * + * @return the effective consent type name + */ + public String getEffectiveTypeName() { + if (type == ConsentType.CUSTOM && customType != null) { + return customType; + } + return type != null ? type.getValue() : null; + } + + /** + * Checks if this consent is currently active (granted and not withdrawn). + * + * @return true if consent is active + */ + public boolean isActive() { + return grantedAt != null && withdrawnAt == null; + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentType.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentType.java new file mode 100644 index 0000000..63c8f2b --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentType.java @@ -0,0 +1,84 @@ +package com.digitalsanctuary.spring.user.gdpr; + +/** + * Enumeration of standard consent types for GDPR compliance. + * + *

These represent common categories of consent that applications + * may need to track. Use {@link #CUSTOM} for application-specific + * consent types not covered by the standard options. + * + * @see ConsentRecord + * @see ConsentAuditService + */ +public enum ConsentType { + + /** + * Consent to the terms of service. + */ + TERMS_OF_SERVICE("terms_of_service"), + + /** + * Consent to the privacy policy. + */ + PRIVACY_POLICY("privacy_policy"), + + /** + * Consent to receive marketing emails. + */ + MARKETING_EMAILS("marketing_emails"), + + /** + * Consent for data processing activities. + */ + DATA_PROCESSING("data_processing"), + + /** + * Consent to share data with third parties. + */ + THIRD_PARTY_SHARING("third_party_sharing"), + + /** + * Consent for analytics and tracking. + */ + ANALYTICS("analytics"), + + /** + * Custom consent type. Use {@link ConsentRecord#getCustomType()} + * to specify the actual consent type name. + */ + CUSTOM("custom"); + + private final String value; + + ConsentType(String value) { + this.value = value; + } + + /** + * Gets the string value of the consent type. + * + * @return the consent type value + */ + public String getValue() { + return value; + } + + /** + * Finds a ConsentType by its string value. + * + * @param value the string value to look up + * @return the matching ConsentType, or CUSTOM if not found + */ + public static ConsentType fromValue(String value) { + if (value == null) { + return CUSTOM; + } + for (ConsentType type : values()) { + if (type.value.equalsIgnoreCase(value)) { + return type; + } + } + return CUSTOM; + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprConfig.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprConfig.java new file mode 100644 index 0000000..92b8830 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprConfig.java @@ -0,0 +1,44 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.PropertySource; +import org.springframework.stereotype.Component; +import lombok.Data; + +/** + * Configuration properties for GDPR functionality. + * + *

Properties are bound from the {@code user.gdpr.*} prefix in application + * configuration. All GDPR features can be enabled or disabled via these properties. + * + * @see GdprExportService + * @see GdprDeletionService + * @see ConsentAuditService + */ +@Data +@Component +@PropertySource("classpath:config/dsspringuserconfig.properties") +@ConfigurationProperties(prefix = "user.gdpr") +public class GdprConfig { + + /** + * Master toggle for GDPR features. When disabled, GDPR endpoints + * will return 404 Not Found responses. + * Default: true + */ + private boolean enabled = true; + + /** + * If true, user data is automatically exported before hard deletion. + * The export is returned in the deletion response for the user to save. + * Default: true + */ + private boolean exportBeforeDeletion = true; + + /** + * If true, consent changes (grant/withdraw) are tracked via the audit system. + * Default: true + */ + private boolean consentTracking = true; + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDataContributor.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDataContributor.java new file mode 100644 index 0000000..9de5e50 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDataContributor.java @@ -0,0 +1,100 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import java.util.Map; +import com.digitalsanctuary.spring.user.persistence.model.User; + +/** + * Interface for components that contribute user data to GDPR exports. + * + *

Consuming applications can implement this interface to include their + * own domain-specific data in GDPR data exports. The framework automatically + * discovers all beans implementing this interface and aggregates their data + * during export operations. + * + *

Example implementation: + *

{@code
+ * @Component
+ * public class OrderDataContributor implements GdprDataContributor {
+ *
+ *     private final OrderRepository orderRepository;
+ *
+ *     @Override
+ *     public String getDataKey() {
+ *         return "orders";
+ *     }
+ *
+ *     @Override
+ *     public Map exportUserData(User user) {
+ *         List orders = orderRepository.findByUserId(user.getId());
+ *         return Map.of(
+ *             "count", orders.size(),
+ *             "orders", orders.stream().map(this::toExportFormat).toList()
+ *         );
+ *     }
+ *
+ *     @Override
+ *     public void prepareForDeletion(User user) {
+ *         // Delete or anonymize order records
+ *         orderRepository.anonymizeByUserId(user.getId());
+ *     }
+ * }
+ * }
+ * + * @see GdprExportService + * @see GdprDeletionService + */ +public interface GdprDataContributor { + + /** + * Returns a unique key identifying this data section in the export. + * + *

The key should be a simple, descriptive identifier that clearly + * indicates what data this contributor provides (e.g., "orders", + * "preferences", "activity_log"). + * + * @return a unique data section key + */ + String getDataKey(); + + /** + * Exports GDPR-relevant data for the given user. + * + *

Returns a map containing the user's data managed by this contributor. + * The map structure should be suitable for JSON serialization. + * Return null or an empty map if no data exists for this user. + * + *

This method should include all data that: + *

+ * + * @param user the user whose data to export + * @return a map of exportable data, or null/empty if no data exists + */ + Map exportUserData(User user); + + /** + * Called before user deletion to clean up related data. + * + *

The framework handles deletion of the User entity and related + * framework-managed data. Implementers should handle cleanup of their + * own domain-specific tables and data. + * + *

This method is called within the deletion transaction, so any + * exceptions will cause the entire deletion to roll back. + * + *

Implementations should either: + *

+ * + * @param user the user being deleted + */ + default void prepareForDeletion(User user) { + // Default no-op; implementations can override to clean up their data + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java new file mode 100644 index 0000000..344cbcc --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java @@ -0,0 +1,205 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.dto.GdprExportDTO; +import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for handling GDPR-compliant user deletion (Right to be Forgotten). + * + *

This service orchestrates the complete user deletion process including: + *

+ * + * @see GdprDataContributor + * @see GdprExportService + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GdprDeletionService { + + private final GdprConfig gdprConfig; + private final GdprExportService gdprExportService; + private final UserRepository userRepository; + private final VerificationTokenRepository verificationTokenRepository; + private final PasswordResetTokenRepository passwordResetTokenRepository; + private final List dataContributors; + private final ApplicationEventPublisher eventPublisher; + + /** + * Result of a GDPR deletion operation. + */ + public static class DeletionResult { + private final boolean success; + private final GdprExportDTO exportedData; + private final String message; + + private DeletionResult(boolean success, GdprExportDTO exportedData, String message) { + this.success = success; + this.exportedData = exportedData; + this.message = message; + } + + public static DeletionResult success(GdprExportDTO exportedData) { + return new DeletionResult(true, exportedData, "User data deleted successfully"); + } + + public static DeletionResult successWithExport(GdprExportDTO exportedData) { + return new DeletionResult(true, exportedData, "User data exported and deleted successfully"); + } + + public static DeletionResult failure(String message) { + return new DeletionResult(false, null, message); + } + + public boolean isSuccess() { + return success; + } + + public GdprExportDTO getExportedData() { + return exportedData; + } + + public String getMessage() { + return message; + } + } + + /** + * Deletes a user and all associated data in a GDPR-compliant manner. + * + *

If {@code exportBeforeDeletion} is enabled in configuration, the user's + * data is exported and included in the result before deletion. + * + * @param user the user to delete + * @return the result of the deletion operation + * @throws IllegalArgumentException if user is null + */ + @Transactional + public DeletionResult deleteUser(User user) { + return deleteUser(user, gdprConfig.isExportBeforeDeletion()); + } + + /** + * Deletes a user and all associated data, with explicit export control. + * + * @param user the user to delete + * @param exportBeforeDeletion whether to export data before deletion + * @return the result of the deletion operation + * @throws IllegalArgumentException if user is null + */ + @Transactional + public DeletionResult deleteUser(User user, boolean exportBeforeDeletion) { + if (user == null) { + throw new IllegalArgumentException("User cannot be null"); + } + + Long userId = user.getId(); + String userEmail = user.getEmail(); + + log.info("GdprDeletionService.deleteUser: Starting GDPR deletion for user {}", userEmail); + + GdprExportDTO exportedData = null; + + try { + // Step 1: Export data if configured + if (exportBeforeDeletion) { + log.debug("GdprDeletionService.deleteUser: Exporting data before deletion for user {}", userEmail); + exportedData = gdprExportService.exportUserData(user); + } + + // Step 2: Notify all GdprDataContributors to prepare for deletion + prepareContributorsForDeletion(user); + + // Step 3: Publish UserPreDeleteEvent for additional cleanup + log.debug("GdprDeletionService.deleteUser: Publishing UserPreDeleteEvent for user {}", userEmail); + eventPublisher.publishEvent(new UserPreDeleteEvent(this, user)); + + // Step 4: Delete framework-managed data + deleteFrameworkData(user); + + // Step 5: Delete the user entity + log.debug("GdprDeletionService.deleteUser: Deleting user entity for {}", userEmail); + userRepository.delete(user); + + log.info("GdprDeletionService.deleteUser: Successfully deleted user {}", userEmail); + + // Step 6: Publish UserDeletedEvent after successful deletion + eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail, exportBeforeDeletion)); + + return exportBeforeDeletion + ? DeletionResult.successWithExport(exportedData) + : DeletionResult.success(null); + + } catch (Exception e) { + log.error("GdprDeletionService.deleteUser: Failed to delete user {}: {}", + userEmail, e.getMessage(), e); + return DeletionResult.failure("Failed to delete user: " + e.getMessage()); + } + } + + /** + * Notifies all GdprDataContributors to prepare for deletion. + */ + private void prepareContributorsForDeletion(User user) { + if (dataContributors == null || dataContributors.isEmpty()) { + return; + } + + for (GdprDataContributor contributor : dataContributors) { + try { + log.debug("GdprDeletionService.prepareContributorsForDeletion: Calling prepareForDeletion on '{}'", + contributor.getDataKey()); + contributor.prepareForDeletion(user); + } catch (Exception e) { + log.error("GdprDeletionService.prepareContributorsForDeletion: Contributor '{}' failed: {}", + contributor.getDataKey(), e.getMessage(), e); + // Re-throw to trigger transaction rollback + throw new RuntimeException("Data contributor '" + contributor.getDataKey() + + "' failed during deletion preparation: " + e.getMessage(), e); + } + } + } + + /** + * Deletes framework-managed data associated with the user. + */ + private void deleteFrameworkData(User user) { + // Delete verification token + VerificationToken verificationToken = verificationTokenRepository.findByUser(user); + if (verificationToken != null) { + log.debug("GdprDeletionService.deleteFrameworkData: Deleting verification token for user {}", + user.getEmail()); + verificationTokenRepository.delete(verificationToken); + } + + // Delete password reset token + PasswordResetToken passwordResetToken = passwordResetTokenRepository.findByUser(user); + if (passwordResetToken != null) { + log.debug("GdprDeletionService.deleteFrameworkData: Deleting password reset token for user {}", + user.getEmail()); + passwordResetTokenRepository.delete(passwordResetToken); + } + + // Password history entries are deleted automatically via cascade (orphanRemoval = true) + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java new file mode 100644 index 0000000..d5fa4af --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java @@ -0,0 +1,296 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import com.digitalsanctuary.spring.user.audit.AuditEventDTO; +import com.digitalsanctuary.spring.user.audit.AuditLogQueryService; +import com.digitalsanctuary.spring.user.dto.GdprExportDTO; +import com.digitalsanctuary.spring.user.event.UserDataExportedEvent; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.Role; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for exporting user data in GDPR-compliant format. + * + *

This service aggregates all user data from the framework and any registered + * {@link GdprDataContributor} implementations to create a comprehensive data export + * as required by GDPR Article 15 (Right of Access) and Article 20 (Right to Data Portability). + * + *

The export includes: + *

    + *
  • Core user account data (name, email, registration date, etc.)
  • + *
  • Audit history for the user
  • + *
  • Consent records
  • + *
  • Token metadata (not actual tokens)
  • + *
  • Data from registered {@link GdprDataContributor} beans
  • + *
+ * + * @see GdprDataContributor + * @see GdprExportDTO + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GdprExportService { + + private static final String EXPORT_FORMAT_VERSION = "1.0"; + private static final String EXPORTER_NAME = "Spring User Framework"; + + private final GdprConfig gdprConfig; + private final AuditLogQueryService auditLogQueryService; + private final VerificationTokenRepository verificationTokenRepository; + private final PasswordResetTokenRepository passwordResetTokenRepository; + private final List dataContributors; + private final ApplicationEventPublisher eventPublisher; + + /** + * Exports all GDPR-relevant data for a user. + * + * @param user the user whose data to export + * @return the complete GDPR export DTO + * @throws IllegalArgumentException if user is null + */ + public GdprExportDTO exportUserData(User user) { + if (user == null) { + throw new IllegalArgumentException("User cannot be null"); + } + + log.info("GdprExportService.exportUserData: Starting export for user {}", user.getEmail()); + + GdprExportDTO export = GdprExportDTO.builder() + .metadata(buildMetadata()) + .userData(buildUserData(user)) + .auditHistory(exportAuditHistory(user)) + .consents(exportConsents(user)) + .tokens(exportTokenMetadata(user)) + .additionalData(exportContributorData(user)) + .build(); + + // Publish event after successful export + eventPublisher.publishEvent(new UserDataExportedEvent(this, user, export)); + + log.info("GdprExportService.exportUserData: Completed export for user {}", user.getEmail()); + return export; + } + + /** + * Builds export metadata. + */ + private GdprExportDTO.ExportMetadata buildMetadata() { + return GdprExportDTO.ExportMetadata.builder() + .exportedAt(Instant.now()) + .formatVersion(EXPORT_FORMAT_VERSION) + .exportedBy(EXPORTER_NAME) + .build(); + } + + /** + * Builds the user data section. + */ + private GdprExportDTO.UserData buildUserData(User user) { + List roleNames = user.getRoles().stream() + .map(Role::getName) + .collect(Collectors.toList()); + + return GdprExportDTO.UserData.builder() + .id(user.getId()) + .email(user.getEmail()) + .firstName(user.getFirstName()) + .lastName(user.getLastName()) + .registrationDate(user.getRegistrationDate() != null + ? user.getRegistrationDate().toInstant() : null) + .lastActivityDate(user.getLastActivityDate() != null + ? user.getLastActivityDate().toInstant() : null) + .enabled(user.isEnabled()) + .locked(user.isLocked()) + .provider(user.getProvider() != null ? user.getProvider().name() : null) + .roles(roleNames) + .build(); + } + + /** + * Exports the user's audit history. + */ + private List exportAuditHistory(User user) { + try { + return auditLogQueryService.findByUser(user); + } catch (Exception e) { + log.warn("GdprExportService.exportAuditHistory: Failed to export audit history for user {}: {}", + user.getEmail(), e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * Exports the user's consent records from audit logs. + */ + private List exportConsents(User user) { + if (!gdprConfig.isConsentTracking()) { + return new ArrayList<>(); + } + + try { + // Get all consent-related audit events + List grantedEvents = auditLogQueryService.findByUserAndAction(user, "CONSENT_GRANTED"); + List withdrawnEvents = auditLogQueryService.findByUserAndAction(user, "CONSENT_WITHDRAWN"); + + // Build consent records from audit events + Map consentMap = new LinkedHashMap<>(); + + // Process granted consents + for (AuditEventDTO event : grantedEvents) { + ConsentRecord record = parseConsentFromAuditEvent(event, true); + if (record != null) { + consentMap.put(record.getEffectiveTypeName(), record); + } + } + + // Process withdrawals (update existing records) + for (AuditEventDTO event : withdrawnEvents) { + ConsentRecord record = parseConsentFromAuditEvent(event, false); + if (record != null) { + String key = record.getEffectiveTypeName(); + ConsentRecord existing = consentMap.get(key); + if (existing != null) { + existing.setWithdrawnAt(record.getWithdrawnAt()); + } else { + consentMap.put(key, record); + } + } + } + + return new ArrayList<>(consentMap.values()); + } catch (Exception e) { + log.warn("GdprExportService.exportConsents: Failed to export consents for user {}: {}", + user.getEmail(), e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * Parses a consent record from an audit event. + */ + private ConsentRecord parseConsentFromAuditEvent(AuditEventDTO event, boolean isGrant) { + if (event == null || event.getExtraData() == null) { + return null; + } + + try { + // Parse extraData JSON (simplified - assumes key=value format or JSON) + String extraData = event.getExtraData(); + ConsentType type = ConsentType.CUSTOM; + String customType = null; + String policyVersion = null; + String method = null; + + // Basic parsing of extraData + if (extraData.contains("consentType=")) { + String typeValue = extractValue(extraData, "consentType"); + type = ConsentType.fromValue(typeValue); + if (type == ConsentType.CUSTOM) { + customType = typeValue; + } + } + if (extraData.contains("policyVersion=")) { + policyVersion = extractValue(extraData, "policyVersion"); + } + if (extraData.contains("method=")) { + method = extractValue(extraData, "method"); + } + + ConsentRecord.ConsentRecordBuilder builder = ConsentRecord.builder() + .type(type) + .customType(customType) + .policyVersion(policyVersion) + .method(method) + .ipAddress(event.getIpAddress()); + + if (isGrant) { + builder.grantedAt(event.getTimestamp()); + } else { + builder.withdrawnAt(event.getTimestamp()); + } + + return builder.build(); + } catch (Exception e) { + log.debug("GdprExportService.parseConsentFromAuditEvent: Failed to parse consent from event", e); + return null; + } + } + + /** + * Extracts a value from a simple key=value string. + */ + private String extractValue(String data, String key) { + int start = data.indexOf(key + "="); + if (start == -1) { + return null; + } + start += key.length() + 1; + int end = data.indexOf(",", start); + if (end == -1) { + end = data.indexOf("}", start); + } + if (end == -1) { + end = data.length(); + } + return data.substring(start, end).trim().replace("\"", ""); + } + + /** + * Exports token metadata (existence and expiry, not actual tokens). + */ + private GdprExportDTO.TokenMetadata exportTokenMetadata(User user) { + VerificationToken verificationToken = verificationTokenRepository.findByUser(user); + PasswordResetToken passwordResetToken = passwordResetTokenRepository.findByUser(user); + + return GdprExportDTO.TokenMetadata.builder() + .hasVerificationToken(verificationToken != null) + .verificationTokenExpiry(verificationToken != null && verificationToken.getExpiryDate() != null + ? verificationToken.getExpiryDate().toInstant() : null) + .hasPasswordResetToken(passwordResetToken != null) + .passwordResetTokenExpiry(passwordResetToken != null && passwordResetToken.getExpiryDate() != null + ? passwordResetToken.getExpiryDate().toInstant() : null) + .build(); + } + + /** + * Exports data from all registered GdprDataContributor beans. + */ + private Map exportContributorData(User user) { + Map additionalData = new LinkedHashMap<>(); + + if (dataContributors == null || dataContributors.isEmpty()) { + return additionalData; + } + + for (GdprDataContributor contributor : dataContributors) { + try { + String key = contributor.getDataKey(); + Map data = contributor.exportUserData(user); + if (data != null && !data.isEmpty()) { + additionalData.put(key, data); + log.debug("GdprExportService.exportContributorData: Added data from contributor '{}'", key); + } + } catch (Exception e) { + log.warn("GdprExportService.exportContributorData: Contributor {} failed: {}", + contributor.getDataKey(), e.getMessage()); + } + } + + return additionalData; + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java new file mode 100644 index 0000000..94aaa66 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditServiceTest.java @@ -0,0 +1,292 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.ArrayList; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.audit.AuditLogQueryService; +import com.digitalsanctuary.spring.user.event.ConsentChangedEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +@ServiceTest +@DisplayName("ConsentAuditService Tests") +class ConsentAuditServiceTest { + + @Mock + private GdprConfig gdprConfig; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private AuditLogQueryService auditLogQueryService; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpSession session; + + @InjectMocks + private ConsentAuditService consentAuditService; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = UserTestDataBuilder.aVerifiedUser() + .withId(1L) + .withEmail("test@example.com") + .build(); + } + + private void setupMocksForConsentRecording() { + when(gdprConfig.isConsentTracking()).thenReturn(true); + when(request.getSession()).thenReturn(session); + when(session.getId()).thenReturn("test-session-id"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + } + + @Nested + @DisplayName("recordConsentGranted") + class RecordConsentGranted { + + @Test + @DisplayName("throws exception when user is null") + void throwsException_whenUserIsNull() { + when(gdprConfig.isConsentTracking()).thenReturn(true); + assertThatThrownBy(() -> consentAuditService.recordConsentGranted( + null, ConsentType.PRIVACY_POLICY, null, "web_form", request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User cannot be null"); + } + + @Test + @DisplayName("throws exception when consent type is null") + void throwsException_whenConsentTypeIsNull() { + when(gdprConfig.isConsentTracking()).thenReturn(true); + assertThatThrownBy(() -> consentAuditService.recordConsentGranted( + testUser, null, null, "web_form", request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Consent type cannot be null"); + } + + @Test + @DisplayName("returns null when consent tracking is disabled") + void returnsNull_whenConsentTrackingDisabled() { + // Given + when(gdprConfig.isConsentTracking()).thenReturn(false); + + // When + ConsentRecord result = consentAuditService.recordConsentGranted( + testUser, ConsentType.PRIVACY_POLICY, null, "web_form", request); + + // Then + assertThat(result).isNull(); + verify(eventPublisher, never()).publishEvent(any(AuditEvent.class)); + } + + @Test + @DisplayName("creates consent record with correct data") + void createsConsentRecord_withCorrectData() { + // Given + setupMocksForConsentRecording(); + + // When + ConsentRecord result = consentAuditService.recordConsentGranted( + testUser, ConsentType.PRIVACY_POLICY, "v1.0", "web_form", request); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(ConsentType.PRIVACY_POLICY); + assertThat(result.getPolicyVersion()).isEqualTo("v1.0"); + assertThat(result.getMethod()).isEqualTo("web_form"); + assertThat(result.getGrantedAt()).isNotNull(); + assertThat(result.getWithdrawnAt()).isNull(); + assertThat(result.isActive()).isTrue(); + } + + @Test + @DisplayName("publishes audit event") + void publishesAuditEvent() { + // Given + setupMocksForConsentRecording(); + + // When + consentAuditService.recordConsentGranted( + testUser, ConsentType.PRIVACY_POLICY, "v1.0", "web_form", request); + + // Then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AuditEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + AuditEvent capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.getAction()).isEqualTo("CONSENT_GRANTED"); + assertThat(capturedEvent.getActionStatus()).isEqualTo("Success"); + assertThat(capturedEvent.getUser()).isEqualTo(testUser); + } + + @Test + @DisplayName("publishes ConsentChangedEvent") + void publishesConsentChangedEvent() { + // Given + setupMocksForConsentRecording(); + + // When + consentAuditService.recordConsentGranted( + testUser, ConsentType.MARKETING_EMAILS, null, "api", request); + + // Then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ConsentChangedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + ConsentChangedEvent capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.getUser()).isEqualTo(testUser); + assertThat(capturedEvent.getConsentType()).isEqualTo(ConsentType.MARKETING_EMAILS); + assertThat(capturedEvent.isGranted()).isTrue(); + } + + @Test + @DisplayName("handles custom consent type") + void handlesCustomConsentType() { + // Given + setupMocksForConsentRecording(); + + // When + ConsentRecord result = consentAuditService.recordConsentGranted( + testUser, ConsentType.CUSTOM, "my_custom_consent", null, "web_form", request); + + // Then + assertThat(result.getType()).isEqualTo(ConsentType.CUSTOM); + assertThat(result.getCustomType()).isEqualTo("my_custom_consent"); + assertThat(result.getEffectiveTypeName()).isEqualTo("my_custom_consent"); + } + } + + @Nested + @DisplayName("recordConsentWithdrawn") + class RecordConsentWithdrawn { + + @Test + @DisplayName("creates withdrawal record") + void createsWithdrawalRecord() { + // Given + setupMocksForConsentRecording(); + + // When + ConsentRecord result = consentAuditService.recordConsentWithdrawn( + testUser, ConsentType.MARKETING_EMAILS, "web_form", request); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(ConsentType.MARKETING_EMAILS); + assertThat(result.getWithdrawnAt()).isNotNull(); + assertThat(result.getGrantedAt()).isNull(); + assertThat(result.isActive()).isFalse(); + } + + @Test + @DisplayName("publishes audit event for withdrawal") + void publishesAuditEvent_forWithdrawal() { + // Given + setupMocksForConsentRecording(); + + // When + consentAuditService.recordConsentWithdrawn( + testUser, ConsentType.ANALYTICS, "api", request); + + // Then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AuditEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + AuditEvent capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.getAction()).isEqualTo("CONSENT_WITHDRAWN"); + } + + @Test + @DisplayName("publishes ConsentChangedEvent for withdrawal") + void publishesConsentChangedEvent_forWithdrawal() { + // Given + setupMocksForConsentRecording(); + + // When + consentAuditService.recordConsentWithdrawn( + testUser, ConsentType.THIRD_PARTY_SHARING, "api", request); + + // Then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ConsentChangedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + ConsentChangedEvent capturedEvent = eventCaptor.getValue(); + assertThat(capturedEvent.isWithdrawn()).isTrue(); + } + } + + @Nested + @DisplayName("getConsentStatus") + class GetConsentStatus { + + @Test + @DisplayName("returns empty map when user is null") + void returnsEmptyMap_whenUserIsNull() { + Map result = consentAuditService.getConsentStatus(null); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("returns empty map when no consent events") + void returnsEmptyMap_whenNoConsentEvents() { + // Given + when(auditLogQueryService.findByUserAndAction(testUser, "CONSENT_GRANTED")) + .thenReturn(new ArrayList<>()); + when(auditLogQueryService.findByUserAndAction(testUser, "CONSENT_WITHDRAWN")) + .thenReturn(new ArrayList<>()); + + // When + Map result = consentAuditService.getConsentStatus(testUser); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("ConsentStatus") + class ConsentStatusTests { + + @Test + @DisplayName("correctly reports active status") + void correctlyReportsActiveStatus() { + ConsentAuditService.ConsentStatus status = new ConsentAuditService.ConsentStatus( + "privacy_policy", true, null, null); + assertThat(status.isActive()).isTrue(); + assertThat(status.getConsentType()).isEqualTo("privacy_policy"); + } + + @Test + @DisplayName("correctly reports inactive status") + void correctlyReportsInactiveStatus() { + ConsentAuditService.ConsentStatus status = new ConsentAuditService.ConsentStatus( + "marketing", false, null, null); + assertThat(status.isActive()).isFalse(); + } + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java new file mode 100644 index 0000000..acfb157 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionServiceTest.java @@ -0,0 +1,239 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import com.digitalsanctuary.spring.user.dto.GdprExportDTO; +import com.digitalsanctuary.spring.user.event.UserDeletedEvent; +import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +@ServiceTest +@DisplayName("GdprDeletionService Tests") +class GdprDeletionServiceTest { + + @Mock + private GdprConfig gdprConfig; + + @Mock + private GdprExportService gdprExportService; + + @Mock + private UserRepository userRepository; + + @Mock + private VerificationTokenRepository verificationTokenRepository; + + @Mock + private PasswordResetTokenRepository passwordResetTokenRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private GdprDeletionService gdprDeletionService; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = UserTestDataBuilder.aVerifiedUser() + .withId(1L) + .withEmail("test@example.com") + .withFirstName("Test") + .withLastName("User") + .build(); + } + + @Nested + @DisplayName("deleteUser") + class DeleteUser { + + @Test + @DisplayName("throws exception when user is null") + void throwsException_whenUserIsNull() { + assertThatThrownBy(() -> gdprDeletionService.deleteUser(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User cannot be null"); + } + + @Test + @DisplayName("successfully deletes user") + void successfullyDeletesUser() { + // Given + when(gdprConfig.isExportBeforeDeletion()).thenReturn(false); + + // When + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(testUser); + + // Then + assertThat(result.isSuccess()).isTrue(); + verify(userRepository).delete(testUser); + } + + @Test + @DisplayName("publishes UserPreDeleteEvent before deletion") + void publishesUserPreDeleteEvent_beforeDeletion() { + // Given + when(gdprConfig.isExportBeforeDeletion()).thenReturn(false); + + // When + gdprDeletionService.deleteUser(testUser); + + // Then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserPreDeleteEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().getUser()).isEqualTo(testUser); + } + + @Test + @DisplayName("publishes UserDeletedEvent after deletion") + void publishesUserDeletedEvent_afterDeletion() { + // Given + when(gdprConfig.isExportBeforeDeletion()).thenReturn(false); + + // When + gdprDeletionService.deleteUser(testUser); + + // Then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserDeletedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().getUserId()).isEqualTo(1L); + assertThat(eventCaptor.getValue().getUserEmail()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("deletes verification token") + void deletesVerificationToken() { + // Given + VerificationToken token = new VerificationToken("token", testUser); + when(gdprConfig.isExportBeforeDeletion()).thenReturn(false); + when(verificationTokenRepository.findByUser(testUser)).thenReturn(token); + + // When + gdprDeletionService.deleteUser(testUser); + + // Then + verify(verificationTokenRepository).delete(token); + } + + @Test + @DisplayName("deletes password reset token") + void deletesPasswordResetToken() { + // Given + PasswordResetToken token = new PasswordResetToken("token", testUser); + when(gdprConfig.isExportBeforeDeletion()).thenReturn(false); + when(passwordResetTokenRepository.findByUser(testUser)).thenReturn(token); + + // When + gdprDeletionService.deleteUser(testUser); + + // Then + verify(passwordResetTokenRepository).delete(token); + } + } + + @Nested + @DisplayName("deleteUser with export") + class DeleteUserWithExport { + + @Test + @DisplayName("exports data before deletion when configured") + void exportsData_beforeDeletion_whenConfigured() { + // Given + GdprExportDTO mockExport = GdprExportDTO.builder().build(); + when(gdprConfig.isExportBeforeDeletion()).thenReturn(true); + when(gdprExportService.exportUserData(testUser)).thenReturn(mockExport); + + // When + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(testUser); + + // Then + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getExportedData()).isNotNull(); + verify(gdprExportService).exportUserData(testUser); + } + + @Test + @DisplayName("does not export when disabled") + void doesNotExport_whenDisabled() { + // When - pass explicit false for exportBeforeDeletion + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(testUser, false); + + // Then + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getExportedData()).isNull(); + verify(gdprExportService, never()).exportUserData(any()); + } + + @Test + @DisplayName("includes exported data in result") + void includesExportedData_inResult() { + // Given + GdprExportDTO mockExport = GdprExportDTO.builder() + .metadata(GdprExportDTO.ExportMetadata.builder() + .formatVersion("1.0") + .build()) + .build(); + when(gdprExportService.exportUserData(testUser)).thenReturn(mockExport); + + // When + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(testUser, true); + + // Then + assertThat(result.getExportedData()).isEqualTo(mockExport); + assertThat(result.getMessage()).contains("exported"); + } + } + + @Nested + @DisplayName("DeletionResult") + class DeletionResultTests { + + @Test + @DisplayName("success creates successful result") + void success_createsSuccessfulResult() { + GdprDeletionService.DeletionResult result = GdprDeletionService.DeletionResult.success(null); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getExportedData()).isNull(); + } + + @Test + @DisplayName("successWithExport includes export data") + void successWithExport_includesExportData() { + GdprExportDTO export = GdprExportDTO.builder().build(); + GdprDeletionService.DeletionResult result = GdprDeletionService.DeletionResult.successWithExport(export); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getExportedData()).isEqualTo(export); + } + + @Test + @DisplayName("failure creates failed result") + void failure_createsFailedResult() { + GdprDeletionService.DeletionResult result = GdprDeletionService.DeletionResult.failure("Error message"); + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).isEqualTo("Error message"); + } + } + +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java new file mode 100644 index 0000000..70e1b84 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/gdpr/GdprExportServiceTest.java @@ -0,0 +1,200 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; +import com.digitalsanctuary.spring.user.audit.AuditEventDTO; +import com.digitalsanctuary.spring.user.audit.AuditLogQueryService; +import com.digitalsanctuary.spring.user.dto.GdprExportDTO; +import com.digitalsanctuary.spring.user.event.UserDataExportedEvent; +import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; +import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; +import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; + +@ServiceTest +@DisplayName("GdprExportService Tests") +class GdprExportServiceTest { + + @Mock + private GdprConfig gdprConfig; + + @Mock + private AuditLogQueryService auditLogQueryService; + + @Mock + private VerificationTokenRepository verificationTokenRepository; + + @Mock + private PasswordResetTokenRepository passwordResetTokenRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private GdprExportService gdprExportService; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = UserTestDataBuilder.aVerifiedUser() + .withId(1L) + .withEmail("test@example.com") + .withFirstName("Test") + .withLastName("User") + .build(); + } + + @Nested + @DisplayName("exportUserData") + class ExportUserData { + + @Test + @DisplayName("throws exception when user is null") + void throwsException_whenUserIsNull() { + assertThatThrownBy(() -> gdprExportService.exportUserData(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("User cannot be null"); + } + + @Test + @DisplayName("exports basic user data") + void exportsBasicUserData() { + // Given + when(gdprConfig.isConsentTracking()).thenReturn(true); + when(auditLogQueryService.findByUser(testUser)).thenReturn(new ArrayList<>()); + when(auditLogQueryService.findByUserAndAction(any(), any())).thenReturn(new ArrayList<>()); + + // When + GdprExportDTO export = gdprExportService.exportUserData(testUser); + + // Then + assertThat(export).isNotNull(); + assertThat(export.getUserData()).isNotNull(); + assertThat(export.getUserData().getId()).isEqualTo(1L); + assertThat(export.getUserData().getEmail()).isEqualTo("test@example.com"); + assertThat(export.getUserData().getFirstName()).isEqualTo("Test"); + assertThat(export.getUserData().getLastName()).isEqualTo("User"); + assertThat(export.getUserData().isEnabled()).isTrue(); + } + + @Test + @DisplayName("includes export metadata") + void includesExportMetadata() { + // Given + when(gdprConfig.isConsentTracking()).thenReturn(true); + when(auditLogQueryService.findByUser(testUser)).thenReturn(new ArrayList<>()); + when(auditLogQueryService.findByUserAndAction(any(), any())).thenReturn(new ArrayList<>()); + + // When + GdprExportDTO export = gdprExportService.exportUserData(testUser); + + // Then + assertThat(export.getMetadata()).isNotNull(); + assertThat(export.getMetadata().getExportedAt()).isNotNull(); + assertThat(export.getMetadata().getFormatVersion()).isEqualTo("1.0"); + assertThat(export.getMetadata().getExportedBy()).isEqualTo("Spring User Framework"); + } + + @Test + @DisplayName("exports audit history") + void exportsAuditHistory() { + // Given + when(gdprConfig.isConsentTracking()).thenReturn(true); + List auditEvents = List.of( + AuditEventDTO.builder() + .timestamp(Instant.now()) + .action("Login") + .actionStatus("Success") + .userEmail("test@example.com") + .build() + ); + when(auditLogQueryService.findByUser(testUser)).thenReturn(auditEvents); + when(auditLogQueryService.findByUserAndAction(any(), any())).thenReturn(new ArrayList<>()); + + // When + GdprExportDTO export = gdprExportService.exportUserData(testUser); + + // Then + assertThat(export.getAuditHistory()).hasSize(1); + assertThat(export.getAuditHistory().get(0).getAction()).isEqualTo("Login"); + } + + @Test + @DisplayName("exports token metadata without exposing tokens") + void exportsTokenMetadata_withoutExposingTokens() { + // Given + when(gdprConfig.isConsentTracking()).thenReturn(true); + VerificationToken verificationToken = new VerificationToken("secret-token", testUser); + when(verificationTokenRepository.findByUser(testUser)).thenReturn(verificationToken); + when(passwordResetTokenRepository.findByUser(testUser)).thenReturn(null); + when(auditLogQueryService.findByUser(testUser)).thenReturn(new ArrayList<>()); + when(auditLogQueryService.findByUserAndAction(any(), any())).thenReturn(new ArrayList<>()); + + // When + GdprExportDTO export = gdprExportService.exportUserData(testUser); + + // Then + assertThat(export.getTokens()).isNotNull(); + assertThat(export.getTokens().isHasVerificationToken()).isTrue(); + assertThat(export.getTokens().getVerificationTokenExpiry()).isNotNull(); + assertThat(export.getTokens().isHasPasswordResetToken()).isFalse(); + } + + @Test + @DisplayName("publishes UserDataExportedEvent") + void publishesUserDataExportedEvent() { + // Given + when(gdprConfig.isConsentTracking()).thenReturn(true); + when(auditLogQueryService.findByUser(testUser)).thenReturn(new ArrayList<>()); + when(auditLogQueryService.findByUserAndAction(any(), any())).thenReturn(new ArrayList<>()); + + // When + gdprExportService.exportUserData(testUser); + + // Then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserDataExportedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().getUser()).isEqualTo(testUser); + } + } + + @Nested + @DisplayName("GdprDataContributor integration") + class DataContributorIntegration { + + @Test + @DisplayName("aggregates data from contributors") + void aggregatesDataFromContributors() { + // Given - contributors are injected as a List + when(gdprConfig.isConsentTracking()).thenReturn(true); + when(auditLogQueryService.findByUser(testUser)).thenReturn(new ArrayList<>()); + when(auditLogQueryService.findByUserAndAction(any(), any())).thenReturn(new ArrayList<>()); + + // When + GdprExportDTO export = gdprExportService.exportUserData(testUser); + + // Then + assertThat(export.getAdditionalData()).isNotNull(); + } + } + +} From 22a5697633b2b6df6b46803cc2c044b87f79d486 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 1 Feb 2026 18:19:31 -0700 Subject: [PATCH 04/16] feat(api): add GDPR REST API endpoints Add REST controller with endpoints for GDPR operations at /user/gdpr/* following the existing UserAPI pattern. Endpoints: - GET /user/gdpr/export - Export current user's data as JSON - POST /user/gdpr/delete - Request account deletion - POST /user/gdpr/consent - Record consent grant/withdrawal - GET /user/gdpr/consent - Get current consent status All endpoints require authentication and return standard JSONResponse format. When GDPR features are disabled via configuration, endpoints return 404 Not Found. --- .../spring/user/api/GdprAPI.java | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java new file mode 100644 index 0000000..90752a7 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java @@ -0,0 +1,328 @@ +package com.digitalsanctuary.spring.user.api; + +import java.util.Map; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.dto.ConsentRequestDto; +import com.digitalsanctuary.spring.user.dto.GdprExportDTO; +import com.digitalsanctuary.spring.user.gdpr.ConsentAuditService; +import com.digitalsanctuary.spring.user.gdpr.ConsentRecord; +import com.digitalsanctuary.spring.user.gdpr.ConsentType; +import com.digitalsanctuary.spring.user.gdpr.GdprConfig; +import com.digitalsanctuary.spring.user.gdpr.GdprDeletionService; +import com.digitalsanctuary.spring.user.gdpr.GdprExportService; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.service.DSUserDetails; +import com.digitalsanctuary.spring.user.service.UserService; +import com.digitalsanctuary.spring.user.util.JSONResponse; +import com.digitalsanctuary.spring.user.util.UserUtils; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * REST API controller for GDPR-related operations. + * + *

Provides JSON endpoints for GDPR compliance including: + *

    + *
  • Data export (Right of Access)
  • + *
  • Account deletion (Right to be Forgotten)
  • + *
  • Consent management
  • + *
+ * + *

All endpoints require authentication and return JSON responses. + * + * @author Devon Hillard + * @see GdprExportService + * @see GdprDeletionService + * @see ConsentAuditService + */ +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping(path = "/user/gdpr", produces = "application/json") +public class GdprAPI { + + private final GdprConfig gdprConfig; + private final GdprExportService gdprExportService; + private final GdprDeletionService gdprDeletionService; + private final ConsentAuditService consentAuditService; + private final UserService userService; + private final MessageSource messages; + private final ApplicationEventPublisher eventPublisher; + + /** + * Exports all GDPR-relevant data for the authenticated user. + * + *

Returns a comprehensive export including: + *

    + *
  • User account data
  • + *
  • Audit history
  • + *
  • Consent records
  • + *
  • Token metadata
  • + *
  • Data from registered GdprDataContributors
  • + *
+ * + * @param userDetails the authenticated user + * @param request the HTTP request + * @return the complete data export as JSON + */ + @GetMapping("/export") + public ResponseEntity exportUserData(@AuthenticationPrincipal DSUserDetails userDetails, + HttpServletRequest request) { + if (!gdprConfig.isEnabled()) { + return buildNotFoundResponse(); + } + + User user = validateAndGetUser(userDetails); + if (user == null) { + return buildErrorResponse("User not authenticated", 1, HttpStatus.UNAUTHORIZED); + } + + try { + GdprExportDTO export = gdprExportService.exportUserData(user); + logAuditEvent("GdprExport", "Success", "User data exported", user, request); + + return ResponseEntity.ok(JSONResponse.builder() + .success(true) + .code(0) + .message("Data export completed successfully") + .data(export) + .build()); + } catch (Exception e) { + log.error("GdprAPI.exportUserData: Failed to export data for user {}: {}", + user.getEmail(), e.getMessage(), e); + logAuditEvent("GdprExport", "Failure", e.getMessage(), user, request); + return buildErrorResponse("Failed to export data", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Requests deletion of the authenticated user's account. + * + *

If configured, exports user data before deletion and includes + * it in the response. After deletion, the user is logged out. + * + * @param userDetails the authenticated user + * @param request the HTTP request + * @return deletion result, optionally including exported data + */ + @PostMapping("/delete") + public ResponseEntity deleteAccount(@AuthenticationPrincipal DSUserDetails userDetails, + HttpServletRequest request) { + if (!gdprConfig.isEnabled()) { + return buildNotFoundResponse(); + } + + User user = validateAndGetUser(userDetails); + if (user == null) { + return buildErrorResponse("User not authenticated", 1, HttpStatus.UNAUTHORIZED); + } + + try { + GdprDeletionService.DeletionResult result = gdprDeletionService.deleteUser(user); + + if (result.isSuccess()) { + logAuditEvent("GdprDelete", "Success", "User account deleted", null, request); + logoutUser(request); + + JSONResponse.JSONResponseBuilder responseBuilder = JSONResponse.builder() + .success(true) + .code(0) + .message(result.getMessage()); + + if (result.getExportedData() != null) { + responseBuilder.data(result.getExportedData()); + } + + return ResponseEntity.ok(responseBuilder.build()); + } else { + log.error("GdprAPI.deleteAccount: Deletion failed for user {}: {}", + user.getEmail(), result.getMessage()); + return buildErrorResponse(result.getMessage(), 2, HttpStatus.INTERNAL_SERVER_ERROR); + } + } catch (Exception e) { + log.error("GdprAPI.deleteAccount: Failed to delete account for user {}: {}", + user.getEmail(), e.getMessage(), e); + logAuditEvent("GdprDelete", "Failure", e.getMessage(), user, request); + return buildErrorResponse("Failed to delete account", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Records a consent grant or withdrawal. + * + * @param userDetails the authenticated user + * @param consentRequest the consent request details + * @param request the HTTP request + * @return the recorded consent details + */ + @PostMapping("/consent") + public ResponseEntity recordConsent(@AuthenticationPrincipal DSUserDetails userDetails, + @Valid @RequestBody ConsentRequestDto consentRequest, + HttpServletRequest request) { + if (!gdprConfig.isEnabled() || !gdprConfig.isConsentTracking()) { + return buildNotFoundResponse(); + } + + User user = validateAndGetUser(userDetails); + if (user == null) { + return buildErrorResponse("User not authenticated", 1, HttpStatus.UNAUTHORIZED); + } + + // Validate custom type if needed + if (consentRequest.getConsentType() == ConsentType.CUSTOM && + (consentRequest.getCustomType() == null || consentRequest.getCustomType().isBlank())) { + return buildErrorResponse("Custom type is required when consent type is CUSTOM", + 2, HttpStatus.BAD_REQUEST); + } + + try { + String method = consentRequest.getMethod() != null ? consentRequest.getMethod() : "api"; + ConsentRecord record; + + if (Boolean.TRUE.equals(consentRequest.getGrant())) { + record = consentAuditService.recordConsentGranted( + user, + consentRequest.getConsentType(), + consentRequest.getCustomType(), + consentRequest.getPolicyVersion(), + method, + request + ); + } else { + record = consentAuditService.recordConsentWithdrawn( + user, + consentRequest.getConsentType(), + consentRequest.getCustomType(), + method, + request + ); + } + + String action = Boolean.TRUE.equals(consentRequest.getGrant()) ? "granted" : "withdrawn"; + return ResponseEntity.ok(JSONResponse.builder() + .success(true) + .code(0) + .message("Consent " + action + " successfully") + .data(record) + .build()); + } catch (Exception e) { + log.error("GdprAPI.recordConsent: Failed to record consent for user {}: {}", + user.getEmail(), e.getMessage(), e); + return buildErrorResponse("Failed to record consent", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Gets the current consent status for the authenticated user. + * + * @param userDetails the authenticated user + * @param request the HTTP request + * @return map of consent types to their current status + */ + @GetMapping("/consent") + public ResponseEntity getConsentStatus(@AuthenticationPrincipal DSUserDetails userDetails, + HttpServletRequest request) { + if (!gdprConfig.isEnabled() || !gdprConfig.isConsentTracking()) { + return buildNotFoundResponse(); + } + + User user = validateAndGetUser(userDetails); + if (user == null) { + return buildErrorResponse("User not authenticated", 1, HttpStatus.UNAUTHORIZED); + } + + try { + Map status = consentAuditService.getConsentStatus(user); + + return ResponseEntity.ok(JSONResponse.builder() + .success(true) + .code(0) + .message("Consent status retrieved successfully") + .data(status) + .build()); + } catch (Exception e) { + log.error("GdprAPI.getConsentStatus: Failed to get consent status for user {}: {}", + user.getEmail(), e.getMessage(), e); + return buildErrorResponse("Failed to get consent status", 5, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Validates the authenticated user and returns the User entity. + */ + private User validateAndGetUser(DSUserDetails userDetails) { + if (userDetails == null || userDetails.getUser() == null) { + return null; + } + // Re-fetch from database to ensure attached entity + return userService.findUserByEmail(userDetails.getUser().getEmail()); + } + + /** + * Logs out the current user. + */ + private void logoutUser(HttpServletRequest request) { + try { + SecurityContextHolder.clearContext(); + request.logout(); + } catch (ServletException e) { + log.warn("GdprAPI.logoutUser: Logout failed", e); + } + } + + /** + * Logs an audit event. + */ + private void logAuditEvent(String action, String status, String message, User user, HttpServletRequest request) { + AuditEvent event = AuditEvent.builder() + .source(this) + .user(user) + .sessionId(request.getSession().getId()) + .ipAddress(UserUtils.getClientIP(request)) + .userAgent(request.getHeader("User-Agent")) + .action(action) + .actionStatus(status) + .message(message) + .build(); + eventPublisher.publishEvent(event); + } + + /** + * Builds a 404 Not Found response (for when GDPR is disabled). + */ + private ResponseEntity buildNotFoundResponse() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(JSONResponse.builder() + .success(false) + .code(404) + .message("GDPR features are not enabled") + .build()); + } + + /** + * Builds an error response. + */ + private ResponseEntity buildErrorResponse(String message, int code, HttpStatus status) { + return ResponseEntity.status(status) + .body(JSONResponse.builder() + .success(false) + .code(code) + .message(message) + .build()); + } + +} From 6acd707fe67e42c92e2e8b7e356ba908e270ed07 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 1 Feb 2026 18:19:36 -0700 Subject: [PATCH 05/16] chore: add GDPR configuration and UserDeletedEvent publication Update existing files to support GDPR functionality: Configuration (dsspringuserconfig.properties): - user.gdpr.enabled: Master toggle for GDPR features - user.gdpr.exportBeforeDeletion: Auto-export before hard delete - user.gdpr.consentTracking: Enable consent audit events UserService: - Publish UserDeletedEvent after successful user deletion - Captures userId and email before deletion for the event --- .../spring/user/service/UserService.java | 9 +++++++++ src/main/resources/config/dsspringuserconfig.properties | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 280b71f..e8e0a7d 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -23,6 +23,7 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.event.UserDeletedEvent; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.PasswordHistoryEntry; @@ -344,6 +345,10 @@ public void deleteOrDisableUser(final User user) { log.debug("UserService.deleteOrDisableUser: called with user: {}", user); if (actuallyDeleteAccount) { log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is true, deleting user: {}", user); + // Capture user details before deletion for the post-delete event + Long userId = user.getId(); + String userEmail = user.getEmail(); + // Publish the UserPreDeleteEvent before deleting the user // This allows any listeners to perform actions before the user is deleted log.debug("Publishing UserPreDeleteEvent"); @@ -361,6 +366,10 @@ public void deleteOrDisableUser(final User user) { } // Delete the user userRepository.delete(user); + + // Publish UserDeletedEvent after successful deletion + log.debug("Publishing UserDeletedEvent"); + eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail)); } else { log.debug("UserService.deleteOrDisableUser: actuallyDeleteAccount is false, disabling user: {}", user); user.setEnabled(false); diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index 7d5fb34..b329624 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -152,3 +152,11 @@ user.roles.roles-and-privileges.ROLE_USER=LOGIN_PRIVILEGE,UPDATE_OWN_USER_PRIVIL # Role hierarchy configuration. Higher level roles inherit all roles from lower level roles. user.roles.role-hierarchy[0]=ROLE_ADMIN > ROLE_MANAGER user.roles.role-hierarchy[1]=ROLE_MANAGER > ROLE_USER + +# GDPR Configuration +# Master toggle for GDPR features (export, deletion, consent tracking) +user.gdpr.enabled=true +# If true, user data is automatically exported before hard deletion +user.gdpr.exportBeforeDeletion=true +# If true, consent changes are tracked via the audit system +user.gdpr.consentTracking=true From 30df4d308f5df8fe34779c839408d6e32858557a Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Feb 2026 06:10:17 -0700 Subject: [PATCH 06/16] fix(security): replace manual JSON parsing with Jackson serialization Address critical code review finding: fragile string parsing had injection risk. Now using Jackson ObjectMapper for safe JSON handling. - Add ConsentExtraData DTO for consent extra data serialization - Replace buildExtraData() with Jackson writeValueAsString() - Replace extractConsentType()/extractValue() with Jackson readValue() - Remove vulnerable escapeJson() helper method - Optimize consent status/export to process events in single pass --- .../spring/user/gdpr/ConsentAuditService.java | 124 ++++++++++-------- .../spring/user/gdpr/ConsentExtraData.java | 38 ++++++ .../spring/user/gdpr/GdprExportService.java | 110 +++++++--------- 3 files changed, 152 insertions(+), 120 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentExtraData.java diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java index 11d650d..26eb216 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -15,6 +16,8 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; /** * Service for tracking user consent via the audit system. @@ -53,6 +56,9 @@ public class ConsentAuditService { private final ApplicationEventPublisher eventPublisher; private final AuditLogQueryService auditLogQueryService; + /** ObjectMapper for JSON serialization/deserialization of consent extra data. */ + private final ObjectMapper objectMapper = new ObjectMapper(); + /** * Records that a user has granted consent. * @@ -129,7 +135,7 @@ public ConsentRecord recordConsentGranted(User user, ConsentType consentType, St ConsentChangedEvent.ChangeType.GRANTED)); log.info("ConsentAuditService.recordConsentGranted: Recorded consent grant for user {} - type {}", - user.getEmail(), record.getEffectiveTypeName()); + user.getId(), record.getEffectiveTypeName()); return record; } @@ -207,7 +213,7 @@ public ConsentRecord recordConsentWithdrawn(User user, ConsentType consentType, ConsentChangedEvent.ChangeType.WITHDRAWN)); log.info("ConsentAuditService.recordConsentWithdrawn: Recorded consent withdrawal for user {} - type {}", - user.getEmail(), record.getEffectiveTypeName()); + user.getId(), record.getEffectiveTypeName()); return record; } @@ -217,6 +223,7 @@ public ConsentRecord recordConsentWithdrawn(User user, ConsentType consentType, * *

This method queries the audit log to determine which consents * are currently active (granted but not withdrawn) for the user. + * Events are fetched once and processed in a single pass for efficiency. * * @param user the user to check * @return map of consent type names to their current status @@ -229,34 +236,36 @@ public Map getConsentStatus(User user) { Map statusMap = new LinkedHashMap<>(); try { - // Get all consent events + // Fetch all consent events in a single combined list List grantedEvents = auditLogQueryService.findByUserAndAction(user, ACTION_CONSENT_GRANTED); List withdrawnEvents = auditLogQueryService.findByUserAndAction(user, ACTION_CONSENT_WITHDRAWN); - // Process grants (most recent first) - for (AuditEventDTO event : grantedEvents) { + // Combine and sort by timestamp (most recent first) + List allEvents = new ArrayList<>(grantedEvents.size() + withdrawnEvents.size()); + allEvents.addAll(grantedEvents); + allEvents.addAll(withdrawnEvents); + allEvents.sort(Comparator.comparing(AuditEventDTO::getTimestamp, + Comparator.nullsLast(Comparator.reverseOrder()))); + + // Process in one pass - most recent event per type determines status + for (AuditEventDTO event : allEvents) { String typeName = extractConsentType(event.getExtraData()); - if (typeName != null && !statusMap.containsKey(typeName)) { - statusMap.put(typeName, new ConsentStatus(typeName, true, event.getTimestamp(), null)); + if (typeName == null || statusMap.containsKey(typeName)) { + continue; // Skip if no type or already processed (newer event wins) } - } - // Process withdrawals - for (AuditEventDTO event : withdrawnEvents) { - String typeName = extractConsentType(event.getExtraData()); - if (typeName != null) { - ConsentStatus existing = statusMap.get(typeName); - if (existing != null && (existing.getWithdrawnAt() == null || - event.getTimestamp().isAfter(existing.getGrantedAt()))) { - statusMap.put(typeName, new ConsentStatus(typeName, false, - existing.getGrantedAt(), event.getTimestamp())); - } + boolean isGrant = ACTION_CONSENT_GRANTED.equals(event.getAction()); + if (isGrant) { + statusMap.put(typeName, new ConsentStatus(typeName, true, event.getTimestamp(), null)); + } else { + // Withdrawal without a prior grant - still record it + statusMap.put(typeName, new ConsentStatus(typeName, false, null, event.getTimestamp())); } } } catch (Exception e) { log.warn("ConsentAuditService.getConsentStatus: Failed to get consent status for user {}: {}", - user.getEmail(), e.getMessage()); + user.getId(), e.getMessage()); } return statusMap; @@ -288,68 +297,67 @@ public List getConsentRecords(User user) { } } catch (Exception e) { log.warn("ConsentAuditService.getConsentRecords: Failed to get consent records for user {}: {}", - user.getEmail(), e.getMessage()); + user.getId(), e.getMessage()); } return records; } /** - * Builds the extraData JSON string for an audit event. - * Uses simple string building to avoid Jackson dependency at compile time. + * Builds the extraData JSON string for an audit event using Jackson serialization. + * + * @param record the consent record to serialize + * @return JSON string representation of consent extra data */ private String buildExtraData(ConsentRecord record) { - StringBuilder sb = new StringBuilder("{"); - sb.append("\"consentType\":\"").append(escapeJson(record.getEffectiveTypeName())).append("\""); - if (record.getPolicyVersion() != null) { - sb.append(",\"policyVersion\":\"").append(escapeJson(record.getPolicyVersion())).append("\""); - } - if (record.getMethod() != null) { - sb.append(",\"method\":\"").append(escapeJson(record.getMethod())).append("\""); + try { + ConsentExtraData extraData = ConsentExtraData.builder() + .consentType(record.getEffectiveTypeName()) + .policyVersion(record.getPolicyVersion()) + .method(record.getMethod()) + .build(); + return objectMapper.writeValueAsString(extraData); + } catch (JacksonException e) { + log.warn("ConsentAuditService.buildExtraData: Failed to serialize consent extra data", e); + return "{}"; } - sb.append("}"); - return sb.toString(); } /** - * Escapes special characters in a string for JSON. + * Extracts the consent type from audit event extra data using Jackson deserialization. + * + * @param extraData the JSON string to parse + * @return the consent type name, or null if parsing fails */ - private String escapeJson(String value) { - if (value == null) { - return ""; + private String extractConsentType(String extraData) { + if (extraData == null || extraData.isEmpty()) { + return null; + } + try { + ConsentExtraData data = objectMapper.readValue(extraData, ConsentExtraData.class); + return data.getConsentType(); + } catch (JacksonException e) { + log.debug("ConsentAuditService.extractConsentType: Failed to parse extra data: {}", extraData); + return null; } - return value.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); } /** - * Extracts the consent type from audit event extra data. - * Uses simple string parsing to avoid Jackson dependency at compile time. + * Parses consent extra data from JSON string. + * + * @param extraData the JSON string to parse + * @return the parsed ConsentExtraData, or null if parsing fails */ - private String extractConsentType(String extraData) { + private ConsentExtraData parseExtraData(String extraData) { if (extraData == null || extraData.isEmpty()) { return null; } - // Parse "consentType":"value" from JSON - String key = "\"consentType\":\""; - int start = extraData.indexOf(key); - if (start == -1) { - // Try without quotes around key - key = "consentType\":\""; - start = extraData.indexOf(key); - } - if (start == -1) { + try { + return objectMapper.readValue(extraData, ConsentExtraData.class); + } catch (JacksonException e) { + log.debug("ConsentAuditService.parseExtraData: Failed to parse extra data: {}", extraData); return null; } - start += key.length(); - int end = extraData.indexOf("\"", start); - if (end > start) { - return extraData.substring(start, end); - } - return null; } /** diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentExtraData.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentExtraData.java new file mode 100644 index 0000000..0e92e5d --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentExtraData.java @@ -0,0 +1,38 @@ +package com.digitalsanctuary.spring.user.gdpr; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object for consent extra data stored in audit events. + * + *

This class is used for Jackson serialization/deserialization of the + * extraData field in consent-related audit events. + * + * @see ConsentAuditService + * @see GdprExportService + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConsentExtraData { + + /** + * The consent type name (e.g., "MARKETING", "ANALYTICS", or custom type). + */ + private String consentType; + + /** + * Optional version of the policy document (e.g., "privacy-policy-v1.2"). + */ + private String policyVersion; + + /** + * The method by which consent was given/withdrawn (e.g., "web_form", "api"). + */ + private String method; + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java index d5fa4af..7aaed7e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java @@ -2,6 +2,7 @@ import java.time.Instant; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -20,6 +21,8 @@ import com.digitalsanctuary.spring.user.persistence.repository.VerificationTokenRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; /** * Service for exporting user data in GDPR-compliant format. @@ -55,6 +58,9 @@ public class GdprExportService { private final List dataContributors; private final ApplicationEventPublisher eventPublisher; + /** ObjectMapper for JSON deserialization of consent extra data. */ + private final ObjectMapper objectMapper = new ObjectMapper(); + /** * Exports all GDPR-relevant data for a user. * @@ -67,7 +73,7 @@ public GdprExportDTO exportUserData(User user) { throw new IllegalArgumentException("User cannot be null"); } - log.info("GdprExportService.exportUserData: Starting export for user {}", user.getEmail()); + log.info("GdprExportService.exportUserData: Starting export for user {}", user.getId()); GdprExportDTO export = GdprExportDTO.builder() .metadata(buildMetadata()) @@ -81,7 +87,7 @@ public GdprExportDTO exportUserData(User user) { // Publish event after successful export eventPublisher.publishEvent(new UserDataExportedEvent(this, user, export)); - log.info("GdprExportService.exportUserData: Completed export for user {}", user.getEmail()); + log.info("GdprExportService.exportUserData: Completed export for user {}", user.getId()); return export; } @@ -128,13 +134,14 @@ private List exportAuditHistory(User user) { return auditLogQueryService.findByUser(user); } catch (Exception e) { log.warn("GdprExportService.exportAuditHistory: Failed to export audit history for user {}: {}", - user.getEmail(), e.getMessage()); + user.getId(), e.getMessage()); return new ArrayList<>(); } } /** * Exports the user's consent records from audit logs. + * Fetches all consent events once and processes together for efficiency. */ private List exportConsents(User user) { if (!gdprConfig.isConsentTracking()) { @@ -142,27 +149,41 @@ private List exportConsents(User user) { } try { - // Get all consent-related audit events + // Fetch all consent-related audit events List grantedEvents = auditLogQueryService.findByUserAndAction(user, "CONSENT_GRANTED"); List withdrawnEvents = auditLogQueryService.findByUserAndAction(user, "CONSENT_WITHDRAWN"); - // Build consent records from audit events + // Combine and sort by timestamp (oldest first for chronological processing) + List allEvents = new ArrayList<>(grantedEvents.size() + withdrawnEvents.size()); + allEvents.addAll(grantedEvents); + allEvents.addAll(withdrawnEvents); + allEvents.sort(Comparator.comparing(AuditEventDTO::getTimestamp, + Comparator.nullsFirst(Comparator.naturalOrder()))); + + // Build consent records from audit events (process in chronological order) Map consentMap = new LinkedHashMap<>(); - // Process granted consents - for (AuditEventDTO event : grantedEvents) { - ConsentRecord record = parseConsentFromAuditEvent(event, true); - if (record != null) { - consentMap.put(record.getEffectiveTypeName(), record); + for (AuditEventDTO event : allEvents) { + boolean isGrant = "CONSENT_GRANTED".equals(event.getAction()); + ConsentRecord record = parseConsentFromAuditEvent(event, isGrant); + if (record == null) { + continue; } - } - // Process withdrawals (update existing records) - for (AuditEventDTO event : withdrawnEvents) { - ConsentRecord record = parseConsentFromAuditEvent(event, false); - if (record != null) { - String key = record.getEffectiveTypeName(); - ConsentRecord existing = consentMap.get(key); + String key = record.getEffectiveTypeName(); + ConsentRecord existing = consentMap.get(key); + + if (isGrant) { + // Grant: create new or update existing + if (existing != null) { + existing.setGrantedAt(record.getGrantedAt()); + existing.setPolicyVersion(record.getPolicyVersion()); + existing.setMethod(record.getMethod()); + } else { + consentMap.put(key, record); + } + } else { + // Withdrawal: update existing or create new if (existing != null) { existing.setWithdrawnAt(record.getWithdrawnAt()); } else { @@ -174,13 +195,13 @@ private List exportConsents(User user) { return new ArrayList<>(consentMap.values()); } catch (Exception e) { log.warn("GdprExportService.exportConsents: Failed to export consents for user {}: {}", - user.getEmail(), e.getMessage()); + user.getId(), e.getMessage()); return new ArrayList<>(); } } /** - * Parses a consent record from an audit event. + * Parses a consent record from an audit event using Jackson deserialization. */ private ConsentRecord parseConsentFromAuditEvent(AuditEventDTO event, boolean isGrant) { if (event == null || event.getExtraData() == null) { @@ -188,33 +209,17 @@ private ConsentRecord parseConsentFromAuditEvent(AuditEventDTO event, boolean is } try { - // Parse extraData JSON (simplified - assumes key=value format or JSON) - String extraData = event.getExtraData(); - ConsentType type = ConsentType.CUSTOM; - String customType = null; - String policyVersion = null; - String method = null; - - // Basic parsing of extraData - if (extraData.contains("consentType=")) { - String typeValue = extractValue(extraData, "consentType"); - type = ConsentType.fromValue(typeValue); - if (type == ConsentType.CUSTOM) { - customType = typeValue; - } - } - if (extraData.contains("policyVersion=")) { - policyVersion = extractValue(extraData, "policyVersion"); - } - if (extraData.contains("method=")) { - method = extractValue(extraData, "method"); - } + ConsentExtraData extraData = objectMapper.readValue(event.getExtraData(), ConsentExtraData.class); + + String typeValue = extraData.getConsentType(); + ConsentType type = ConsentType.fromValue(typeValue); + String customType = (type == ConsentType.CUSTOM) ? typeValue : null; ConsentRecord.ConsentRecordBuilder builder = ConsentRecord.builder() .type(type) .customType(customType) - .policyVersion(policyVersion) - .method(method) + .policyVersion(extraData.getPolicyVersion()) + .method(extraData.getMethod()) .ipAddress(event.getIpAddress()); if (isGrant) { @@ -224,31 +229,12 @@ private ConsentRecord parseConsentFromAuditEvent(AuditEventDTO event, boolean is } return builder.build(); - } catch (Exception e) { + } catch (JacksonException e) { log.debug("GdprExportService.parseConsentFromAuditEvent: Failed to parse consent from event", e); return null; } } - /** - * Extracts a value from a simple key=value string. - */ - private String extractValue(String data, String key) { - int start = data.indexOf(key + "="); - if (start == -1) { - return null; - } - start += key.length() + 1; - int end = data.indexOf(",", start); - if (end == -1) { - end = data.indexOf("}", start); - } - if (end == -1) { - end = data.length(); - } - return data.substring(start, end).trim().replace("\"", ""); - } - /** * Exports token metadata (existence and expiry, not actual tokens). */ From de907fa640756c36d1f54349c4a13fa29e6c5dd9 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Feb 2026 06:10:23 -0700 Subject: [PATCH 07/16] fix(security): remove PII from log statements Address critical code review finding: user emails were being logged, exposing PII. Now using user IDs instead. - Replace user.getEmail() with user.getId() in all GDPR log statements - Affected files: GdprAPI, GdprDeletionService - ConsentAuditService/GdprExportService already updated in prior commit --- .../spring/user/api/GdprAPI.java | 10 +-- .../spring/user/gdpr/GdprDeletionService.java | 77 ++++++++++++------- 2 files changed, 53 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java index 90752a7..7a8791f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java @@ -103,7 +103,7 @@ public ResponseEntity exportUserData(@AuthenticationPrincipal DSUs .build()); } catch (Exception e) { log.error("GdprAPI.exportUserData: Failed to export data for user {}: {}", - user.getEmail(), e.getMessage(), e); + user.getId(), e.getMessage(), e); logAuditEvent("GdprExport", "Failure", e.getMessage(), user, request); return buildErrorResponse("Failed to export data", 5, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -150,12 +150,12 @@ public ResponseEntity deleteAccount(@AuthenticationPrincipal DSUse return ResponseEntity.ok(responseBuilder.build()); } else { log.error("GdprAPI.deleteAccount: Deletion failed for user {}: {}", - user.getEmail(), result.getMessage()); + user.getId(), result.getMessage()); return buildErrorResponse(result.getMessage(), 2, HttpStatus.INTERNAL_SERVER_ERROR); } } catch (Exception e) { log.error("GdprAPI.deleteAccount: Failed to delete account for user {}: {}", - user.getEmail(), e.getMessage(), e); + user.getId(), e.getMessage(), e); logAuditEvent("GdprDelete", "Failure", e.getMessage(), user, request); return buildErrorResponse("Failed to delete account", 5, HttpStatus.INTERNAL_SERVER_ERROR); } @@ -221,7 +221,7 @@ record = consentAuditService.recordConsentWithdrawn( .build()); } catch (Exception e) { log.error("GdprAPI.recordConsent: Failed to record consent for user {}: {}", - user.getEmail(), e.getMessage(), e); + user.getId(), e.getMessage(), e); return buildErrorResponse("Failed to record consent", 5, HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -256,7 +256,7 @@ public ResponseEntity getConsentStatus(@AuthenticationPrincipal DS .build()); } catch (Exception e) { log.error("GdprAPI.getConsentStatus: Failed to get consent status for user {}: {}", - user.getEmail(), e.getMessage(), e); + user.getId(), e.getMessage(), e); return buildErrorResponse("Failed to get consent status", 5, HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java index 344cbcc..21a7af3 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java @@ -89,11 +89,13 @@ public String getMessage() { *

If {@code exportBeforeDeletion} is enabled in configuration, the user's * data is exported and included in the result before deletion. * + *

Note: Export is performed OUTSIDE the transaction to avoid holding + * the transaction open during potentially slow export operations. + * * @param user the user to delete * @return the result of the deletion operation * @throws IllegalArgumentException if user is null */ - @Transactional public DeletionResult deleteUser(User user) { return deleteUser(user, gdprConfig.isExportBeforeDeletion()); } @@ -101,59 +103,76 @@ public DeletionResult deleteUser(User user) { /** * Deletes a user and all associated data, with explicit export control. * + *

Note: Export is performed OUTSIDE the transaction to avoid holding + * the transaction open during potentially slow export operations. + * * @param user the user to delete * @param exportBeforeDeletion whether to export data before deletion * @return the result of the deletion operation * @throws IllegalArgumentException if user is null */ - @Transactional public DeletionResult deleteUser(User user, boolean exportBeforeDeletion) { if (user == null) { throw new IllegalArgumentException("User cannot be null"); } Long userId = user.getId(); - String userEmail = user.getEmail(); - log.info("GdprDeletionService.deleteUser: Starting GDPR deletion for user {}", userEmail); + log.info("GdprDeletionService.deleteUser: Starting GDPR deletion for user {}", userId); GdprExportDTO exportedData = null; try { - // Step 1: Export data if configured + // Step 1: Export data OUTSIDE transaction (avoids holding transaction during slow I/O) if (exportBeforeDeletion) { - log.debug("GdprDeletionService.deleteUser: Exporting data before deletion for user {}", userEmail); + log.debug("GdprDeletionService.deleteUser: Exporting data before deletion for user {}", userId); exportedData = gdprExportService.exportUserData(user); } - // Step 2: Notify all GdprDataContributors to prepare for deletion - prepareContributorsForDeletion(user); + // Step 2: Perform deletion in transaction + return executeUserDeletion(user, exportedData, exportBeforeDeletion); + + } catch (Exception e) { + log.error("GdprDeletionService.deleteUser: Failed to delete user {}: {}", + userId, e.getMessage(), e); + return DeletionResult.failure("Failed to delete user: " + e.getMessage()); + } + } + + /** + * Internal transactional method that performs the actual user deletion. + * + * @param user the user to delete + * @param exportedData the pre-exported data (may be null) + * @param wasExported whether export was performed + * @return the result of the deletion operation + */ + @Transactional + protected DeletionResult executeUserDeletion(User user, GdprExportDTO exportedData, boolean wasExported) { + Long userId = user.getId(); - // Step 3: Publish UserPreDeleteEvent for additional cleanup - log.debug("GdprDeletionService.deleteUser: Publishing UserPreDeleteEvent for user {}", userEmail); - eventPublisher.publishEvent(new UserPreDeleteEvent(this, user)); + // Step 2: Notify all GdprDataContributors to prepare for deletion + prepareContributorsForDeletion(user); - // Step 4: Delete framework-managed data - deleteFrameworkData(user); + // Step 3: Publish UserPreDeleteEvent for additional cleanup + log.debug("GdprDeletionService.deleteUser: Publishing UserPreDeleteEvent for user {}", userId); + eventPublisher.publishEvent(new UserPreDeleteEvent(this, user)); - // Step 5: Delete the user entity - log.debug("GdprDeletionService.deleteUser: Deleting user entity for {}", userEmail); - userRepository.delete(user); + // Step 4: Delete framework-managed data + deleteFrameworkData(user); - log.info("GdprDeletionService.deleteUser: Successfully deleted user {}", userEmail); + // Step 5: Delete the user entity + log.debug("GdprDeletionService.deleteUser: Deleting user entity for {}", userId); + userRepository.delete(user); - // Step 6: Publish UserDeletedEvent after successful deletion - eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail, exportBeforeDeletion)); + log.info("GdprDeletionService.deleteUser: Successfully deleted user {}", userId); - return exportBeforeDeletion - ? DeletionResult.successWithExport(exportedData) - : DeletionResult.success(null); + // Step 6: Publish UserDeletedEvent after successful deletion + eventPublisher.publishEvent(new UserDeletedEvent(this, userId, user.getEmail(), wasExported)); - } catch (Exception e) { - log.error("GdprDeletionService.deleteUser: Failed to delete user {}: {}", - userEmail, e.getMessage(), e); - return DeletionResult.failure("Failed to delete user: " + e.getMessage()); - } + return wasExported + ? DeletionResult.successWithExport(exportedData) + : DeletionResult.success(null); } /** @@ -187,7 +206,7 @@ private void deleteFrameworkData(User user) { VerificationToken verificationToken = verificationTokenRepository.findByUser(user); if (verificationToken != null) { log.debug("GdprDeletionService.deleteFrameworkData: Deleting verification token for user {}", - user.getEmail()); + user.getId()); verificationTokenRepository.delete(verificationToken); } @@ -195,7 +214,7 @@ private void deleteFrameworkData(User user) { PasswordResetToken passwordResetToken = passwordResetTokenRepository.findByUser(user); if (passwordResetToken != null) { log.debug("GdprDeletionService.deleteFrameworkData: Deleting password reset token for user {}", - user.getEmail()); + user.getId()); passwordResetTokenRepository.delete(passwordResetToken); } From e45baedd57ae59a3aa297ec5b46a6ba8fe615101 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Feb 2026 06:10:27 -0700 Subject: [PATCH 08/16] fix(security): add input validation to consent customType field Address critical code review finding: missing validation on customType could allow injection attacks. - Add @Size(max = 100) to limit custom type length - Add @Pattern for alphanumeric, underscore, hyphen only - Prevents injection of special characters in consent type names --- .../digitalsanctuary/spring/user/dto/ConsentRequestDto.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java index 3281503..df4b149 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java @@ -2,6 +2,8 @@ import com.digitalsanctuary.spring.user.gdpr.ConsentType; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,7 +30,10 @@ public class ConsentRequestDto { /** * For CUSTOM consent type, specifies the custom consent type name. * Required when consentType is CUSTOM. + * Must contain only alphanumeric characters, underscores, and hyphens. */ + @Size(max = 100, message = "Custom type must not exceed 100 characters") + @Pattern(regexp = "^[a-zA-Z0-9_-]*$", message = "Custom type can only contain letters, numbers, underscores, and hyphens") private String customType; /** From fc9bf230d9065114e5cce9c9a749b74c2141d635 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Feb 2026 06:10:32 -0700 Subject: [PATCH 09/16] perf(audit): use streaming for audit log file queries Address high-severity code review finding: unbounded memory usage when reading large audit log files. - Replace BufferedReader iteration with Files.lines() stream - Process lines lazily to reduce memory footprint - Maintain same filtering and sorting behavior --- .../user/audit/FileAuditLogQueryService.java | 60 +++++-------------- 1 file changed, 16 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java index 3483f00..03ab51e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java @@ -1,6 +1,5 @@ package com.digitalsanctuary.spring.user.audit; -import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -9,11 +8,13 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; -import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.stereotype.Service; import com.digitalsanctuary.spring.user.persistence.model.User; import lombok.RequiredArgsConstructor; @@ -71,6 +72,7 @@ public List findByUserAndAction(User user, String action) { /** * Internal method to find audit events with optional filtering. + * Uses Java Streams for efficient memory handling with large log files. * * @param user the user to filter by * @param since optional timestamp filter @@ -88,55 +90,25 @@ private List findByUser(User user, Instant since, String action) return Collections.emptyList(); } - List results = new ArrayList<>(); String userEmail = user.getEmail(); String userId = user.getId() != null ? user.getId().toString() : null; - try (BufferedReader reader = Files.newBufferedReader(logPath)) { - String line; - boolean isFirstLine = true; - - while ((line = reader.readLine()) != null) { - // Skip header line - if (isFirstLine) { - isFirstLine = false; - if (line.startsWith("Date|Action")) { - continue; - } - } - - AuditEventDTO event = parseLine(line); - if (event == null) { - continue; - } - - // Filter by user - if (!matchesUser(event, userEmail, userId)) { - continue; - } - - // Filter by timestamp if specified - if (since != null && event.getTimestamp() != null && event.getTimestamp().isBefore(since)) { - continue; - } - - // Filter by action if specified - if (action != null && !action.equals(event.getAction())) { - continue; - } - - results.add(event); - } + try (Stream lines = Files.lines(logPath)) { + return lines + .skip(1) // Skip header line + .map(this::parseLine) + .filter(Objects::nonNull) + .filter(event -> matchesUser(event, userEmail, userId)) + .filter(event -> since == null || event.getTimestamp() == null || + !event.getTimestamp().isBefore(since)) + .filter(event -> action == null || action.equals(event.getAction())) + .sorted(Comparator.comparing(AuditEventDTO::getTimestamp, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collect(Collectors.toList()); } catch (IOException e) { log.error("FileAuditLogQueryService.findByUser: Error reading audit log file", e); return Collections.emptyList(); } - - // Sort by timestamp descending (most recent first) - results.sort(Comparator.comparing(AuditEventDTO::getTimestamp, - Comparator.nullsLast(Comparator.reverseOrder()))); - - return results; } /** From 1892f2b7561b53d0823cd165a8c714367dd3bc78 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Feb 2026 06:10:37 -0700 Subject: [PATCH 10/16] test(api): add GdprAPI unit tests Add comprehensive unit tests for GDPR REST API endpoints using standalone MockMvc with mocked services. Test cases cover: - Data export: authenticated, unauthenticated, GDPR disabled - Account deletion: authenticated, unauthenticated - Consent recording: valid, custom type, validation errors - Consent status: authenticated, unauthenticated, tracking disabled --- .../spring/user/api/GdprAPITest.java | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/api/GdprAPITest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/GdprAPITest.java b/src/test/java/com/digitalsanctuary/spring/user/api/GdprAPITest.java new file mode 100644 index 0000000..514bdad --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/api/GdprAPITest.java @@ -0,0 +1,507 @@ +package com.digitalsanctuary.spring.user.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.MessageSource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import com.digitalsanctuary.spring.user.dto.ConsentRequestDto; +import com.digitalsanctuary.spring.user.dto.GdprExportDTO; +import com.digitalsanctuary.spring.user.gdpr.ConsentAuditService; +import com.digitalsanctuary.spring.user.gdpr.ConsentRecord; +import com.digitalsanctuary.spring.user.gdpr.ConsentType; +import com.digitalsanctuary.spring.user.gdpr.GdprConfig; +import com.digitalsanctuary.spring.user.gdpr.GdprDeletionService; +import com.digitalsanctuary.spring.user.gdpr.GdprExportService; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.service.DSUserDetails; +import com.digitalsanctuary.spring.user.service.UserService; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Unit tests for GdprAPI REST controller. + * + *

These tests use standalone MockMvc setup with mocked services to test + * the GDPR API endpoints in isolation. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("GdprAPI Unit Tests") +class GdprAPITest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + private GdprConfig gdprConfig; + + @Mock + private GdprExportService gdprExportService; + + @Mock + private GdprDeletionService gdprDeletionService; + + @Mock + private ConsentAuditService consentAuditService; + + @Mock + private UserService userService; + + @Mock + private MessageSource messages; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private GdprAPI gdprAPI; + + private User testUser; + private DSUserDetails testUserDetails; + + /** + * Custom argument resolver that returns a specific DSUserDetails or null. + */ + private static class DSUserDetailsArgumentResolver implements HandlerMethodArgumentResolver { + private final DSUserDetails userDetails; + + DSUserDetailsArgumentResolver(DSUserDetails userDetails) { + this.userDetails = userDetails; + } + + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(DSUserDetails.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return userDetails; + } + } + + @BeforeEach + void setUp() { + testUser = UserTestDataBuilder.aUser() + .withId(1L) + .withEmail("test@example.com") + .withFirstName("Test") + .withLastName("User") + .enabled() + .build(); + + testUserDetails = new DSUserDetails(testUser); + + // Default MockMvc with null user (unauthenticated) + mockMvc = MockMvcBuilders.standaloneSetup(gdprAPI) + .setCustomArgumentResolvers(new DSUserDetailsArgumentResolver(null)) + .build(); + } + + /** + * Creates a MockMvc instance with a custom argument resolver that returns the test user. + */ + private MockMvc mockMvcWithAuthenticatedUser() { + return MockMvcBuilders.standaloneSetup(gdprAPI) + .setCustomArgumentResolvers(new DSUserDetailsArgumentResolver(testUserDetails)) + .build(); + } + + /** + * Creates a MockMvc instance with validation enabled and authenticated user. + */ + private MockMvc mockMvcWithValidationAndAuth() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + return MockMvcBuilders.standaloneSetup(gdprAPI) + .setValidator(validator) + .setCustomArgumentResolvers(new DSUserDetailsArgumentResolver(testUserDetails)) + .build(); + } + + @Nested + @DisplayName("Export User Data Tests") + class ExportUserDataTests { + + @Test + @DisplayName("GET /user/gdpr/export - returns export when authenticated") + void exportUserData_whenAuthenticated_returnsExport() throws Exception { + // Given + MockMvc authedMockMvc = mockMvcWithAuthenticatedUser(); + when(gdprConfig.isEnabled()).thenReturn(true); + when(userService.findUserByEmail(testUser.getEmail())).thenReturn(testUser); + + GdprExportDTO exportDTO = GdprExportDTO.builder() + .metadata(GdprExportDTO.ExportMetadata.builder() + .exportedAt(Instant.now()) + .formatVersion("1.0") + .exportedBy("Spring User Framework") + .build()) + .userData(GdprExportDTO.UserData.builder() + .id(testUser.getId()) + .email(testUser.getEmail()) + .firstName(testUser.getFirstName()) + .lastName(testUser.getLastName()) + .enabled(true) + .build()) + .auditHistory(Collections.emptyList()) + .consents(Collections.emptyList()) + .build(); + when(gdprExportService.exportUserData(testUser)).thenReturn(exportDTO); + + // When & Then + authedMockMvc.perform(get("/user/gdpr/export") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").exists()); + + verify(gdprExportService).exportUserData(testUser); + } + + @Test + @DisplayName("GET /user/gdpr/export - returns 401 when unauthenticated") + void exportUserData_whenUnauthenticated_returns401() throws Exception { + // Given + when(gdprConfig.isEnabled()).thenReturn(true); + + // When & Then + mockMvc.perform(get("/user/gdpr/export") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(1)); + + verify(gdprExportService, never()).exportUserData(any()); + } + + @Test + @DisplayName("GET /user/gdpr/export - returns 404 when GDPR disabled") + void exportUserData_whenGdprDisabled_returns404() throws Exception { + // Given + MockMvc authedMockMvc = mockMvcWithAuthenticatedUser(); + when(gdprConfig.isEnabled()).thenReturn(false); + + // When & Then + authedMockMvc.perform(get("/user/gdpr/export") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(404)); + + verify(gdprExportService, never()).exportUserData(any()); + } + } + + @Nested + @DisplayName("Delete Account Tests") + class DeleteAccountTests { + + @Test + @DisplayName("POST /user/gdpr/delete - deletes and logs out when authenticated") + void deleteAccount_whenAuthenticated_deletesAndLogsOut() throws Exception { + // Given + MockMvc authedMockMvc = mockMvcWithAuthenticatedUser(); + when(gdprConfig.isEnabled()).thenReturn(true); + when(userService.findUserByEmail(testUser.getEmail())).thenReturn(testUser); + + GdprDeletionService.DeletionResult result = GdprDeletionService.DeletionResult.success(null); + when(gdprDeletionService.deleteUser(testUser)).thenReturn(result); + + // When & Then + authedMockMvc.perform(post("/user/gdpr/delete") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value(0)); + + verify(gdprDeletionService).deleteUser(testUser); + } + + @Test + @DisplayName("POST /user/gdpr/delete - returns 401 when unauthenticated") + void deleteAccount_whenUnauthenticated_returns401() throws Exception { + // Given + when(gdprConfig.isEnabled()).thenReturn(true); + + // When & Then + mockMvc.perform(post("/user/gdpr/delete") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.code").value(1)); + + verify(gdprDeletionService, never()).deleteUser(any()); + } + } + + @Nested + @DisplayName("Record Consent Tests") + class RecordConsentTests { + + @Test + @DisplayName("POST /user/gdpr/consent - grants consent with valid request") + void recordConsent_withValidRequest_grantsConsent() throws Exception { + // Given + MockMvc authedMockMvc = mockMvcWithValidationAndAuth(); + when(gdprConfig.isEnabled()).thenReturn(true); + when(gdprConfig.isConsentTracking()).thenReturn(true); + when(userService.findUserByEmail(testUser.getEmail())).thenReturn(testUser); + + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.MARKETING_EMAILS) + .grantedAt(Instant.now()) + .method("api") + .build(); + when(consentAuditService.recordConsentGranted(any(), any(), any(), any(), any(), any())) + .thenReturn(record); + + ConsentRequestDto request = ConsentRequestDto.builder() + .consentType(ConsentType.MARKETING_EMAILS) + .grant(true) + .policyVersion("v1.0") + .method("api") + .build(); + + // When & Then + authedMockMvc.perform(post("/user/gdpr/consent") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value(0)); + + verify(consentAuditService).recordConsentGranted(any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("POST /user/gdpr/consent - handles custom type correctly") + void recordConsent_withCustomType_validatesInput() throws Exception { + // Given + MockMvc authedMockMvc = mockMvcWithValidationAndAuth(); + when(gdprConfig.isEnabled()).thenReturn(true); + when(gdprConfig.isConsentTracking()).thenReturn(true); + when(userService.findUserByEmail(testUser.getEmail())).thenReturn(testUser); + + ConsentRecord record = ConsentRecord.builder() + .type(ConsentType.CUSTOM) + .customType("my_custom_consent") + .grantedAt(Instant.now()) + .method("api") + .build(); + when(consentAuditService.recordConsentGranted(any(), any(), any(), any(), any(), any())) + .thenReturn(record); + + ConsentRequestDto request = ConsentRequestDto.builder() + .consentType(ConsentType.CUSTOM) + .customType("my_custom_consent") + .grant(true) + .build(); + + // When & Then + authedMockMvc.perform(post("/user/gdpr/consent") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("POST /user/gdpr/consent - rejects invalid custom type pattern") + void recordConsent_withInvalidCustomType_returns400() throws Exception { + // Given + MockMvc authedMockMvc = mockMvcWithValidationAndAuth(); + + ConsentRequestDto request = ConsentRequestDto.builder() + .consentType(ConsentType.CUSTOM) + .customType("invalid