From 025b8004ff59bc13710e25fc59f328010d34c7ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:04:00 +0900 Subject: [PATCH 1/6] test(security): specify privacy-safe admin audit boundary --- .../audit/AdministrativeAuditLoggerTest.java | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java diff --git a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java new file mode 100644 index 000000000..036dd9594 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java @@ -0,0 +1,219 @@ +package com.clearfolio.viewer.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; + +import com.clearfolio.viewer.audit.AdministrativeAuditLogger.Action; +import com.clearfolio.viewer.audit.AdministrativeAuditLogger.Outcome; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.config.ConversionProperties; + +class AdministrativeAuditLoggerTest { + + private static final String AUDIT_SECRET = "0123456789abcdef0123456789abcdef"; + + @Test + void recordsAuthenticatedContextWithoutRawIdentifiers() { + AdministrativeAuditLogger auditLogger = configuredLogger(AUDIT_SECRET, "rotation-8"); + TenantContext context = new TenantContext( + "sensitive-tenant", + "employee-007@example.com", + Set.of("admin:read") + ); + CapturingAppender appender = attachAppender(); + + try { + auditLogger.record( + context, + Action.LIST_JOBS, + Outcome.ALLOWED, + HttpStatus.OK, + null, + 2 + ); + } finally { + appender.closeAndDetach(); + } + + String message = appender.singleMessage(); + assertTrue(message.contains("action=LIST_JOBS")); + assertTrue(message.contains("outcome=ALLOWED")); + assertTrue(message.contains("status=200")); + assertTrue(message.contains("jobFingerprint=absent:rotation-8")); + assertFalse(message.contains("jobId=")); + assertTrue(message.contains("resultCount=2")); + assertTrue(message.matches(".*tenantFingerprint=rotation-8:[0-9a-f]{32}.*")); + assertTrue(message.matches(".*actorFingerprint=rotation-8:[0-9a-f]{32}.*")); + assertFalse(message.contains("sensitive-tenant")); + assertFalse(message.contains("employee-007@example.com")); + assertEquals("absent:rotation-8", auditLogger.actorFingerprint(null)); + assertNotEquals( + auditLogger.actorFingerprint(context), + fieldValue(message, "tenantFingerprint") + ); + } + + @Test + void recordsUntrustedHeadersAndJobIdentifiersOnlyAsDomainSeparatedPseudonyms() { + AdministrativeAuditLogger auditLogger = configuredLogger(AUDIT_SECRET, "v2"); + HttpHeaders headers = new HttpHeaders(); + headers.set(TenantContext.TENANT_ID_HEADER, "tenant-from-untrusted-header"); + headers.set(TenantContext.SUBJECT_ID_HEADER, "subject-from-untrusted-header"); + UUID jobId = UUID.randomUUID(); + CapturingAppender appender = attachAppender(); + + try { + auditLogger.recordHeaders( + headers, + Action.DELETE_JOB, + Outcome.DENIED, + HttpStatus.FORBIDDEN, + jobId + ); + } finally { + appender.closeAndDetach(); + } + + String message = appender.singleMessage(); + assertTrue(message.contains("action=DELETE_JOB")); + assertTrue(message.contains("outcome=DENIED")); + assertTrue(message.contains("status=403")); + assertTrue(message.matches(".*jobFingerprint=v2:[0-9a-f]{32}.*")); + assertFalse(message.contains(jobId.toString())); + assertFalse(message.contains("jobId=")); + assertTrue(message.contains("resultCount=-1")); + assertFalse(message.contains("tenant-from-untrusted-header")); + assertFalse(message.contains("subject-from-untrusted-header")); + + String tenantFingerprint = fieldValue(message, "tenantFingerprint"); + String actorFingerprint = fieldValue(message, "actorFingerprint"); + String jobFingerprint = fieldValue(message, "jobFingerprint"); + assertNotEquals(tenantFingerprint, actorFingerprint); + assertNotEquals(tenantFingerprint, jobFingerprint); + assertNotEquals(actorFingerprint, jobFingerprint); + } + + @Test + void usesExplicitAbsentAndUnavailableMarkers() { + AdministrativeAuditLogger unavailableLogger = configuredLogger("", "v9"); + HttpHeaders presentHeaders = new HttpHeaders(); + presentHeaders.set(TenantContext.TENANT_ID_HEADER, "tenant"); + presentHeaders.set(TenantContext.SUBJECT_ID_HEADER, "subject"); + CapturingAppender appender = attachAppender(); + + try { + unavailableLogger.recordHeaders( + presentHeaders, + Action.RETRY_JOB, + Outcome.DENIED, + HttpStatus.UNAUTHORIZED, + null + ); + unavailableLogger.recordHeaders( + null, + Action.RETRY_JOB, + Outcome.DENIED, + HttpStatus.UNAUTHORIZED, + null + ); + unavailableLogger.record( + null, + Action.RETRY_JOB, + Outcome.FAILED, + HttpStatus.INTERNAL_SERVER_ERROR, + null, + null + ); + } finally { + appender.closeAndDetach(); + } + + List messages = appender.messages(); + assertEquals(3, messages.size()); + assertTrue(messages.get(0).contains("tenantFingerprint=unavailable:v9")); + assertTrue(messages.get(0).contains("actorFingerprint=unavailable:v9")); + assertTrue(messages.get(0).contains("jobFingerprint=absent:v9")); + assertTrue(messages.get(1).contains("tenantFingerprint=absent:v9")); + assertTrue(messages.get(1).contains("actorFingerprint=absent:v9")); + assertTrue(messages.get(1).contains("jobFingerprint=absent:v9")); + assertTrue(messages.get(2).contains("tenantFingerprint=absent:v9")); + assertTrue(messages.get(2).contains("actorFingerprint=absent:v9")); + assertTrue(messages.get(2).contains("jobFingerprint=absent:v9")); + } + + private static String fieldValue(String message, String fieldName) { + String prefix = fieldName + "="; + int start = message.indexOf(prefix); + int end = message.indexOf(' ', start); + return message.substring(start + prefix.length(), end < 0 ? message.length() : end); + } + + private static AdministrativeAuditLogger configuredLogger(String secret, String version) { + ConversionProperties properties = new ConversionProperties(); + properties.setAuditPseudonymSecret(secret); + properties.setAuditPseudonymKeyVersion(version); + return new AdministrativeAuditLogger(properties); + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(AdministrativeAuditLogger.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List messages = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "administrative-audit-test-appender", + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + private String singleMessage() { + assertEquals(1, messages.size()); + return messages.getFirst(); + } + + private List messages() { + return List.copyOf(messages); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} From 05ebb7a9dff1b762e55e32f8e28605a429cb159d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:07:08 +0900 Subject: [PATCH 2/6] feat(security): domain-separate administrative audit fingerprints --- .../viewer/security/AuditPseudonymizer.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java index ed3bd31db..0643b6cef 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java @@ -21,6 +21,9 @@ public final class AuditPseudonymizer { private static final String HMAC_SHA_256 = "HmacSHA256"; private static final String DEFAULT_KEY_VERSION = "v1"; private static final String APPROVER_DOMAIN = "clearfolio:audit-approver:v1"; + private static final String ADMIN_ACTOR_DOMAIN = "clearfolio:audit-admin-actor:v1"; + private static final String ADMIN_TENANT_DOMAIN = "clearfolio:audit-admin-tenant:v1"; + private static final String ADMIN_JOB_DOMAIN = "clearfolio:audit-admin-job:v1"; private static final int FINGERPRINT_BYTES = 16; private static final int MIN_SECRET_BYTES = 32; private static final int MAX_KEY_VERSION_LENGTH = 32; @@ -41,6 +44,63 @@ public AuditPseudonymizer(String secret, String keyVersion) { this(secret, keyVersion, APPROVER_DOMAIN); } + /** + * Creates a pseudonymizer for administrative actor identifiers. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; null or blank disables + * correlation and produces the fixed unavailable marker + * @param keyVersion non-sensitive key-rotation identifier; null selects + * {@code v1} + * @return actor-domain pseudonymizer + * @throws IllegalArgumentException when a configured secret is shorter than + * 32 UTF-8 bytes, or when the key version is + * empty, longer than 32 characters, or + * contains characters outside letters, + * digits, period, underscore, and hyphen + */ + public static AuditPseudonymizer forAdministrativeActor(String secret, String keyVersion) { + return new AuditPseudonymizer(secret, keyVersion, ADMIN_ACTOR_DOMAIN); + } + + /** + * Creates a pseudonymizer for administrative tenant identifiers. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; null or blank disables + * correlation and produces the fixed unavailable marker + * @param keyVersion non-sensitive key-rotation identifier; null selects + * {@code v1} + * @return tenant-domain pseudonymizer + * @throws IllegalArgumentException when a configured secret is shorter than + * 32 UTF-8 bytes, or when the key version is + * empty, longer than 32 characters, or + * contains characters outside letters, + * digits, period, underscore, and hyphen + */ + public static AuditPseudonymizer forAdministrativeTenant(String secret, String keyVersion) { + return new AuditPseudonymizer(secret, keyVersion, ADMIN_TENANT_DOMAIN); + } + + /** + * Creates a pseudonymizer for administrative conversion-job identifiers. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; null or blank disables + * correlation and produces the fixed unavailable marker + * @param keyVersion non-sensitive key-rotation identifier; null selects + * {@code v1} + * @return job-domain pseudonymizer + * @throws IllegalArgumentException when a configured secret is shorter than + * 32 UTF-8 bytes, or when the key version is + * empty, longer than 32 characters, or + * contains characters outside letters, + * digits, period, underscore, and hyphen + */ + public static AuditPseudonymizer forAdministrativeJob(String secret, String keyVersion) { + return new AuditPseudonymizer(secret, keyVersion, ADMIN_JOB_DOMAIN); + } + /** * Creates a pseudonymizer with an explicit domain for isolated internal use * and domain-separation verification. From e5827892a244702dc5e2cd2ec12c1a1b3b059c61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:07:59 +0900 Subject: [PATCH 3/6] feat(security): add privacy-safe administrative audit logger --- .../audit/AdministrativeAuditLogger.java | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java diff --git a/src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java b/src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java new file mode 100644 index 000000000..0f47db86d --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/audit/AdministrativeAuditLogger.java @@ -0,0 +1,165 @@ +package com.clearfolio.viewer.audit; + +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.security.AuditPseudonymizer; + +/** + * Emits structured, privacy-safe administrative authorization evidence. + * + *

Actor, tenant, and conversion-job identifiers are pseudonymized in + * separate HMAC domains. The logger never emits raw claim headers, subject + * identifiers, tenant identifiers, job identifiers, tokens, filenames, job + * messages, or document content.

+ */ +@Component +public final class AdministrativeAuditLogger { + + private static final Logger LOGGER = LoggerFactory.getLogger(AdministrativeAuditLogger.class); + private static final int NO_RESULT_COUNT = -1; + + private final AuditPseudonymizer actorPseudonymizer; + private final AuditPseudonymizer tenantPseudonymizer; + private final AuditPseudonymizer jobPseudonymizer; + + /** + * Administrative actions represented in authorization evidence. + */ + public enum Action { + /** Lists tenant-owned conversion jobs. */ + LIST_JOBS, + /** Deletes one tenant-owned conversion job. */ + DELETE_JOB, + /** Retries one tenant-owned dead-lettered conversion job. */ + RETRY_JOB + } + + /** + * Stable outcomes represented in authorization evidence. + */ + public enum Outcome { + /** Authorization and the requested operation succeeded. */ + ALLOWED, + /** Authentication or permission evaluation denied the request. */ + DENIED, + /** The resource was absent or intentionally concealed. */ + NOT_FOUND, + /** The resource existed but its state rejected the operation. */ + NOT_ELIGIBLE, + /** Authorization succeeded but the operation failed unexpectedly. */ + FAILED + } + + /** + * Creates the logger from the dedicated audit pseudonym configuration. + * + * @param properties conversion and audit configuration + */ + public AdministrativeAuditLogger(ConversionProperties properties) { + String secret = properties.getAuditPseudonymSecret(); + String keyVersion = properties.getAuditPseudonymKeyVersion(); + this.actorPseudonymizer = AuditPseudonymizer.forAdministrativeActor(secret, keyVersion); + this.tenantPseudonymizer = AuditPseudonymizer.forAdministrativeTenant(secret, keyVersion); + this.jobPseudonymizer = AuditPseudonymizer.forAdministrativeJob(secret, keyVersion); + } + + /** + * Returns a privacy-safe actor identifier suitable for retry provenance. + * + * @param context authenticated tenant context, or null when unavailable + * @return administrative actor fingerprint + */ + public String actorFingerprint(TenantContext context) { + return actorPseudonymizer.fingerprint(context == null ? null : context.subjectId()); + } + + /** + * Records an authorization decision using authenticated context values. + * + * @param context authenticated tenant context, or null when unavailable + * @param action administrative action + * @param outcome decision outcome + * @param status response status + * @param jobId optional job identifier used only as a keyed HMAC input + * @param resultCount optional list result count + */ + public void record( + TenantContext context, + Action action, + Outcome outcome, + HttpStatusCode status, + UUID jobId, + Integer resultCount + ) { + recordIdentifiers( + context == null ? null : context.tenantId(), + context == null ? null : context.subjectId(), + action, + outcome, + status, + jobId, + resultCount + ); + } + + /** + * Records a denied request using untrusted headers only as HMAC inputs. + * + * @param headers request headers, or null when unavailable + * @param action administrative action + * @param outcome decision outcome + * @param status response status + * @param jobId optional job identifier used only as a keyed HMAC input + */ + public void recordHeaders( + HttpHeaders headers, + Action action, + Outcome outcome, + HttpStatusCode status, + UUID jobId + ) { + recordIdentifiers( + firstHeader(headers, TenantContext.TENANT_ID_HEADER), + firstHeader(headers, TenantContext.SUBJECT_ID_HEADER), + action, + outcome, + status, + jobId, + null + ); + } + + private void recordIdentifiers( + String tenantId, + String subjectId, + Action action, + Outcome outcome, + HttpStatusCode status, + UUID jobId, + Integer resultCount + ) { + LOGGER.info( + "Administrative access decision action={} outcome={} status={} " + + "tenantFingerprint={} actorFingerprint={} jobFingerprint={} resultCount={}", + action, + outcome, + status.value(), + tenantPseudonymizer.fingerprint(tenantId), + actorPseudonymizer.fingerprint(subjectId), + jobPseudonymizer.fingerprint(jobId == null ? null : jobId.toString()), + resultCount == null ? NO_RESULT_COUNT : resultCount + ); + } + + private String firstHeader(HttpHeaders headers, String name) { + return headers == null ? null : headers.getFirst(name); + } +} From 1cb53d311ca0263909ccedb7370bd915d96d6ebf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:08:51 +0900 Subject: [PATCH 4/6] test(security): keep audit regression warning-free --- .../clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java index 036dd9594..8804606d9 100644 --- a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java +++ b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java @@ -173,7 +173,7 @@ private static AdministrativeAuditLogger configuredLogger(String secret, String } private static CapturingAppender attachAppender() { - Logger logger = (Logger) LogManager.getLogger(AdministrativeAuditLogger.class); + Logger logger = LogManager.getLogger(AdministrativeAuditLogger.class); CapturingAppender appender = new CapturingAppender(logger); appender.start(); logger.addAppender(appender); From 6a3302f7b1c915b1622bf1a30b17e7e32ebd95c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 08:10:50 +0900 Subject: [PATCH 5/6] test(security): bind audit appender to core logger --- .../clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java index 8804606d9..036dd9594 100644 --- a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java +++ b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java @@ -173,7 +173,7 @@ private static AdministrativeAuditLogger configuredLogger(String secret, String } private static CapturingAppender attachAppender() { - Logger logger = LogManager.getLogger(AdministrativeAuditLogger.class); + Logger logger = (Logger) LogManager.getLogger(AdministrativeAuditLogger.class); CapturingAppender appender = new CapturingAppender(logger); appender.start(); logger.addAppender(appender); From 267e9f00e7a6ff92966bb9bb75e46d0fd09d20a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:09:17 +0900 Subject: [PATCH 6/6] test(security): address administrative audit review findings --- .../audit/AdministrativeAuditLoggerTest.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java index 036dd9594..6f165c553 100644 --- a/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java +++ b/src/test/java/com/clearfolio/viewer/audit/AdministrativeAuditLoggerTest.java @@ -24,6 +24,7 @@ import com.clearfolio.viewer.audit.AdministrativeAuditLogger.Outcome; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.config.ConversionProperties; +import com.clearfolio.viewer.security.AuditPseudonymizer; class AdministrativeAuditLoggerTest { @@ -110,6 +111,24 @@ void recordsUntrustedHeadersAndJobIdentifiersOnlyAsDomainSeparatedPseudonyms() { assertNotEquals(actorFingerprint, jobFingerprint); } + @Test + void separatesAdministrativeDomainsForIdenticalIdentifierValues() { + String sharedIdentifier = "11111111-1111-1111-1111-111111111111"; + String actorFingerprint = AuditPseudonymizer + .forAdministrativeActor(AUDIT_SECRET, "v2") + .fingerprint(sharedIdentifier); + String tenantFingerprint = AuditPseudonymizer + .forAdministrativeTenant(AUDIT_SECRET, "v2") + .fingerprint(sharedIdentifier); + String jobFingerprint = AuditPseudonymizer + .forAdministrativeJob(AUDIT_SECRET, "v2") + .fingerprint(sharedIdentifier); + + assertNotEquals(actorFingerprint, tenantFingerprint); + assertNotEquals(actorFingerprint, jobFingerprint); + assertNotEquals(tenantFingerprint, jobFingerprint); + } + @Test void usesExplicitAbsentAndUnavailableMarkers() { AdministrativeAuditLogger unavailableLogger = configuredLogger("", "v9"); @@ -161,6 +180,7 @@ void usesExplicitAbsentAndUnavailableMarkers() { private static String fieldValue(String message, String fieldName) { String prefix = fieldName + "="; int start = message.indexOf(prefix); + assertTrue(start >= 0, "missing field " + fieldName + " in: " + message); int end = message.indexOf(' ', start); return message.substring(start + prefix.length(), end < 0 ? message.length() : end); } @@ -174,7 +194,7 @@ private static AdministrativeAuditLogger configuredLogger(String secret, String private static CapturingAppender attachAppender() { Logger logger = (Logger) LogManager.getLogger(AdministrativeAuditLogger.class); - CapturingAppender appender = new CapturingAppender(logger); + CapturingAppender appender = new CapturingAppender(logger, logger.getLevel()); appender.start(); logger.addAppender(appender); logger.setLevel(Level.INFO); @@ -184,9 +204,10 @@ private static CapturingAppender attachAppender() { private static final class CapturingAppender extends AbstractAppender { private final Logger logger; + private final Level previousLevel; private final List messages = new ArrayList<>(); - private CapturingAppender(Logger logger) { + private CapturingAppender(Logger logger, Level previousLevel) { super( "administrative-audit-test-appender", null, @@ -195,6 +216,7 @@ private CapturingAppender(Logger logger) { null ); this.logger = logger; + this.previousLevel = previousLevel; } @Override @@ -213,6 +235,7 @@ private List messages() { private void closeAndDetach() { logger.removeAppender(this); + logger.setLevel(previousLevel); stop(); } }