This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new dcf2258749 [#11170] fix(audit): Fix file rotation, client IP capture,
and log format (#11191)
dcf2258749 is described below
commit dcf2258749bdcdb7820b17e46906a962e87b6a2e
Author: Jerry Shao <[email protected]>
AuthorDate: Mon May 25 14:30:07 2026 +0800
[#11170] fix(audit): Fix file rotation, client IP capture, and log format
(#11191)
## What changes were proposed in this pull request?
Fixes three audit log bugs from #11170:
**Problem 1 — FileAuditWriter has no file rotation, compression, or
retention**
- Rewrite `FileAuditWriter` to delegate all file management to Log4j2
via a
dedicated SLF4J logger named `gravitino.audit`
- Add `audit_file` rolling appender group to
`conf/log4j2.properties.template`:
256 MB or daily rotation, gzip compression, 30-day retention
- Deprecated writer properties (`fileName`, `append`,
`flushIntervalSecs`) now
emit a `WARN` log at startup and are otherwise ignored
- Update `gravitino-server-config.md` with migration guidance
**Problem 3 — remoteAddress() always returns "unknown" for Gravitino
server events**
- Add `RequestContext` ThreadLocal in `core/` to hold the client IP on
the servlet thread
- Read the value at `Event` construction time (not at listener
processing time),
so async listener threads safely read a `final` field instead of
ThreadLocal
- Add `RequestContextFilter` in `server/` to set/clear the value around
each
request, honoring `X-Forwarded-For` for reverse-proxy deployments
- Apply the same `X-Forwarded-For` logic to `IcebergRequestContext`
**Problem 4 — SimpleAuditLogV2 uses second-precision timestamps and
omits customInfo**
- Replace `new SimpleDateFormat(...)` per call with a static thread-safe
`DateTimeFormatter` at millisecond precision (`yyyy-MM-dd HH:mm:ss.SSS`)
- Append `customInfo()` as an 8th tab-separated field (empty string when
absent)
## How was this patch tested?
- `TestRequestContext` — ThreadLocal set/get/clear/thread-isolation
- `TestEventRemoteAddress` — value captured at construction time,
survives ThreadLocal clear
- `TestRequestContextFilter` — X-Forwarded-For (single/multi-entry),
fallback to `getRemoteAddr()`, cleanup in finally including on exception
- `TestSimpleAuditLogV2` — millisecond precision, 8-field output,
customInfo presence/absence
- `TestFileAuditWriter` — doWrite publishes to audit logger, deprecated
keys emit WARN, clean properties emit no WARN, close is no-op
- `TestAuditManager` — default writer/formatter types, batch dispatch
smoke test
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
conf/log4j2.properties.template | 29 ++++
.../apache/gravitino/audit/FileAuditWriter.java | 92 +++-------
.../gravitino/audit/v2/SimpleAuditLogV2.java | 16 +-
.../apache/gravitino/listener/api/event/Event.java | 25 ++-
.../org/apache/gravitino/utils/RequestContext.java | 62 +++++++
.../apache/gravitino/audit/TestAuditManager.java | 132 ++-------------
.../gravitino/audit/TestFileAuditWriter.java | 187 +++++++++++++++++++++
.../gravitino/audit/v2/TestSimpleAuditLogV2.java | 103 ++++++++++++
.../listener/api/event/TestEventRemoteAddress.java | 89 ++++++++++
.../apache/gravitino/utils/TestRequestContext.java | 70 ++++++++
docs/gravitino-server-config.md | 46 +++--
.../listener/api/event/IcebergRequestContext.java | 14 +-
.../gravitino/server/web/RequestContextFilter.java | 75 +++++++++
.../server/web/TestRequestContextFilter.java | 120 +++++++++++++
.../apache/gravitino/server/GravitinoServer.java | 2 +
15 files changed, 862 insertions(+), 200 deletions(-)
diff --git a/conf/log4j2.properties.template b/conf/log4j2.properties.template
index f0579d1265..ff27eb3e0b 100644
--- a/conf/log4j2.properties.template
+++ b/conf/log4j2.properties.template
@@ -72,6 +72,35 @@ logger.lineage.level = info
logger.lineage.appenderRef.lineage_file.ref = lineage_file
logger.lineage.additivity = false
+## use separate file for audit log
+appender.audit_file.type = RollingFile
+appender.audit_file.name = audit_file
+appender.audit_file.fileName = ${basePath}/gravitino_audit.log
+appender.audit_file.filePattern =
${basePath}/gravitino_audit_%d{yyyyMMdd}.%i.log.gz
+appender.audit_file.layout.type = PatternLayout
+appender.audit_file.layout.pattern = %msg%n
+appender.audit_file.policies.type = Policies
+appender.audit_file.policies.size.type = SizeBasedTriggeringPolicy
+appender.audit_file.policies.size.size = 256MB
+appender.audit_file.policies.time.type = TimeBasedTriggeringPolicy
+appender.audit_file.policies.time.interval = 1
+appender.audit_file.policies.time.modulate = true
+appender.audit_file.strategy.type = DefaultRolloverStrategy
+appender.audit_file.strategy.delete.type = Delete
+appender.audit_file.strategy.delete.basePath = ${basePath}
+appender.audit_file.strategy.delete.maxDepth = 10
+appender.audit_file.strategy.delete.ifAll.type = IfAll
+appender.audit_file.strategy.delete.ifAll.ifFileName.type = IfFileName
+appender.audit_file.strategy.delete.ifAll.ifFileName.regex =
gravitino_audit.*\.log\.gz
+# Delete all audit log files older than 30 days
+appender.audit_file.strategy.delete.ifAll.ifLastModified.type = IfLastModified
+appender.audit_file.strategy.delete.ifAll.ifLastModified.age = 30d
+
+logger.audit.name = gravitino.audit
+logger.audit.level = info
+logger.audit.appenderRef.audit_file.ref = audit_file
+logger.audit.additivity = false
+
logger.rest.name = org.apache.gravitino.server.web.rest
logger.rest.level = warn
logger.rest.appenderRef.rolling.ref = fileLogger
diff --git a/core/src/main/java/org/apache/gravitino/audit/FileAuditWriter.java
b/core/src/main/java/org/apache/gravitino/audit/FileAuditWriter.java
index a8cefea807..e1125a0049 100644
--- a/core/src/main/java/org/apache/gravitino/audit/FileAuditWriter.java
+++ b/core/src/main/java/org/apache/gravitino/audit/FileAuditWriter.java
@@ -19,37 +19,34 @@
package org.apache.gravitino.audit;
-import com.google.common.annotations.VisibleForTesting;
-import java.io.FileOutputStream;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.Writer;
-import java.nio.charset.StandardCharsets;
-import java.time.Instant;
+import com.google.common.collect.ImmutableMap;
import java.util.Map;
-import org.apache.gravitino.exceptions.GravitinoRuntimeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * DefaultFileAuditWriter is the default implementation of AuditLogWriter,
which writes audit logs
- * to a file.
+ * FileAuditWriter is the default implementation of {@link AuditLogWriter}. It
delegates all file
+ * management (rotation, compression, retention) to Log4j2 via a dedicated
logger named {@code
+ * gravitino.audit}. Configure the appender in {@code conf/log4j2.properties}.
*/
public class FileAuditWriter implements AuditLogWriter {
- private static final Logger Log =
LoggerFactory.getLogger(FileAuditWriter.class);
- private static final String AUDIT_LOG_FILE_NAME = "fileName";
- private static final String APPEND = "append";
- private static final String FLUSH_INTERVAL_SECS = "flushIntervalSecs";
- private static final String LINE_SEPARATOR = System.lineSeparator();
+ private static final Logger LOG =
LoggerFactory.getLogger(FileAuditWriter.class);
- @VisibleForTesting Writer outWriter;
- @VisibleForTesting String fileName;
+ /** Logger name that must match the {@code logger.audit.name} entry in
log4j2.properties. */
+ static final String AUDIT_LOGGER_NAME = "gravitino.audit";
+
+ private static final Map<String, String> DEPRECATED_KEYS =
+ ImmutableMap.of(
+ "fileName",
+ "configure appender.audit_file.fileName in log4j2.properties",
+ "append",
+ "configure appender.audit_file.append in log4j2.properties",
+ "flushIntervalSecs",
+ "configure immediateFlush or an async appender in
log4j2.properties");
private Formatter formatter;
- private boolean append;
- private int flushIntervalSecs;
- private Instant nextFlushTime = Instant.now();
+ private Logger auditLogger;
@Override
public Formatter getFormatter() {
@@ -59,63 +56,28 @@ public class FileAuditWriter implements AuditLogWriter {
@Override
public void init(Formatter formatter, Map<String, String> properties) {
this.formatter = formatter;
- this.fileName =
- System.getProperty("gravitino.log.path")
- + "/"
- + properties.getOrDefault(AUDIT_LOG_FILE_NAME,
"gravitino_audit.log");
- this.append = Boolean.parseBoolean(properties.getOrDefault(APPEND,
"true"));
- this.flushIntervalSecs =
Integer.parseInt(properties.getOrDefault(FLUSH_INTERVAL_SECS, "10"));
- try {
- OutputStream outputStream = new FileOutputStream(fileName, append);
- this.outWriter = new OutputStreamWriter(outputStream,
StandardCharsets.UTF_8);
- } catch (Exception e) {
- throw new GravitinoRuntimeException(
- e, "Init audit log writer fail, filename is %s", fileName);
- }
+ DEPRECATED_KEYS.forEach(
+ (key, hint) -> {
+ if (properties.containsKey(key)) {
+ LOG.warn(
+ "FileAuditWriter config key '{}' is deprecated and has no
effect. {}", key, hint);
+ }
+ });
+ this.auditLogger = LoggerFactory.getLogger(AUDIT_LOGGER_NAME);
}
@Override
public void doWrite(AuditLog auditLog) {
- String log = auditLog.toString();
- try {
- outWriter.write(log + LINE_SEPARATOR);
- tryFlush();
- } catch (Exception e) {
- Log.warn("Failed to write audit log: {}", log, e);
- }
+ auditLogger.info(auditLog.toString());
}
@Override
public void close() {
- if (outWriter != null) {
- try {
- outWriter.close();
- } catch (Exception e) {
- Log.warn("Failed to close writer", e);
- }
- }
+ // Log4j2 manages the appender lifecycle; nothing to close here.
}
@Override
public String name() {
return "file";
}
-
- private void tryFlush() {
- Instant now = Instant.now();
- if (now.isAfter(nextFlushTime)) {
- nextFlushTime = now.plusSeconds(flushIntervalSecs);
- doFlush();
- }
- }
-
- private void doFlush() {
- if (outWriter != null) {
- try {
- outWriter.flush();
- } catch (Exception e) {
- Log.warn("Flush audit log failed,", e);
- }
- }
- }
}
diff --git
a/core/src/main/java/org/apache/gravitino/audit/v2/SimpleAuditLogV2.java
b/core/src/main/java/org/apache/gravitino/audit/v2/SimpleAuditLogV2.java
index 177b034154..104e91a864 100644
--- a/core/src/main/java/org/apache/gravitino/audit/v2/SimpleAuditLogV2.java
+++ b/core/src/main/java/org/apache/gravitino/audit/v2/SimpleAuditLogV2.java
@@ -19,7 +19,9 @@
package org.apache.gravitino.audit.v2;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.Optional;
import org.apache.gravitino.NameIdentifier;
@@ -35,6 +37,9 @@ import org.apache.gravitino.listener.api.event.OperationType;
*/
public class SimpleAuditLogV2 implements AuditLog {
+ private static final DateTimeFormatter TIMESTAMP_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd
HH:mm:ss.SSS").withZone(ZoneId.systemDefault());
+
private final BaseEvent event;
public SimpleAuditLogV2(BaseEvent event) {
@@ -95,14 +100,17 @@ public class SimpleAuditLogV2 implements AuditLog {
@Override
public String toString() {
+ Map<String, String> info = customInfo();
+ String customInfoStr = info != null && !info.isEmpty() ? info.toString() :
"";
return String.format(
- "[%s]\t%s\t%s\t%s\t%s\t%s\t%s",
- new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(timestamp()),
+ "[%s]\t%s\t%s\t%s\t%s\t%s\t%s\t%s",
+ TIMESTAMP_FORMATTER.format(Instant.ofEpochMilli(timestamp())),
user(),
operationType(),
identifier(),
operationStatus(),
eventSource(),
- remoteAddress());
+ remoteAddress(),
+ customInfoStr);
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/Event.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/Event.java
index 7dba616d42..cb2b0829b7 100644
--- a/core/src/main/java/org/apache/gravitino/listener/api/event/Event.java
+++ b/core/src/main/java/org/apache/gravitino/listener/api/event/Event.java
@@ -19,13 +19,36 @@
package org.apache.gravitino.listener.api.event;
+import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.utils.RequestContext;
-/** Represents a post event. */
+/**
+ * Represents a post event for Gravitino server operations.
+ *
+ * <p>The client remote address is captured from {@link RequestContext} at
construction time on the
+ * servlet thread, so async listener threads can safely call {@link
#remoteAddress()} without
+ * accessing thread-local storage.
+ */
@DeveloperApi
public abstract class Event extends BaseEvent {
+
+ private final String remoteAddress;
+
protected Event(String user, NameIdentifier identifier) {
super(user, identifier);
+ String addr = RequestContext.getRemoteAddress();
+ this.remoteAddress = StringUtils.isNoneBlank(addr) ? addr : "unknown";
+ }
+
+ /**
+ * Returns the client remote address captured at event construction time.
+ *
+ * @return the client IP address, or {@code "unknown"} if the request
context was not set.
+ */
+ @Override
+ public String remoteAddress() {
+ return remoteAddress;
}
}
diff --git a/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
new file mode 100644
index 0000000000..9b37bdb942
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.utils;
+
+/**
+ * Holds per-request context data (e.g. client IP) in a {@link ThreadLocal} so
that event classes
+ * constructed on the servlet thread can capture it without carrying a servlet
dependency.
+ *
+ * <p><b>Threading contract:</b> values must be set and cleared on the same
(servlet) thread. Event
+ * constructors read the value at construction time and store it as a field,
so async listener
+ * threads never access this class.
+ */
+public class RequestContext {
+
+ private static final ThreadLocal<String> REMOTE_ADDRESS = new
ThreadLocal<>();
+
+ private RequestContext() {}
+
+ /**
+ * Sets the client remote address for the current request thread.
+ *
+ * @param remoteAddress the client IP address (or the first entry of {@code
X-Forwarded-For}).
+ */
+ public static void setRemoteAddress(String remoteAddress) {
+ REMOTE_ADDRESS.set(remoteAddress);
+ }
+
+ /**
+ * Returns the client remote address previously set on this thread, or
{@code null} if none was
+ * set.
+ *
+ * @return the client remote address, or {@code null}.
+ */
+ public static String getRemoteAddress() {
+ return REMOTE_ADDRESS.get();
+ }
+
+ /**
+ * Removes the remote address binding from the current thread. Must be
called in a {@code finally}
+ * block after the request completes to prevent thread-pool leaks.
+ */
+ public static void clear() {
+ REMOTE_ADDRESS.remove();
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java
b/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java
index d4055ce35d..21ec1ca1c6 100644
--- a/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java
+++ b/core/src/test/java/org/apache/gravitino/audit/TestAuditManager.java
@@ -22,66 +22,24 @@ package org.apache.gravitino.audit;
import static org.apache.gravitino.audit.AuditLog.Operation;
import static org.apache.gravitino.audit.AuditLog.Status;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
import java.util.HashMap;
-import java.util.stream.Stream;
import org.apache.gravitino.Config;
import org.apache.gravitino.Configs;
import org.apache.gravitino.NameIdentifier;
-import org.apache.gravitino.audit.v2.SimpleAuditLogV2;
import org.apache.gravitino.audit.v2.SimpleFormatterV2;
-import org.apache.gravitino.config.ConfigBuilder;
import org.apache.gravitino.listener.EventBus;
import org.apache.gravitino.listener.EventListenerManager;
import org.apache.gravitino.listener.api.event.Event;
import org.apache.gravitino.listener.api.event.FailureEvent;
-import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class TestAuditManager {
- private static final Logger LOG =
LoggerFactory.getLogger(TestAuditManager.class);
-
- private static final String DEFAULT_FILE_NAME = "gravitino_audit.log";
private static final int EVENT_NUM = 2000;
- private Path logPath;
-
- @BeforeAll
- public void setup() {
- String logDir = System.getProperty("gravitino.log.path");
- Path logDirPath = Paths.get(logDir);
- if (!Files.exists(logDirPath)) {
- try {
- Files.createDirectories(logDirPath);
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
- this.logPath = Paths.get(logDir + "/" + DEFAULT_FILE_NAME);
- if (Files.exists(logPath)) {
- LOG.warn(
- String.format("tmp audit log file: %s already exists, delete it",
DEFAULT_FILE_NAME));
- try {
- Files.delete(logPath);
- LOG.warn(String.format("delete tmp audit log file: %s success",
DEFAULT_FILE_NAME));
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
- }
-
/** Test audit log with custom audit writer and formatter. */
@Test
public void testAuditLog() {
@@ -109,10 +67,10 @@ public class TestAuditManager {
DummyAuditLog formattedAuditLog = formatter.format(dummyEvent);
Assertions.assertNotNull(formattedAuditLog);
- Assertions.assertEquals(formattedAuditLog.operation(),
Operation.UNKNOWN_OPERATION);
- Assertions.assertEquals(formattedAuditLog.status(), Status.SUCCESS);
- Assertions.assertEquals(formattedAuditLog.user(), "user");
- Assertions.assertEquals(formattedAuditLog.identifier(), "a.b.c.d");
+ Assertions.assertEquals(Operation.UNKNOWN_OPERATION,
formattedAuditLog.operation());
+ Assertions.assertEquals(Status.SUCCESS, formattedAuditLog.status());
+ Assertions.assertEquals("user", formattedAuditLog.user());
+ Assertions.assertEquals("a.b.c.d", formattedAuditLog.identifier());
Assertions.assertEquals(formattedAuditLog.timestamp(),
dummyEvent.eventTime());
Assertions.assertEquals(formattedAuditLog,
dummyAuditWriter.getAuditLogs().get(0));
@@ -121,97 +79,41 @@ public class TestAuditManager {
eventBus.dispatchEvent(dummyFailEvent);
DummyAuditLog formattedFailAuditLog = formatter.format(dummyFailEvent);
Assertions.assertEquals(formattedFailAuditLog,
dummyAuditWriter.getAuditLogs().get(1));
- Assertions.assertEquals(formattedFailAuditLog.operation(),
Operation.UNKNOWN_OPERATION);
- Assertions.assertEquals(formattedFailAuditLog.status(), Status.FAILURE);
+ Assertions.assertEquals(Operation.UNKNOWN_OPERATION,
formattedFailAuditLog.operation());
+ Assertions.assertEquals(Status.FAILURE, formattedFailAuditLog.status());
Assertions.assertEquals(formattedFailAuditLog,
dummyAuditWriter.getAuditLogs().get(1));
}
- /** Test audit log with default audit writer and formatter. */
- @SuppressWarnings("deprecation")
+ /** Verify that FileAuditWriter is used by default and delegates to
SimpleFormatterV2. */
@Test
public void testFileAuditLog() {
Config config = new Config(false) {};
config.set(Configs.AUDIT_LOG_ENABLED_CONF, true);
- DummyEvent dummyEvent = mockDummyEvent();
EventListenerManager eventListenerManager = mockEventListenerManager();
AuditLogManager auditLogManager = mockAuditLogManager(config,
eventListenerManager);
EventBus eventBus = eventListenerManager.createEventBus();
- eventBus.dispatchEvent(dummyEvent);
- Assertions.assertInstanceOf(FileAuditWriter.class,
auditLogManager.getAuditLogWriter());
- Assertions.assertInstanceOf(
- SimpleFormatterV2.class,
(auditLogManager.getAuditLogWriter()).getFormatter());
-
- FileAuditWriter fileAuditWriter = (FileAuditWriter)
auditLogManager.getAuditLogWriter();
- String fileName = fileAuditWriter.fileName;
- try {
- fileAuditWriter.outWriter.flush();
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- String auditLog = readAuditLog(fileName);
- Formatter formatter = fileAuditWriter.getFormatter();
- SimpleAuditLogV2 formattedAuditLog = (SimpleAuditLogV2)
formatter.format(dummyEvent);
+ // Dispatching events must not throw even though no file appender is
configured in the test.
+ eventBus.dispatchEvent(mockDummyEvent());
- Assertions.assertNotNull(formattedAuditLog);
- Assertions.assertEquals(formattedAuditLog.toString(), auditLog);
+ Assertions.assertInstanceOf(FileAuditWriter.class,
auditLogManager.getAuditLogWriter());
+ Assertions.assertInstanceOf(
+ SimpleFormatterV2.class,
auditLogManager.getAuditLogWriter().getFormatter());
}
+ /** Verify that dispatching many events does not throw or deadlock. */
@Test
- public void testBathEvents() {
+ public void testBatchEvents() {
Config config = new Config(false) {};
config.set(Configs.AUDIT_LOG_ENABLED_CONF, true);
- // set immediate flush to true for testing, so that the audit log will be
read immediately
- config.set(
- new
ConfigBuilder("gravitino.audit.writer.file.immediateFlush").stringConf(),
"true");
-
EventListenerManager eventListenerManager = mockEventListenerManager();
- AuditLogManager auditLogManager = mockAuditLogManager(config,
eventListenerManager);
+ mockAuditLogManager(config, eventListenerManager);
EventBus eventBus = eventListenerManager.createEventBus();
for (int i = 0; i < EVENT_NUM; i++) {
- DummyEvent dummyEvent = mockDummyEvent();
- eventBus.dispatchEvent(dummyEvent);
- }
-
- FileAuditWriter fileAuditWriter = (FileAuditWriter)
auditLogManager.getAuditLogWriter();
- String fileName = fileAuditWriter.fileName;
- try {
- fileAuditWriter.outWriter.flush();
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- long auditSize = getAuditSize(fileName);
- Assertions.assertEquals(EVENT_NUM, auditSize);
- }
-
- @AfterEach
- public void cleanup() {
- try {
- if (Files.exists(logPath)) {
- Files.delete(logPath);
- LOG.warn(String.format("delete tmp audit log file: %s success",
DEFAULT_FILE_NAME));
- }
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
-
- private String readAuditLog(String fileName) {
- try (BufferedReader reader =
- Files.newBufferedReader(Paths.get(fileName), StandardCharsets.UTF_8)) {
- return reader.readLine();
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
-
- private long getAuditSize(String fileName) {
- try (Stream<String> lines = Files.lines(Paths.get(fileName))) {
- return lines.count();
- } catch (IOException e) {
- throw new RuntimeException(e);
+ eventBus.dispatchEvent(mockDummyEvent());
}
+ // No assertion needed — we just verify no exception is thrown for a large
batch.
}
private AuditLogManager mockAuditLogManager(
diff --git
a/core/src/test/java/org/apache/gravitino/audit/TestFileAuditWriter.java
b/core/src/test/java/org/apache/gravitino/audit/TestFileAuditWriter.java
new file mode 100644
index 0000000000..f117c649f4
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/audit/TestFileAuditWriter.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.audit;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.audit.v2.SimpleFormatterV2;
+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.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.AbstractConfiguration;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+import org.apache.logging.log4j.core.layout.PatternLayout;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestFileAuditWriter {
+
+ /** Minimal in-memory appender for capturing log events in tests. */
+ static class CaptureAppender extends AbstractAppender {
+ private final List<LogEvent> events = new ArrayList<>();
+
+ CaptureAppender(String name) {
+ super(name, null, PatternLayout.createDefaultLayout(), true, null);
+ }
+
+ @Override
+ public void append(LogEvent event) {
+ events.add(event.toImmutable());
+ }
+
+ List<LogEvent> getEvents() {
+ return events;
+ }
+ }
+
+ private CaptureAppender auditCapture;
+ private CaptureAppender warnCapture;
+ private LoggerContext loggerContext;
+
+ @BeforeEach
+ public void setup() {
+ loggerContext =
+ (LoggerContext)
LogManager.getContext(FileAuditWriter.class.getClassLoader(), false);
+ Configuration config = loggerContext.getConfiguration();
+
+ auditCapture = new CaptureAppender("auditCapture");
+ auditCapture.start();
+ config.addAppender(auditCapture);
+
+ // Wire a dedicated logger for gravitino.audit so writes are captured in
tests.
+ LoggerConfig auditLoggerConfig =
+ new LoggerConfig(FileAuditWriter.AUDIT_LOGGER_NAME, Level.INFO, false);
+ auditLoggerConfig.addAppender(auditCapture, Level.INFO, null);
+ config.addLogger(FileAuditWriter.AUDIT_LOGGER_NAME, auditLoggerConfig);
+
+ // Capture WARN logs from FileAuditWriter itself (for deprecation warning
tests).
+ warnCapture = new CaptureAppender("warnCapture");
+ warnCapture.start();
+ config.addAppender(warnCapture);
+ String writerLoggerName = FileAuditWriter.class.getName();
+ LoggerConfig writerLoggerConfig = new LoggerConfig(writerLoggerName,
Level.WARN, false);
+ writerLoggerConfig.addAppender(warnCapture, Level.WARN, null);
+ config.addLogger(writerLoggerName, writerLoggerConfig);
+
+ loggerContext.updateLoggers();
+ }
+
+ @AfterEach
+ public void teardown() {
+ AbstractConfiguration config = (AbstractConfiguration)
loggerContext.getConfiguration();
+ config.removeLogger(FileAuditWriter.AUDIT_LOGGER_NAME);
+ config.removeLogger(FileAuditWriter.class.getName());
+ auditCapture.stop();
+ warnCapture.stop();
+ config.removeAppender(auditCapture.getName());
+ config.removeAppender(warnCapture.getName());
+ loggerContext.updateLoggers();
+ }
+
+ @Test
+ public void testDoWritePublishesToAuditLogger() {
+ FileAuditWriter writer = new FileAuditWriter();
+ writer.init(new SimpleFormatterV2(), new HashMap<>());
+
+ AuditLog log = new DummyAuditLog();
+ writer.doWrite(log);
+
+ Assertions.assertEquals(1, auditCapture.getEvents().size());
+ Assertions.assertEquals(
+ log.toString(),
auditCapture.getEvents().get(0).getMessage().getFormattedMessage());
+ }
+
+ @Test
+ public void testDeprecatedKeysProduceWarnLogs() {
+ FileAuditWriter writer = new FileAuditWriter();
+ Map<String, String> properties = new HashMap<>();
+ properties.put("fileName", "old-path.log");
+ properties.put("append", "true");
+ properties.put("flushIntervalSecs", "5");
+ writer.init(new SimpleFormatterV2(), properties);
+
+ List<LogEvent> warns = warnCapture.getEvents();
+ Assertions.assertEquals(3, warns.size(), "Expected one warning per
deprecated key");
+
+ List<String> messages = new ArrayList<>();
+ warns.forEach(e -> messages.add(e.getMessage().getFormattedMessage()));
+ Assertions.assertTrue(messages.stream().anyMatch(m ->
m.contains("fileName")));
+ Assertions.assertTrue(messages.stream().anyMatch(m ->
m.contains("append")));
+ Assertions.assertTrue(messages.stream().anyMatch(m ->
m.contains("flushIntervalSecs")));
+ }
+
+ @Test
+ public void testNoWarnForCleanProperties() {
+ FileAuditWriter writer = new FileAuditWriter();
+ writer.init(new SimpleFormatterV2(), new HashMap<>());
+ Assertions.assertTrue(warnCapture.getEvents().isEmpty());
+ }
+
+ @Test
+ public void testCloseIsNoOp() {
+ FileAuditWriter writer = new FileAuditWriter();
+ writer.init(new SimpleFormatterV2(), new HashMap<>());
+ // Must not throw.
+ Assertions.assertDoesNotThrow(writer::close);
+ }
+
+ // ---- helpers ----
+
+ static class DummyAuditLog implements AuditLog {
+ @Override
+ public String user() {
+ return "test-user";
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public AuditLog.Operation operation() {
+ return AuditLog.Operation.UNKNOWN_OPERATION;
+ }
+
+ @Override
+ public String identifier() {
+ return "metalake.catalog";
+ }
+
+ @Override
+ public long timestamp() {
+ return System.currentTimeMillis();
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public AuditLog.Status status() {
+ return AuditLog.Status.SUCCESS;
+ }
+
+ @Override
+ public String toString() {
+ return "dummy-audit-log";
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/audit/v2/TestSimpleAuditLogV2.java
b/core/src/test/java/org/apache/gravitino/audit/v2/TestSimpleAuditLogV2.java
new file mode 100644
index 0000000000..a96a4de2a3
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/audit/v2/TestSimpleAuditLogV2.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.audit.v2;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.listener.api.event.Event;
+import org.apache.gravitino.listener.api.event.EventSource;
+import org.apache.gravitino.listener.api.event.OperationStatus;
+import org.apache.gravitino.listener.api.event.OperationType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestSimpleAuditLogV2 {
+
+ @Test
+ public void testTimestampHasMillisecondPrecision() {
+ SimpleAuditLogV2 log = new SimpleAuditLogV2(new StubEvent());
+ String output = log.toString();
+ // Format is [yyyy-MM-dd HH:mm:ss.SSS] — the dot-separated millis must be
present.
+ Assertions.assertTrue(
+ output.matches("\\[\\d{4}-\\d{2}-\\d{2}
\\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\].*"),
+ "Expected millisecond precision in: " + output);
+ }
+
+ @Test
+ public void testCustomInfoAppendedWhenPresent() {
+ SimpleAuditLogV2 log = new SimpleAuditLogV2(new StubEventWithCustomInfo());
+ String output = log.toString();
+ String[] fields = output.split("\t", -1);
+ // 8 tab-separated fields expected: timestamp, user, opType, id, status,
source, addr, custom
+ Assertions.assertEquals(8, fields.length, "Expected 8 tab-separated
fields, got: " + output);
+ Assertions.assertTrue(
+ fields[7].contains("k1"), "Last field should contain customInfo key: "
+ fields[7]);
+ }
+
+ @Test
+ public void testCustomInfoEmptyWhenAbsent() {
+ SimpleAuditLogV2 log = new SimpleAuditLogV2(new StubEvent());
+ String output = log.toString();
+ String[] fields = output.split("\t", -1);
+ Assertions.assertEquals(8, fields.length);
+ Assertions.assertEquals("", fields[7], "Last field should be empty when
customInfo is absent");
+ }
+
+ @Test
+ public void testOutputContainsAllCoreFields() {
+ SimpleAuditLogV2 log = new SimpleAuditLogV2(new StubEvent());
+ String output = log.toString();
+ Assertions.assertTrue(output.contains("test-user"));
+ Assertions.assertTrue(output.contains("LIST_TABLE"));
+ Assertions.assertTrue(output.contains("metalake.catalog"));
+ Assertions.assertTrue(output.contains("SUCCESS"));
+ }
+
+ // ---- stubs ----
+
+ static class StubEvent extends Event {
+ StubEvent() {
+ super("test-user", NameIdentifier.of("metalake", "catalog"));
+ }
+
+ @Override
+ public OperationType operationType() {
+ return OperationType.LIST_TABLE;
+ }
+
+ @Override
+ public OperationStatus operationStatus() {
+ return OperationStatus.SUCCESS;
+ }
+
+ @Override
+ public EventSource eventSource() {
+ return EventSource.GRAVITINO_SERVER;
+ }
+ }
+
+ static class StubEventWithCustomInfo extends StubEvent {
+ @Override
+ public Map<String, String> customInfo() {
+ return ImmutableMap.of("k1", "v1");
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestEventRemoteAddress.java
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestEventRemoteAddress.java
new file mode 100644
index 0000000000..5ff669bb18
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestEventRemoteAddress.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.utils.RequestContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestEventRemoteAddress {
+
+ @AfterEach
+ public void cleanup() {
+ RequestContext.clear();
+ }
+
+ @Test
+ public void testRemoteAddressCapturedAtConstructionTime() {
+ RequestContext.setRemoteAddress("10.0.0.1");
+ Event event = new StubEvent();
+ // Clear the ThreadLocal after construction to simulate what the servlet
filter does.
+ RequestContext.clear();
+
+ Assertions.assertEquals(
+ "10.0.0.1",
+ event.remoteAddress(),
+ "remoteAddress() must return the value captured at construction, not
from ThreadLocal");
+ }
+
+ @Test
+ public void testRemoteAddressDefaultsToUnknownWhenNotSet() {
+ // No RequestContext set — must fall back to "unknown".
+ Event event = new StubEvent();
+ Assertions.assertEquals("unknown", event.remoteAddress());
+ }
+
+ @Test
+ public void testRemoteAddressIsolatedAcrossInstances() {
+ RequestContext.setRemoteAddress("1.2.3.4");
+ Event first = new StubEvent();
+
+ RequestContext.setRemoteAddress("5.6.7.8");
+ Event second = new StubEvent();
+
+ Assertions.assertEquals("1.2.3.4", first.remoteAddress());
+ Assertions.assertEquals("5.6.7.8", second.remoteAddress());
+ }
+
+ // ---- stub ----
+
+ static class StubEvent extends Event {
+ StubEvent() {
+ super("test-user", NameIdentifier.of("metalake", "catalog"));
+ }
+
+ @Override
+ public OperationType operationType() {
+ return OperationType.LIST_TABLE;
+ }
+
+ @Override
+ public OperationStatus operationStatus() {
+ return OperationStatus.SUCCESS;
+ }
+
+ @Override
+ public EventSource eventSource() {
+ return EventSource.GRAVITINO_SERVER;
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
new file mode 100644
index 0000000000..5c70284446
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.utils;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestRequestContext {
+
+ @AfterEach
+ public void cleanup() {
+ RequestContext.clear();
+ }
+
+ @Test
+ public void testSetAndGet() {
+ RequestContext.setRemoteAddress("192.168.1.1");
+ Assertions.assertEquals("192.168.1.1", RequestContext.getRemoteAddress());
+ }
+
+ @Test
+ public void testClearRemovesValue() {
+ RequestContext.setRemoteAddress("10.0.0.1");
+ RequestContext.clear();
+ Assertions.assertNull(RequestContext.getRemoteAddress());
+ }
+
+ @Test
+ public void testGetReturnsNullWhenNotSet() {
+ Assertions.assertNull(RequestContext.getRemoteAddress());
+ }
+
+ @Test
+ public void testThreadIsolation() throws InterruptedException {
+ RequestContext.setRemoteAddress("main-thread-ip");
+ AtomicReference<String> childValue = new AtomicReference<>();
+
+ Thread child =
+ new Thread(
+ () -> {
+ // Child thread has its own ThreadLocal slot — must not see the
main-thread value.
+ childValue.set(RequestContext.getRemoteAddress());
+ });
+ child.start();
+ child.join();
+
+ Assertions.assertNull(childValue.get(), "Child thread should not inherit
parent ThreadLocal");
+ Assertions.assertEquals(
+ "main-thread-ip", RequestContext.getRemoteAddress(), "Main thread
value unchanged");
+ }
+}
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 65d549d0a5..cf6501fc43 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -245,29 +245,47 @@ When processing pre-event, you could throw a
`ForbiddenException` to skip the fo
The audit log framework defines how audit logs are formatted and written to
various storages. The formatter defines an interface that transforms different
`Event` types into a unified `AuditLog`. The writer defines an interface to
writing AuditLog to different storages.
-Gravitino provides a default implement to log basic audit information to a
file, you could extend the audit system by implementation corresponding
interfaces.
+Gravitino provides a default implementation to log basic audit information to
a file. You can extend the audit system by implementing the corresponding
interfaces.
-| Property name | Description
| Default value | Required | Since Version
|
-|---------------------------------------|----------------------------------------|---------------------------------------------|----------|----------------------------|
-| `gravitino.audit.enabled` | The audit log enable flag.
| false | NO | 0.7.0-incubating
|
-| `gravitino.audit.writer.className` | The class name of audit log writer.
| org.apache.gravitino.audit.FileAuditWriter | NO | 0.7.0-incubating
|
-| `gravitino.audit.formatter.className` | The class name of audit log
formatter. | org.apache.gravitino.audit.SimpleFormatter | NO |
0.7.0-incubating |
+| Property name | Description
| Default value | Required | Since Version
|
+|---------------------------------------|----------------------------------------|------------------------------------------------|----------|------------------|
+| `gravitino.audit.enabled` | The audit log enable flag.
| false | NO |
0.7.0-incubating |
+| `gravitino.audit.writer.className` | The class name of audit log writer.
| org.apache.gravitino.audit.FileAuditWriter | NO |
0.7.0-incubating |
+| `gravitino.audit.formatter.className` | The class name of audit log
formatter. | org.apache.gravitino.audit.v2.SimpleFormatterV2 | NO |
0.7.0-incubating |
#### Audit log formatter
-The Formatter defines an interface that formats metadata audit logs into a
unified format. `SimpleFormatter` is a default implement to format audit
information, you don't need to do extra configurations.
+The `Formatter` interface transforms an `Event` into an `AuditLog`.
`SimpleFormatterV2` is the default implementation and requires no extra
configuration. It produces a tab-separated line with the following fields:
timestamp, user, operation type, identifier, operation status, event source,
remote address, and custom info.
#### Audit log writer
-The `AuditLogWriter` defines an interface that enables the writing of metadata
audit logs to different storage mediums such as files, databases, etc.
+The `AuditLogWriter` interface enables writing audit logs to different storage
mediums (files, databases, etc.).
-Writer configuration begins with `gravitino.audit.writer.${name}`, where
`${name}` is replaced with the actual writer name defined in method `name()`.
`FileAuditWriter` is a default implement to log audit information, whose name
is `file`.
+`FileAuditWriter` is the default implementation. It delegates all file
management — rotation, compression, and retention — to Log4j2 via a dedicated
logger named `gravitino.audit`. The appender is configured in
`conf/log4j2.properties` (see the `audit_file` appender group). The default
configuration rotates daily and on 256 MB, compresses rotated files with gzip,
and deletes files older than 30 days.
-| Property name | Description
| Default value |
Required | Since Version |
-|-------------------------------------------------|-------------------------------------------------------------------------------|---------------------|----------|------------------|
-| `gravitino.audit.writer.file.fileName` | The audit log file name,
the path is `${sys:gravitino.log.path}/${fileName}`. | gravitino_audit.log | NO
| 0.7.0-incubating |
-| `gravitino.audit.writer.file.flushIntervalSecs` | The flush interval time of
the audit file in seconds. | 10 | NO
| 0.7.0-incubating |
-| `gravitino.audit.writer.file.append` | Whether the log will be
written to the end or the beginning of the file. | true |
NO | 0.7.0-incubating |
+##### Deprecated FileAuditWriter properties
+
+The following `gravitino.audit.writer.file.*` properties were accepted in
earlier versions but are now **deprecated** and have no effect.
`FileAuditWriter` emits a `WARN` log at startup if any of them are present.
Configure the equivalent behavior directly in `conf/log4j2.properties` instead.
+
+| Deprecated property | Migration: configure in
`conf/log4j2.properties` |
+|-------------------------------------------------|--------------------------------------------------------------------|
+| `gravitino.audit.writer.file.fileName` |
`appender.audit_file.fileName` |
+| `gravitino.audit.writer.file.append` |
`appender.audit_file.append` |
+| `gravitino.audit.writer.file.flushIntervalSecs` | Use `immediateFlush` on
the appender or an async appender wrapper |
+
+Example — change the audit log path:
+
+```properties
+# conf/log4j2.properties
+appender.audit_file.fileName = /var/log/gravitino/my_audit.log
+appender.audit_file.filePattern =
/var/log/gravitino/my_audit_%d{yyyyMMdd}.%i.log.gz
+```
+
+Example — adjust retention to 90 days:
+
+```properties
+appender.audit_file.strategy.delete.ifLastModified.age = 90d
+```
### Security configuration
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
index c6cf52661c..4881257805 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergRequestContext.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.listener.api.event;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import org.apache.gravitino.utils.PrincipalUtils;
@@ -58,13 +59,24 @@ public class IcebergRequestContext {
public IcebergRequestContext(
HttpServletRequest httpRequest, String catalogName, boolean
requestCredentialVending) {
this.httpServletRequest = httpRequest;
- this.remoteHostName = httpRequest.getRemoteHost();
+ this.remoteHostName = resolveClientAddress(httpRequest);
this.httpHeaders = IcebergRESTUtils.getHttpHeaders(httpRequest);
this.catalogName = catalogName;
this.userName = PrincipalUtils.getCurrentUserName();
this.requestCredentialVending = requestCredentialVending;
}
+ private static String resolveClientAddress(HttpServletRequest request) {
+ // X-Forwarded-For is trusted unconditionally; callers in environments
where the server is
+ // reachable directly (not only via a trusted proxy) should be aware that
this header can be
+ // spoofed by clients.
+ String xForwardedFor = request.getHeader("X-Forwarded-For");
+ if (StringUtils.isNotBlank(xForwardedFor)) {
+ return xForwardedFor.split(",")[0].trim();
+ }
+ return request.getRemoteHost();
+ }
+
/**
* Returns the catalog name.
*
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/RequestContextFilter.java
b/server-common/src/main/java/org/apache/gravitino/server/web/RequestContextFilter.java
new file mode 100644
index 0000000000..f5de5185bf
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/RequestContextFilter.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.server.web;
+
+import java.io.IOException;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.utils.RequestContext;
+
+/**
+ * A servlet filter that captures the client remote address from each HTTP
request and stores it in
+ * {@link RequestContext} so that audit event constructors can read it on the
same thread.
+ *
+ * <p>When a reverse proxy is in use, the real client IP is taken from the
first entry of the {@code
+ * X-Forwarded-For} header (a de-facto standard header set by reverse proxies;
note that it is
+ * trusted unconditionally — deployments where the server is reachable
directly, without a trusted
+ * reverse proxy, should be aware that clients can spoof this header). If the
header is absent,
+ * {@link HttpServletRequest#getRemoteAddr()} is used instead.
+ *
+ * <p>The stored value is always cleared in a {@code finally} block to prevent
thread-pool leaks.
+ */
+public class RequestContextFilter implements Filter {
+
+ private static final String X_FORWARDED_FOR = "X-Forwarded-For";
+
+ @Override
+ public void init(FilterConfig filterConfig) {}
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
+ throws IOException, ServletException {
+ try {
+ if (request instanceof HttpServletRequest) {
+
RequestContext.setRemoteAddress(resolveClientAddress((HttpServletRequest)
request));
+ }
+ chain.doFilter(request, response);
+ } finally {
+ RequestContext.clear();
+ }
+ }
+
+ @Override
+ public void destroy() {}
+
+ private String resolveClientAddress(HttpServletRequest request) {
+ String xForwardedFor = request.getHeader(X_FORWARDED_FOR);
+ if (StringUtils.isNotBlank(xForwardedFor)) {
+ return xForwardedFor.split(",")[0].trim();
+ }
+ return request.getRemoteAddr();
+ }
+}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/TestRequestContextFilter.java
b/server-common/src/test/java/org/apache/gravitino/server/web/TestRequestContextFilter.java
new file mode 100644
index 0000000000..1b69f03ac4
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/TestRequestContextFilter.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.server.web;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.utils.RequestContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestRequestContextFilter {
+
+ private final RequestContextFilter filter = new RequestContextFilter();
+
+ @AfterEach
+ public void cleanup() {
+ RequestContext.clear();
+ }
+
+ @Test
+ public void testSetsRemoteAddrFromRequest() throws IOException,
ServletException {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(req.getHeader("X-Forwarded-For")).thenReturn(null);
+ when(req.getRemoteAddr()).thenReturn("192.168.1.1");
+
+ AtomicReference<String> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRemoteAddress());
+
+ filter.doFilter(req, resp, chain);
+
+ Assertions.assertEquals("192.168.1.1", captured.get());
+ }
+
+ @Test
+ public void testXForwardedForSingleEntry() throws IOException,
ServletException {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(req.getHeader("X-Forwarded-For")).thenReturn("203.0.113.5");
+ when(req.getRemoteAddr()).thenReturn("10.0.0.1");
+
+ AtomicReference<String> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRemoteAddress());
+
+ filter.doFilter(req, resp, chain);
+
+ Assertions.assertEquals("203.0.113.5", captured.get());
+ }
+
+ @Test
+ public void testXForwardedForMultipleEntriesUsesFirst() throws IOException,
ServletException {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(req.getHeader("X-Forwarded-For")).thenReturn("203.0.113.5, 10.1.1.1,
10.2.2.2");
+ when(req.getRemoteAddr()).thenReturn("10.0.0.1");
+
+ AtomicReference<String> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRemoteAddress());
+
+ filter.doFilter(req, resp, chain);
+
+ Assertions.assertEquals("203.0.113.5", captured.get());
+ }
+
+ @Test
+ public void testThreadLocalClearedAfterChain() throws IOException,
ServletException {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(req.getHeader("X-Forwarded-For")).thenReturn(null);
+ when(req.getRemoteAddr()).thenReturn("1.2.3.4");
+
+ filter.doFilter(req, resp, (request, response) -> {});
+
+ Assertions.assertNull(
+ RequestContext.getRemoteAddress(), "ThreadLocal must be cleared after
chain completes");
+ }
+
+ @Test
+ public void testThreadLocalClearedEvenOnChainException() throws IOException,
ServletException {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(req.getHeader("X-Forwarded-For")).thenReturn(null);
+ when(req.getRemoteAddr()).thenReturn("1.2.3.4");
+
+ FilterChain throwingChain =
+ (request, response) -> {
+ throw new ServletException("simulated error");
+ };
+
+ Assertions.assertThrows(
+ ServletException.class, () -> filter.doFilter(req, resp,
throwingChain));
+ Assertions.assertNull(
+ RequestContext.getRemoteAddress(), "ThreadLocal must be cleared even
when chain throws");
+ }
+}
diff --git
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index 91630cfe5d..2b2f424c5a 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -53,6 +53,7 @@ import
org.apache.gravitino.server.web.HttpServerMetricsSource;
import org.apache.gravitino.server.web.JettyServer;
import org.apache.gravitino.server.web.JettyServerConfig;
import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.apache.gravitino.server.web.RequestContextFilter;
import org.apache.gravitino.server.web.VersioningFilter;
import org.apache.gravitino.server.web.filter.AccessControlNotAllowedFilter;
import org.apache.gravitino.server.web.filter.GravitinoInterceptionService;
@@ -186,6 +187,7 @@ public class GravitinoServer extends ResourceConfig {
server.addServlet(new HealthAliasServlet(), "/health.html");
server.addCustomFilters(API_ANY_PATH);
+ server.addFilter(new RequestContextFilter(), API_ANY_PATH);
server.addFilter(new VersioningFilter(), API_ANY_PATH);
server.addSystemFilters(API_ANY_PATH);
if (server.isWebUiEnabled()) {