diff --git a/CONFIG.md b/CONFIG.md index 44d4535..00eb99d 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -38,6 +38,26 @@ Welcome to the User Framework SpringBoot Configuration Guide! This document outl - **Log File Path (`user.audit.logFilePath`)**: The path to the audit log file. - **Flush on Write (`user.audit.flushOnWrite`)**: Set to `true` for immediate log flushing. Defaults to `false` for performance. +- **Max Query Results (`user.audit.maxQueryResults`)**: Maximum number of audit events returned from queries. Prevents memory issues with large logs. Defaults to `10000`. + +## GDPR Compliance + +GDPR features are disabled by default and must be explicitly enabled. + +- **Enable GDPR (`user.gdpr.enabled`)**: Master toggle for all GDPR features. When `false`, all GDPR endpoints return 404. Defaults to `false`. +- **Export Before Deletion (`user.gdpr.exportBeforeDeletion`)**: When `true`, user data is automatically exported and included in the deletion response. Defaults to `true`. +- **Consent Tracking (`user.gdpr.consentTracking`)**: Enable consent grant/withdrawal tracking via the audit system. Defaults to `true`. + +**Example configuration:** +```yaml +user: + gdpr: + enabled: true + exportBeforeDeletion: true + consentTracking: true +``` + +**Note**: When GDPR is enabled, ensure you have a `UserPreDeleteEvent` listener configured to clean up application-specific user data before deletion. See the README for details. ## Security Settings diff --git a/README.md b/README.md index 36d7e78..63d5f13 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond - [Handling User Account Deletion and Profile Cleanup](#handling-user-account-deletion-and-profile-cleanup) - [Enabling Actual Deletion](#enabling-actual-deletion) - [SSO OAuth2 with Google and Facebook](#sso-oauth2-with-google-and-facebook) + - [GDPR Compliance](#gdpr-compliance) + - [Enabling GDPR Features](#enabling-gdpr-features) + - [Data Export (Right of Access)](#data-export-right-of-access) + - [Account Deletion (Right to be Forgotten)](#account-deletion-right-to-be-forgotten) + - [Consent Management](#consent-management) + - [Extending GDPR Exports](#extending-gdpr-exports) + - [GDPR Events](#gdpr-events) - [Examples](#examples) - [Contributing](#contributing) - [Reference Documentation](#reference-documentation) @@ -92,6 +99,13 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond - Comprehensive documentation - Demo application for reference +- **GDPR Compliance** (opt-in) + - Data export (Right of Access - Article 15) + - Account deletion (Right to be Forgotten - Article 17) + - Consent tracking and management (Article 7) + - Extensible data contributor system for custom data + - Audit trail for all GDPR operations + ## Installation Choose the version that matches your Spring Boot version: @@ -747,6 +761,182 @@ By implementing such a listener, your application ensures data integrity when th The framework supports SSO OAuth2 with Google, Facebook and Keycloak. To enable this you need to configure the client id and secret for each provider. This is done in the application.yml (or application.properties) file using the [Spring Security OAuth2 properties](https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html). You can see the example configuration in the Demo Project's `application.yml` file. +## GDPR Compliance + +The framework provides opt-in GDPR compliance features to help your application meet European data protection requirements. These features are **disabled by default** and must be explicitly enabled. + +### Enabling GDPR Features + +Add the following to your `application.yml`: + +```yaml +user: + gdpr: + enabled: true # Master toggle for all GDPR features + exportBeforeDeletion: true # Automatically export data before deletion + consentTracking: true # Enable consent grant/withdrawal tracking +``` + +When enabled, the following REST endpoints become available (all require authentication): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/user/gdpr/export` | GET | Export all user data as JSON | +| `/user/gdpr/delete` | POST | Request account deletion | +| `/user/gdpr/consent` | POST | Record consent grant or withdrawal | +| `/user/gdpr/consent/status` | GET | Get current consent status | + +### Data Export (Right of Access) + +Users can request a complete export of their data via the `/user/gdpr/export` endpoint. The export includes: + +- **User account data**: Name, email, registration date, roles +- **Audit history**: Login events, password changes, profile updates +- **Consent records**: All consent grants and withdrawals with timestamps +- **Token metadata**: Verification and password reset token expiry (not actual tokens) +- **Custom data**: Any data contributed by registered `GdprDataContributor` beans + +**Example Response:** +```json +{ + "success": true, + "data": { + "exportedAt": "2024-01-15T10:30:00Z", + "user": { + "id": 123, + "email": "user@example.com", + "firstName": "John", + "lastName": "Doe", + "createdDate": "2023-06-01T08:00:00Z", + "roles": ["ROLE_USER"] + }, + "auditHistory": [...], + "consents": [...], + "customData": {} + } +} +``` + +### Account Deletion (Right to be Forgotten) + +Users can request complete deletion of their account via the `/user/gdpr/delete` endpoint. The deletion process: + +1. **Exports data** (if `exportBeforeDeletion=true`) and includes it in the response +2. **Notifies contributors** via `GdprDataContributor.prepareForDeletion()` +3. **Publishes `UserPreDeleteEvent`** for custom cleanup listeners +4. **Deletes framework data**: Verification tokens, password reset tokens, password history +5. **Deletes user entity** from database +6. **Publishes `UserDeletedEvent`** for post-deletion processing +7. **Invalidates all sessions** across all devices +8. **Logs out** the current session + +**Important**: This performs a hard delete. Ensure you have the `UserPreDeleteEvent` listener configured (see [Handling User Account Deletion](#handling-user-account-deletion-and-profile-cleanup)) to clean up related data. + +### Consent Management + +Track user consent for various purposes (marketing, analytics, data processing, etc.): + +**Recording Consent:** +```bash +# Grant consent +curl -X POST /user/gdpr/consent \ + -H "Content-Type: application/json" \ + -d '{"consentType": "MARKETING", "granted": true}' + +# Withdraw consent +curl -X POST /user/gdpr/consent \ + -H "Content-Type: application/json" \ + -d '{"consentType": "MARKETING", "granted": false}' + +# Custom consent type +curl -X POST /user/gdpr/consent \ + -H "Content-Type: application/json" \ + -d '{"consentType": "CUSTOM", "customType": "newsletter_weekly", "granted": true}' +``` + +**Built-in Consent Types:** +- `MARKETING` - Marketing communications +- `ANALYTICS` - Analytics and tracking +- `THIRD_PARTY` - Third-party data sharing +- `PROFILING` - User profiling +- `CUSTOM` - Application-specific (requires `customType` field) + +**Checking Consent Status:** +```bash +curl /user/gdpr/consent/status?type=MARKETING +``` + +All consent changes are recorded in the audit log with timestamps, IP addresses, and user agent information. + +### Extending GDPR Exports + +To include your application's custom data in GDPR exports, implement the `GdprDataContributor` interface: + +```java +@Component +public class OrderDataContributor implements GdprDataContributor { + + private final OrderRepository orderRepository; + + public OrderDataContributor(OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + + @Override + public String getDataKey() { + return "orders"; // Key in the export JSON + } + + @Override + public Object contributeData(User user) { + // Return data to include in export (will be serialized to JSON) + return orderRepository.findByUserId(user.getId()) + .stream() + .map(this::toExportDto) + .toList(); + } + + @Override + public void prepareForDeletion(User user) { + // Clean up data before user deletion (runs within transaction) + // WARNING: Only delete LOCAL database data here, not external APIs + orderRepository.deleteByUserId(user.getId()); + } + + private OrderExportDto toExportDto(Order order) { + // Map to DTO for export + } +} +``` + +**Important**: The `prepareForDeletion()` method runs within the same database transaction as user deletion. Only perform local database operations here. For external API cleanup, use a `UserDeletedEvent` listener instead. + +### GDPR Events + +The framework publishes Spring events for GDPR operations: + +| Event | When Published | Use Case | +|-------|----------------|----------| +| `UserPreDeleteEvent` | Before user deletion (in transaction) | Clean up related database records | +| `UserDeletedEvent` | After successful deletion | External API cleanup, notifications | +| `UserDataExportedEvent` | After data export | Audit logging, analytics | +| `ConsentChangedEvent` | After consent grant/withdrawal | Trigger consent-dependent workflows | + +**Example: External Cleanup After Deletion** +```java +@Component +public class ExternalCleanupListener { + + @EventListener + @Async // Run asynchronously after transaction commits + public void onUserDeleted(UserDeletedEvent event) { + // Safe to call external APIs here + externalCrmService.deleteCustomer(event.getUserEmail()); + analyticsService.anonymizeUser(event.getUserId()); + } +} +``` + ## Examples For complete working examples, check out the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp). 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..a240574 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/api/GdprAPI.java @@ -0,0 +1,352 @@ +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.SessionInvalidationService; +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: + *
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 SessionInvalidationService sessionInvalidationService; + private final MessageSource messages; + private final ApplicationEventPublisher eventPublisher; + + /** + * Exports all GDPR-relevant data for the authenticated user. + * + *
Returns a comprehensive export including: + *
Rate Limiting: This endpoint performs resource-intensive operations
+ * (file I/O, JSON parsing, data aggregation). Consider implementing rate limiting
+ * at the infrastructure level (e.g., API gateway, Spring Security rate limiter,
+ * or reverse proxy) to prevent abuse.
+ *
+ * @param userDetails the authenticated user
+ * @param request the HTTP request
+ * @return the complete data export as JSON
+ */
+ @GetMapping("/export")
+ public ResponseEntity If configured, exports user data before deletion and includes
+ * it in the response. After deletion, the user is logged out.
+ *
+ * Rate Limiting: This endpoint performs destructive, resource-intensive
+ * operations. Consider implementing rate limiting at the infrastructure level
+ * to prevent abuse or accidental repeated deletion attempts.
+ *
+ * @param userDetails the authenticated user
+ * @param request the HTTP request
+ * @return deletion result, optionally including exported data
+ */
+ @PostMapping("/delete")
+ public ResponseEntity This method invalidates ALL sessions for the user across all devices/browsers,
+ * not just the current session. This is critical for GDPR account deletion to ensure
+ * no orphaned sessions remain with references to the deleted user.
+ *
+ * @param user the user to log out (used for invalidating all sessions)
+ * @param request the current HTTP request
+ */
+ private void logoutUser(User user, HttpServletRequest request) {
+ try {
+ // Invalidate all user sessions across all devices
+ int invalidatedCount = sessionInvalidationService.invalidateUserSessions(user);
+ log.debug("GdprAPI.logoutUser: Invalidated {} sessions for user {}", invalidatedCount, user.getId());
+
+ // Clear current context and logout current session
+ 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(false) != null ? request.getSession(false).getId() : null)
+ .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 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 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 (<50MB, <100K events), applications with high audit volumes
+ * or frequent export requests should consider implementing a database-backed
+ * query service for better performance.
+ *
+ * Performance Note: GDPR export operations call this service
+ * multiple times (findByUser, findByUserAndAction) which results in reading
+ * and parsing the entire log file for each call. For production deployments
+ * with large audit logs, consider:
+ * 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 Note: This parser assumes the audit log writer properly escapes
+ * pipe characters in message content. If audit messages contain unescaped pipes,
+ * parsing may be corrupted. Consider migrating to a structured format (JSON lines)
+ * for production deployments with untrusted input.
+ *
+ * @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 but got {}: {}",
+ parts.length, line);
+ return null;
+ }
+
+ // Defensive: If more than 10 fields exist due to unescaped pipes in message,
+ // join the extra parts back into the message field
+ if (parts.length > 10) {
+ log.debug("FileAuditLogQueryService.parseLine: Line has {} fields (expected 10), " +
+ "likely due to unescaped pipes in message content", parts.length);
+ // Join parts[7] through parts[parts.length-3] as the message
+ StringBuilder messageBuilder = new StringBuilder(parts[7]);
+ for (int i = 8; i < parts.length - 2; i++) {
+ messageBuilder.append("|").append(parts[i]);
+ }
+ parts[7] = messageBuilder.toString();
+ // Shift the last two fields (userAgent and extraData) to their expected positions
+ parts[8] = parts[parts.length - 2];
+ parts[9] = parts[parts.length - 1];
+ }
+
+ 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 - first as ZonedDateTime, then as LocalDateTime
+ for (DateTimeFormatter formatter : DATE_FORMATTERS) {
+ // Try parsing as ZonedDateTime (for formats with timezone info)
+ try {
+ ZonedDateTime zdt = ZonedDateTime.parse(dateStr.trim(), formatter);
+ return zdt.toInstant();
+ } catch (DateTimeParseException e) {
+ // Try next approach
+ }
+
+ // Try parsing as LocalDateTime (for formats without timezone info like MessageFormat output)
+ try {
+ LocalDateTime ldt = LocalDateTime.parse(dateStr.trim(), formatter);
+ return ldt.atZone(ZoneId.systemDefault()).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/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..7884c77
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/dto/ConsentRequestDto.java
@@ -0,0 +1,58 @@
+package com.digitalsanctuary.spring.user.dto;
+
+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;
+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.
+ * 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;
+
+ /**
+ * 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 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/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..81618c3
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/ConsentAuditService.java
@@ -0,0 +1,433 @@
+package com.digitalsanctuary.spring.user.gdpr;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+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 com.digitalsanctuary.spring.user.util.UserUtils;
+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.
+ *
+ * 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;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * 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 consent type safely - avoid exposing custom type names which could contain PII
+ String logTypeName = record.getType() == ConsentType.CUSTOM ? "CUSTOM" : record.getEffectiveTypeName();
+ log.info("ConsentAuditService.recordConsentGranted: Recorded consent grant for user {} - type {}",
+ user.getId(), logTypeName);
+
+ 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 consent type safely - avoid exposing custom type names which could contain PII
+ String logTypeName = record.getType() == ConsentType.CUSTOM ? "CUSTOM" : record.getEffectiveTypeName();
+ log.info("ConsentAuditService.recordConsentWithdrawn: Recorded consent withdrawal for user {} - type {}",
+ user.getId(), logTypeName);
+
+ 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.
+ * 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
+ */
+ public Map 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/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:
+ * 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:
+ * 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:
+ * This service orchestrates the complete user deletion process including:
+ * 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
+ */
+ public DeletionResult deleteUser(User user) {
+ return deleteUser(user, gdprConfig.isExportBeforeDeletion());
+ }
+
+ /**
+ * 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
+ */
+ public DeletionResult deleteUser(User user, boolean exportBeforeDeletion) {
+ if (user == null) {
+ throw new IllegalArgumentException("User cannot be null");
+ }
+
+ Long userId = user.getId();
+
+ log.info("GdprDeletionService.deleteUser: Starting GDPR deletion for user {}", userId);
+
+ GdprExportDTO exportedData = null;
+
+ try {
+ // 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 {}", userId);
+ exportedData = gdprExportService.exportUserData(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();
+ String userEmail = user.getEmail();
+
+ // 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 {}", userId);
+ 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 {}", userId);
+ userRepository.delete(user);
+
+ log.info("GdprDeletionService.deleteUser: Successfully deleted user {}", userId);
+
+ // Step 6: Publish UserDeletedEvent after successful deletion
+ eventPublisher.publishEvent(new UserDeletedEvent(this, userId, userEmail, wasExported));
+
+ return wasExported
+ ? DeletionResult.successWithExport(exportedData)
+ : DeletionResult.success(null);
+ }
+
+ /**
+ * Notifies all GdprDataContributors to prepare for deletion.
+ *
+ * IMPORTANT: Contributors MUST only delete data within the same transactional context
+ * (i.e., database records in the same database). Avoid calling external APIs or services
+ * that could succeed while the main transaction fails, leading to partial deletion.
+ *
+ * If external data needs cleanup, consider implementing async event listeners for
+ * {@link com.digitalsanctuary.spring.user.event.UserDeletedEvent} instead.
+ */
+ 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.getId());
+ 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.getId());
+ 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..0c89011
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprExportService.java
@@ -0,0 +1,282 @@
+package com.digitalsanctuary.spring.user.gdpr;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+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;
+import tools.jackson.core.JacksonException;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * 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:
+ * 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 = JsonMapper.builder().build();
+
+ @Mock
+ private GdprConfig gdprConfig;
+
+ @Mock
+ private GdprExportService gdprExportService;
+
+ @Mock
+ private GdprDeletionService gdprDeletionService;
+
+ @Mock
+ private ConsentAuditService consentAuditService;
+
+ @Mock
+ private UserService userService;
+
+ @Mock
+ private SessionInvalidationService sessionInvalidationService;
+
+ @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
+ *
+ *
+ *
+ *
+ *
+ * {@code
+ * @Component
+ * public class OrderDataContributor implements GdprDataContributor {
+ *
+ * private final OrderRepository orderRepository;
+ *
+ * @Override
+ * public String getDataKey() {
+ * return "orders";
+ * }
+ *
+ * @Override
+ * public Map
+ *
+ * @see GdprExportService
+ * @see GdprDeletionService
+ */
+public interface GdprDataContributor {
+
+ /**
+ * Returns a unique key identifying this data section in the export.
+ *
+ *
+ *
+ *
+ * @param user the user whose data to export
+ * @return a map of exportable data, or null/empty if no data exists
+ */
+ Map
+ *
+ *
+ * @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..f6b9499
--- /dev/null
+++ b/src/main/java/com/digitalsanctuary/spring/user/gdpr/GdprDeletionService.java
@@ -0,0 +1,232 @@
+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).
+ *
+ *
+ *
+ *
+ * @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
+ *
+ *
+ * @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