base = new ArrayList<>(List.of("accept", "decline", "withdraw", "status", "help"));
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
return filter(base, args[0]);
}
diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
index 1665075..1318993 100644
--- a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
+++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
@@ -1,5 +1,9 @@
package de.simolzimol.mclogger.consent.database;
+import java.net.InetAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
@@ -92,12 +96,15 @@ public class ConsentDatabase {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
- // Accepted consents - primary record of who agreed to what version
+ // Accepted consents – primary record of who agreed to what version.
+ // ip_address is pseudonymized (last octet/80 bits zeroed) at insert time.
+ // policy_hash is the SHA-256 of the policy_version string for accountability.
stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent (" +
" uuid VARCHAR(36) NOT NULL," +
" username VARCHAR(16) NOT NULL," +
" policy_version VARCHAR(64) NOT NULL," +
+ " policy_hash VARCHAR(64) NULL," +
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" ip_address VARCHAR(45) NULL," +
" PRIMARY KEY (uuid, policy_version)," +
@@ -105,7 +112,12 @@ public class ConsentDatabase {
" INDEX idx_consent_version (policy_version)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
- // Decline audit trail - never deleted (important for GDPR accountability)
+ // Migrate existing tables: add policy_hash if not present
+ stmt.execute(
+ "ALTER TABLE player_consent " +
+ "ADD COLUMN IF NOT EXISTS policy_hash VARCHAR(64) NULL");
+
+ // Decline audit trail
stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent_declines (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
@@ -148,17 +160,23 @@ public class ConsentDatabase {
/**
* Records that the player has accepted the current policy version.
* Uses {@code INSERT IGNORE} so duplicate calls are safe.
+ *
+ * The IP address is pseudonymized before storage (Art. 5(1)(c) GDPR):
+ * last octet zeroed for IPv4, last 80 bits zeroed for IPv6.
+ * A SHA-256 hash of the policy version string is stored for accountability
+ * (Art. 5(2) GDPR).
*/
public void recordConsent(String uuid, String username, String policyVersion, String ip) {
final String sql =
- "INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" +
- " VALUES (?, ?, ?, ?)";
+ "INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, ip_address)" +
+ " VALUES (?, ?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, policyVersion);
- ps.setString(4, ip);
+ ps.setString(4, computePolicyHash(policyVersion));
+ ps.setString(5, pseudonymizeIp(ip));
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
@@ -167,7 +185,7 @@ public class ConsentDatabase {
/**
* Records that the player declined the current policy version.
- * Declines are appended to an audit table and are never deleted.
+ * Declines are appended to an audit table; their IP is pseudonymized.
*/
public void recordDecline(String uuid, String username, String policyVersion, String ip) {
final String sql =
@@ -178,7 +196,7 @@ public class ConsentDatabase {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, policyVersion);
- ps.setString(4, ip);
+ ps.setString(4, pseudonymizeIp(ip));
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
@@ -243,6 +261,100 @@ public class ConsentDatabase {
* @param uuid Player UUID string
* @param version Policy version string
*/
+ // ─────────────────────────────────────────────────────────
+ // GDPR – Art. 5(1)(e) Storage limitation
+ // ─────────────────────────────────────────────────────────
+
+ /**
+ * Deletes consent and decline records older than the configured retention
+ * periods. Pass 0 to skip deletion for that table.
+ *
+ *
Intended to be called from an async scheduled task once per day.
+ */
+ public void purgeOldConsents(int consentRetentionDays, int declineRetentionDays) {
+ if (consentRetentionDays > 0) {
+ final String sql =
+ "DELETE FROM player_consent " +
+ "WHERE consented_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setInt(1, consentRetentionDays);
+ int rows = ps.executeUpdate();
+ if (rows > 0) {
+ plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
+ + " consent record(s) older than " + consentRetentionDays + " days.");
+ }
+ } catch (SQLException e) {
+ plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() consent failed", e);
+ }
+ }
+ if (declineRetentionDays > 0) {
+ final String sql =
+ "DELETE FROM player_consent_declines " +
+ "WHERE declined_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement ps = conn.prepareStatement(sql)) {
+ ps.setInt(1, declineRetentionDays);
+ int rows = ps.executeUpdate();
+ if (rows > 0) {
+ plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
+ + " decline record(s) older than " + declineRetentionDays + " days.");
+ }
+ } catch (SQLException e) {
+ plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() declines failed", e);
+ }
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────
+ // Privacy helpers
+ // ─────────────────────────────────────────────────────────
+
+ /**
+ * Pseudonymizes an IP address for storage (Art. 5(1)(c) GDPR – data minimisation).
+ *
+ * - IPv4: last octet set to 0 (e.g. {@code 192.168.1.100} → {@code 192.168.1.0})
+ * - IPv6: last 80 bits set to 0 (keeps the first 48 bits / ISP prefix)
+ *
+ * Returns {@code "unknown"} if the address cannot be parsed.
+ */
+ static String pseudonymizeIp(String ip) {
+ if (ip == null || ip.isBlank() || ip.equals("unknown") || ip.equals("admin-forced")) {
+ return ip;
+ }
+ try {
+ InetAddress addr = InetAddress.getByName(ip);
+ byte[] bytes = addr.getAddress();
+ if (bytes.length == 4) {
+ // IPv4 – zero last octet
+ bytes[3] = 0;
+ } else if (bytes.length == 16) {
+ // IPv6 – zero last 10 bytes (80 bits)
+ for (int i = 6; i < 16; i++) bytes[i] = 0;
+ }
+ return InetAddress.getByAddress(bytes).getHostAddress();
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+
+ /**
+ * Computes a SHA-256 hex digest of the policy version string.
+ * Stored alongside each consent record so the exact version accepted can
+ * be verified later (Art. 5(2) GDPR – accountability).
+ */
+ static String computePolicyHash(String policyVersion) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(policyVersion.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) sb.append(String.format("%02x", b));
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ return "unavailable";
+ }
+ }
+
public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
diff --git a/consent-plugin/src/main/resources/config.yml b/consent-plugin/src/main/resources/config.yml
index 0b965c8..32f944f 100644
--- a/consent-plugin/src/main/resources/config.yml
+++ b/consent-plugin/src/main/resources/config.yml
@@ -89,7 +89,14 @@ consent:
# Players with this permission bypass all consent checks.
# Default: consent.bypass (assigned to ops by default)
bypass-permission: "consent.bypass"
+ # ── GDPR – Aufbewahrungsfristen (Art. 5(1)(e)) ─────────────────
+ # Number of days to keep accepted consent records.
+ # After this period data is automatically deleted by the plugin.
+ # Set to 0 to keep records indefinitely (not recommended for GDPR).
+ retention-days-consent: 1095 # 3 years
+ # Number of days to keep decline records.
+ retention-days-declines: 1095 # 3 years
# ── Admin settings ───────────────────────────────────────────
admin:
# Log consent accept/decline events to the server_events table in
@@ -125,9 +132,13 @@ messages:
# ── Join prompt ─────────────────────────────────────────────
# Sent to a player when they join and consent is required.
+ # This text must inform the player what data is being collected
+ # and for what purpose (Art. 13 GDPR).
join-prompt: |
Privacy Policy Consent Required
Before you can play you must read and accept our Privacy Policy.
+ We collect: chat messages, commands, login/logout times, IP address
+ (pseudonymized), and player position – for server moderation purposes.
Click to open in browser'>📄 {url}
➜ Type /{command} accept to agree and start playing.
➜ Type /{command} decline to disconnect.
@@ -197,3 +208,15 @@ messages:
⚠ Reminder: Please accept our Privacy Policy.
{url}
Type /{command} accept to accept.
+ # ── Consent withdrawn (Art. 7(3) GDPR) ───────────────────────
+ # Shown in chat just before the player is kicked after /consent withdraw.
+ withdrawn: "Your consent has been withdrawn. You will be disconnected. You may rejoin and re-accept the Privacy Policy at any time."
+
+ # Disconnect screen message shown after /consent withdraw.
+ withdraw-kick-message: |
+ Consent Withdrawn
+
+ You have successfully withdrawn your consent.
+ Your data will be handled according to the Privacy Policy.
+ You may reconnect at any time and accept the Privacy Policy.
+ Visit: {url}
\ No newline at end of file