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 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.getId(), 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. + * + *

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 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()) { + // Log audit event with user info before logout (user still in memory) + logAuditEvent("GdprDelete", "Success", "User account deleted", user, request); + logoutUser(user, 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.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.getId(), 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.getId(), 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.getId(), 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 user from all sessions and clears the current security context. + * + *

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 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()); + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java index 0ff8bc2..01b1564 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/AuditConfig.java @@ -47,4 +47,12 @@ public class AuditConfig { */ private int flushRate; + /** + * Maximum number of audit events to return from a single query. + * This prevents unbounded memory usage when querying large audit logs. + * Set to 0 or negative to disable the limit (not recommended for production). + * Default is 10000. + */ + private int maxQueryResults = 10000; + } 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..b04554f --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/audit/FileAuditLogQueryService.java @@ -0,0 +1,291 @@ +package com.digitalsanctuary.spring.user.audit; + +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.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +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; +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 (<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 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. + * Uses Java Streams for efficient memory handling with large log files. + * + * @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(); + } + + String userEmail = user.getEmail(); + String userId = user.getId() != null ? user.getId().toString() : null; + + int maxResults = auditConfig.getMaxQueryResults(); + + try (Stream lines = Files.lines(logPath)) { + Stream stream = 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()))); + + // Apply limit if configured to prevent unbounded memory usage + if (maxResults > 0) { + stream = stream.limit(maxResults); + } + + return stream.collect(Collectors.toList()); + } catch (IOException e) { + log.error("FileAuditLogQueryService.findByUser: Error reading audit log file", e); + return Collections.emptyList(); + } + } + + /** + * 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. + * + *

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 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/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/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 getConsentStatus(User user) { + if (user == null) { + return new LinkedHashMap<>(); + } + + Map statusMap = new LinkedHashMap<>(); + + try { + // 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); + + // 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)) { + continue; // Skip if no type or already processed (newer event wins) + } + + 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.getId(), 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.getId(), e.getMessage()); + } + + return records; + } + + /** + * 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) { + 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 "{}"; + } + } + + /** + * 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 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; + } + } + + /** + * Parses consent extra data from JSON string. + * + * @param extraData the JSON string to parse + * @return the parsed ConsentExtraData, or null if parsing fails + */ + private ConsentExtraData parseExtraData(String extraData) { + if (extraData == null || extraData.isEmpty()) { + return null; + } + try { + return objectMapper.readValue(extraData, ConsentExtraData.class); + } catch (JacksonException e) { + log.debug("ConsentAuditService.parseExtraData: Failed to parse extra data: {}", extraData); + 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) { + return UserUtils.getClientIP(request); + } + + /** + * 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/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/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..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). + * + *

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. + * + *

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: + *

+ * + * @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; + private final ObjectMapper objectMapper; + + /** + * 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.getId()); + + 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.getId()); + 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.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()) { + return new ArrayList<>(); + } + + try { + // Fetch all consent-related audit events + List grantedEvents = auditLogQueryService.findByUserAndAction(user, "CONSENT_GRANTED"); + List withdrawnEvents = auditLogQueryService.findByUserAndAction(user, "CONSENT_WITHDRAWN"); + + // 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<>(); + + for (AuditEventDTO event : allEvents) { + boolean isGrant = "CONSENT_GRANTED".equals(event.getAction()); + ConsentRecord record = parseConsentFromAuditEvent(event, isGrant); + if (record == null) { + continue; + } + + 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()); + // Clear any previous withdrawal - consent is now active again + existing.setWithdrawnAt(null); + } else { + consentMap.put(key, record); + } + } else { + // Withdrawal: update existing or create new + 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.getId(), e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * Parses a consent record from an audit event using Jackson deserialization. + */ + private ConsentRecord parseConsentFromAuditEvent(AuditEventDTO event, boolean isGrant) { + if (event == null || event.getExtraData() == null) { + return null; + } + + try { + 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(extraData.getPolicyVersion()) + .method(extraData.getMethod()) + .ipAddress(event.getIpAddress()); + + if (isGrant) { + builder.grantedAt(event.getTimestamp()); + } else { + builder.withdrawnAt(event.getTimestamp()); + } + + return builder.build(); + } catch (JacksonException e) { + log.debug("GdprExportService.parseConsentFromAuditEvent: Failed to parse consent from event", e); + return null; + } + } + + /** + * 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/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..2196994 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -27,6 +27,11 @@ user.audit.flushRate=30000 # If true, all events will be logged. user.audit.logEvents=true +# Maximum number of audit events to return from a single query. +# Prevents unbounded memory usage when querying large audit logs. +# Set to 0 or negative to disable the limit (not recommended for production). +user.audit.maxQueryResults=10000 + # If true, users can delete their own accounts. If false, accounts are disabled instead of deleted. user.actuallyDeleteAccount=false @@ -152,3 +157,12 @@ 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) +# Disabled by default - enable explicitly to activate GDPR endpoints +user.gdpr.enabled=false +# 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 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..fa5fdc1 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/api/GdprAPITest.java @@ -0,0 +1,512 @@ +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.SessionInvalidationService; +import com.digitalsanctuary.spring.user.service.UserService; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * 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 = 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