feat(sprint-6): Phase 2 — DSGVO consent management
- V6 migration: consents table with audit columns - Consent entity, repository, service (grant/revoke/check) - ConsentController: GET/POST/DELETE consent endpoints - DSGVO export (Art. 15): full personal data JSON download - DSGVO deletion (Art. 17): anonymization + account deactivation - Frontend: consent banner (modal, cannot dismiss), privacy settings page - React Query hooks for consent + DSGVO operations - Full i18n (de/en) for consent and DSGVO namespaces
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.ConsentType;
|
||||
import de.cannamanage.service.ConsentService;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/consent")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Consent", description = "DSGVO consent management")
|
||||
public class ConsentController {
|
||||
|
||||
private final ConsentService consentService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get current user's consents")
|
||||
public ResponseEntity<List<ConsentResponse>> getConsents(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
List<ConsentResponse> consents = consentService.getUserConsents(userId).stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(consents);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Grant consent")
|
||||
public ResponseEntity<ConsentResponse> grantConsent(
|
||||
@RequestBody GrantConsentRequest request,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
String ipAddress = httpRequest.getRemoteAddr();
|
||||
String userAgent = httpRequest.getHeader("User-Agent");
|
||||
|
||||
Consent consent = consentService.grantConsent(
|
||||
userId,
|
||||
request.type(),
|
||||
request.version() != null ? request.version() : 1,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
return ResponseEntity.ok(toResponse(consent));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{type}")
|
||||
@Operation(summary = "Revoke consent")
|
||||
public ResponseEntity<Void> revokeConsent(@PathVariable String type, Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
ConsentType consentType = ConsentType.valueOf(type);
|
||||
consentService.revokeConsent(userId, consentType);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
@Operation(summary = "Check if user has required DATA_PROCESSING consent")
|
||||
public ResponseEntity<Map<String, Boolean>> checkConsent(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
boolean hasConsent = consentService.hasRequiredConsents(userId);
|
||||
return ResponseEntity.ok(Map.of("hasDataProcessingConsent", hasConsent));
|
||||
}
|
||||
|
||||
private UUID resolveUserId(Authentication auth) {
|
||||
String email = auth.getName();
|
||||
return userRepository.findByEmailAndTenantId(email, TenantContext.getCurrentTenant())
|
||||
.map(User::getId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
private ConsentResponse toResponse(Consent consent) {
|
||||
return new ConsentResponse(
|
||||
consent.getId(),
|
||||
consent.getConsentType().name(),
|
||||
consent.isGranted(),
|
||||
consent.getGrantedAt() != null ? consent.getGrantedAt().toString() : null,
|
||||
consent.getRevokedAt() != null ? consent.getRevokedAt().toString() : null,
|
||||
consent.getVersion()
|
||||
);
|
||||
}
|
||||
|
||||
public record GrantConsentRequest(ConsentType type, Integer version) {}
|
||||
public record ConsentResponse(UUID id, String type, boolean granted, String grantedAt, String revokedAt, int version) {}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.service.DsgvoService;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dsgvo")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "DSGVO", description = "Data export and deletion (GDPR Art. 15 & 17)")
|
||||
public class DsgvoController {
|
||||
|
||||
private final DsgvoService dsgvoService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Art. 15 DSGVO — Export all personal data as JSON.
|
||||
*/
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "Export all personal data (Art. 15 DSGVO)")
|
||||
public ResponseEntity<Map<String, Object>> exportData(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Map<String, Object> data = dsgvoService.exportUserData(userId, tenantId);
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Art. 17 DSGVO — Right to erasure.
|
||||
* Anonymizes personal data, deactivates account.
|
||||
*/
|
||||
@DeleteMapping("/delete")
|
||||
@Operation(summary = "Delete account and anonymize data (Art. 17 DSGVO)")
|
||||
public ResponseEntity<Map<String, String>> deleteAccount(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
dsgvoService.deleteUserData(userId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "deleted",
|
||||
"message", "Dein Konto wurde gelöscht und deine Daten anonymisiert."
|
||||
));
|
||||
}
|
||||
|
||||
private UUID resolveUserId(Authentication auth) {
|
||||
String email = auth.getName();
|
||||
return userRepository.findByEmailAndTenantId(email, TenantContext.getCurrentTenant())
|
||||
.map(User::getId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- V6: DSGVO Consent Management
|
||||
-- Tracks user consent for data processing, marketing, analytics per GDPR Art. 6/7
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
consent_type VARCHAR(50) NOT NULL, -- 'DATA_PROCESSING', 'MARKETING', 'ANALYTICS'
|
||||
granted BOOLEAN NOT NULL DEFAULT false,
|
||||
granted_at TIMESTAMP WITH TIME ZONE,
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1, -- consent text version
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_consents_user ON consents(user_id);
|
||||
CREATE INDEX idx_consents_tenant ON consents(tenant_id);
|
||||
CREATE INDEX idx_consents_type ON consents(consent_type, user_id);
|
||||
Reference in New Issue
Block a user