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 15259af5d7 [#12872] fix(core): Capture and redact request query
parameters in audit log entries (#12891)
15259af5d7 is described below
commit 15259af5d7d4124fa367df3c5106bbc75744b426
Author: Jerry Shao <[email protected]>
AuthorDate: Fri Sep 4 19:30:30 2026 +0800
[#12872] fix(core): Capture and redact request query parameters in audit
log entries (#12891)
### What changes were proposed in this pull request?
- `Event`'s constructor now captures the current request's query
parameters (stashed per-request by `RequestContextFilter`) and merges
them into `customInfo()` automatically, for every event in the system,
with no per-event-class wiring.
- `RequestContextFilter` is now also registered on the Iceberg and Lance
REST servers (previously main server only).
- `customInfo()` is sealed (`final`) with a new `ownCustomInfo()`
extension point, so a subclass can no longer override `customInfo()`
directly and silently discard the automatically captured parameters —
this bug pattern was caught and fixed in 6 existing classes during
development.
- Redaction is now a single, uniform pass at audit-log format time in
`AuditLogRedactor`: an exact-match list for known internal keys, plus a
substring match for arbitrary caller-supplied names (query parameters,
headers), with an explicit exemption list (`auth.method`, `http.status`,
etc.) for fixed key literals the codebase itself chooses, so they're
never masked for coincidentally containing a sensitive substring.
- `HttpAuditFilter` gains a generic `HttpRequestEvent` fallback
(mirroring the existing `HttpRequestFailureEvent`), so any endpoint not
yet wired into the operation-dispatcher event system still produces a
baseline audit record (method, URI, status, query parameters) instead of
none at all — automatically suppressed when a richer structured event
already fired for the same request.
### Why are the changes needed?
Audit log entries recorded the request path, operation type, and object,
but never the request parameters that determine how much or what kind of
data a call returns. Two calls to the same endpoint differing only in a
query parameter (e.g. a catalog listing with `details=true` vs
`details=false`) produced identical audit entries apart from the
timestamp, making it impossible to reconstruct after the fact what a
given call actually returned.
Fix: #12872
### Does this PR introduce _any_ user-facing change?
- `customInfo()` in every audit log entry now includes the request's
query parameters automatically.
- New redaction rules (substring match + exemption list) are documented
in `docs/gravitino-server-config.md`.
- Endpoints without a structured operation event now produce a generic
fallback audit entry instead of none. No config keys added, removed, or
renamed.
- `Event.customInfo()` is now `final`. `Event` is annotated
`@DeveloperApi`; any external subclass that previously overrode
`customInfo()` directly will fail to compile against this change and
must move its logic into the new `ownCustomInfo()` extension point
instead. This PR is labeled `branch-1.3`, so flagging this explicitly
for anyone considering a patch-release backport.
### How was this patch tested?
- New/updated unit tests across `core`, `server-common`,
`iceberg-rest-server`, `lance-rest-server` (redaction rules, the
`customInfo()` merge contract, the generic fallback event, filter
registration).
- Live end-to-end verification against a running distribution build:
confirmed `details=true`/`false` now produce distinguishable audit
entries, sensitive query parameters are redacted while non-sensitive
ones are not, Basic-auth credentials never leak into any log file, and
catalog creation produces the expected audit-entry count under
anonymous-admin, authorization-denied, and authenticated-non-admin-owner
scenarios.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
.../apache/gravitino/audit/AuditLogRedactor.java | 69 ++++++-
.../org/apache/gravitino/listener/EventBus.java | 19 ++
.../apache/gravitino/listener/api/event/Event.java | 55 ++++++
.../listener/api/event/GrantGroupRolesEvent.java | 2 +-
.../api/event/GrantGroupRolesFailureEvent.java | 2 +-
.../listener/api/event/GrantUserRolesEvent.java | 2 +-
.../api/event/GrantUserRolesFailureEvent.java | 2 +-
.../listener/api/event/RevokeGroupRolesEvent.java | 2 +-
.../api/event/RevokeGroupRolesFailureEvent.java | 2 +-
.../listener/api/event/RevokeUserRolesEvent.java | 2 +-
.../api/event/RevokeUserRolesFailureEvent.java | 2 +-
.../gravitino/listener/api/event/TableEvent.java | 2 +-
.../listener/api/event/TableFailureEvent.java | 2 +-
.../server/AuthorizationDenialFailureEvent.java | 5 +-
...uestFailureEvent.java => HttpRequestEvent.java} | 81 ++++-----
.../api/event/server/HttpRequestFailureEvent.java | 23 ++-
.../org/apache/gravitino/utils/RequestContext.java | 121 +++++++++++--
.../gravitino/audit/TestAuditLogRedactor.java | 159 ++++++++++++++++
.../gravitino/audit/TestJsonAuditFormatter.java | 4 +-
.../gravitino/audit/v2/TestSimpleAuditLogV2.java | 31 +++-
.../apache/gravitino/listener/TestEventBus.java | 37 ++++
.../gravitino/listener/api/event/TestEvent.java | 102 +++++++++++
.../listener/api/event/TestTableEvent.java | 21 +++
.../TestAuthorizationDenialFailureEvent.java | 25 +++
.../apache/gravitino/utils/TestRequestContext.java | 74 ++++++++
docs/gravitino-server-config.md | 30 +++-
.../org/apache/gravitino/iceberg/RESTService.java | 4 +
.../gravitino/listener/api/event/IcebergEvent.java | 3 +-
.../listener/api/event/IcebergFailureEvent.java | 3 +-
.../TestIcebergTableEventDispatcher.java | 30 +++-
.../api/event/TestIcebergRequestContext.java | 9 +-
.../apache/gravitino/lance/LanceRESTService.java | 4 +
.../gravitino/server/web/HttpAuditFilter.java | 67 ++++---
.../gravitino/server/web/RequestContextFilter.java | 130 +++++++++++++-
.../gravitino/server/web/TestHttpAuditFilter.java | 91 +++++++++-
.../server/web/TestRequestContextFilter.java | 199 +++++++++++++++++++++
.../apache/gravitino/server/GravitinoServer.java | 2 +-
37 files changed, 1292 insertions(+), 126 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/audit/AuditLogRedactor.java
b/core/src/main/java/org/apache/gravitino/audit/AuditLogRedactor.java
index a60dece5d4..ac49e131d8 100644
--- a/core/src/main/java/org/apache/gravitino/audit/AuditLogRedactor.java
+++ b/core/src/main/java/org/apache/gravitino/audit/AuditLogRedactor.java
@@ -25,16 +25,61 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
-/** Utility methods for redacting sensitive values in audit logs. */
+/**
+ * Utility methods for redacting sensitive values in audit logs.
+ *
+ * <p>This is the single place redaction happens: {@code customInfo()} entries
are captured raw
+ * everywhere they originate (dispatcher extras, request query parameters,
HTTP headers, etc.) and
+ * redacted only here, once, right before a log line is written — by {@link
+ * org.apache.gravitino.audit.v2.SimpleAuditLogV2} and {@link
JsonAuditFormatter}, the only two
+ * renderers that expose {@code customInfo()}. Keeping exactly one redaction
pass, applied uniformly
+ * to the fully-merged map regardless of which layer contributed which key,
avoids the alternative
+ * of multiple redaction call sites drifting out of sync with different
keyword lists.
+ */
public final class AuditLogRedactor {
/** Redacted value used for sensitive audit fields. */
public static final String REDACTED_VALUE = "***";
+ /** Case-insensitive exact matches against known internal semantic keys. */
private static final Set<String> MASKED_CUSTOM_INFO_KEYS =
ImmutableSet.of(
"authorization", "cookie", "x-amz-security-token",
"s3.access-key-id", "jdbc-password");
+ /**
+ * Case-insensitive substrings of a {@code customInfo} key that mark it as
sensitive, checked
+ * (against a separator-stripped form of the key — see {@link
#isSensitiveKey(String)}) in
+ * addition to the exact-match {@link #MASKED_CUSTOM_INFO_KEYS}. This exists
because some keys —
+ * notably request query-parameter names, which become {@code customInfo}
entries via {@link
+ * org.apache.gravitino.listener.api.event.Event#customInfo()} — are
supplied by the caller and
+ * can use arbitrary naming conventions (e.g. {@code accessToken}, {@code
s3-secret-access-key},
+ * {@code api-key}, {@code x_api_key}) that an exact-match list alone would
miss. Extend this set
+ * as new sensitive key names are identified.
+ */
+ private static final Set<String> SENSITIVE_KEY_SUBSTRINGS =
+ ImmutableSet.of(
+ "password",
+ "secret",
+ "token",
+ "credential",
+ "apikey",
+ "accesskey",
+ "privatekey",
+ "auth",
+ "signature");
+
+ /**
+ * Case-insensitive exact matches that are never sensitive, checked before
{@link
+ * #SENSITIVE_KEY_SUBSTRINGS}. These are fixed key names the codebase itself
chooses (e.g. {@code
+ * ownCustomInfo()} in {@code AuthorizationDenialFailureEvent}, {@code
HttpRequestEvent}), not
+ * caller-supplied data — unlike query-parameter names, a coincidental
substring match here (e.g.
+ * {@code auth.method} contains "auth") is always a false positive that
would destroy genuinely
+ * useful audit data instead of protecting anything. Add a key here only
when it is a fixed string
+ * literal in the codebase, never for a name that could come from external
input.
+ */
+ private static final Set<String> NEVER_SENSITIVE_KEYS =
+ ImmutableSet.of("http.method", "http.uri", "http.status", "auth.method",
"auth.expression");
+
private AuditLogRedactor() {}
/**
@@ -61,10 +106,26 @@ public final class AuditLogRedactor {
* @return the redacted value for sensitive keys, otherwise the original
value
*/
public static String redactValue(String key, String value) {
- return isSensitiveCustomInfoKey(key) ? REDACTED_VALUE : value;
+ return isSensitiveKey(key) ? REDACTED_VALUE : value;
}
- private static boolean isSensitiveCustomInfoKey(String key) {
- return key != null &&
MASKED_CUSTOM_INFO_KEYS.contains(key.toLowerCase(Locale.ROOT));
+ private static boolean isSensitiveKey(String key) {
+ if (key == null) {
+ return false;
+ }
+ // Exact-match checks run against the raw (only case-folded) key:
NEVER_SENSITIVE_KEYS and
+ // MASKED_CUSTOM_INFO_KEYS are fixed literals, so there is nothing to
normalize away.
+ String lowerCaseKey = key.toLowerCase(Locale.ROOT);
+ if (NEVER_SENSITIVE_KEYS.contains(lowerCaseKey)) {
+ return false;
+ }
+ if (MASKED_CUSTOM_INFO_KEYS.contains(lowerCaseKey)) {
+ return true;
+ }
+ // Caller-supplied key names vary in separator style (api-key, api_key,
x-api-key, ...), so the
+ // substring check strips everything but letters and digits before
matching; otherwise a
+ // hyphenated or underscored spelling of a multi-word entry like "apikey"
would never match.
+ String normalizedKey = lowerCaseKey.replaceAll("[^a-z0-9]", "");
+ return SENSITIVE_KEY_SUBSTRINGS.stream().anyMatch(normalizedKey::contains);
}
}
diff --git a/core/src/main/java/org/apache/gravitino/listener/EventBus.java
b/core/src/main/java/org/apache/gravitino/listener/EventBus.java
index 17af8d718a..33de94ce42 100644
--- a/core/src/main/java/org/apache/gravitino/listener/EventBus.java
+++ b/core/src/main/java/org/apache/gravitino/listener/EventBus.java
@@ -31,6 +31,7 @@ import org.apache.gravitino.listener.api.event.Event;
import org.apache.gravitino.listener.api.event.FailureEvent;
import org.apache.gravitino.listener.api.event.PreEvent;
import org.apache.gravitino.listener.api.event.SupportsChangingPreEvent;
+import org.apache.gravitino.listener.api.event.server.HttpRequestEvent;
import org.apache.gravitino.listener.api.event.server.HttpRequestFailureEvent;
import org.apache.gravitino.utils.RequestContext;
import org.slf4j.Logger;
@@ -126,7 +127,25 @@ public class EventBus {
return eventListeners;
}
+ /**
+ * Dispatches a success event to listeners.
+ *
+ * <p>For every success event that is <em>not</em> an {@link
HttpRequestEvent}, this method also
+ * sets {@link RequestContext#markOperationSuccessFired()} on the current
thread. This lets {@code
+ * HttpAuditFilter} detect that an operation-layer success event has already
been dispatched and
+ * skip emitting a redundant HTTP-level fallback event for the same request.
Tracked independently
+ * from the failure-side flag so an operation that succeeds but whose HTTP
response delivery later
+ * fails still produces both a success and a failure audit entry.
+ *
+ * @param postEvent the success event to dispatch
+ */
private void dispatchPostEvent(Event postEvent) {
+ // Mark the request thread so HttpAuditFilter skips emitting its own
fallback event.
+ // HttpRequestEvent is exempt — it IS the HTTP-layer fallback event and
must not set the flag,
+ // otherwise it would suppress itself on the next pass through the finally
block.
+ if (!(postEvent instanceof HttpRequestEvent)) {
+ RequestContext.markOperationSuccessFired();
+ }
eventListeners.forEach(eventListener ->
eventListener.onPostEvent(postEvent));
}
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 cb2b0829b7..799eeacfc8 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,6 +19,9 @@
package org.apache.gravitino.listener.api.event;
+import com.google.common.collect.ImmutableMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.annotation.DeveloperApi;
@@ -30,16 +33,38 @@ import org.apache.gravitino.utils.RequestContext;
* <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.
+ *
+ * <p>The current request's query parameters are captured the same way, raw
and <b>unredacted</b>
+ * (redaction happens later — see {@code
org.apache.gravitino.audit.AuditLogRedactor}'s class doc),
+ * and merged into {@link #customInfo()} automatically. This gives every event
in the system —
+ * including ones with no subclass-specific {@code customInfo} — automatic
audit coverage of the
+ * parameters that produced it, with no per-event-class wiring required. It
also means every {@link
+ * org.apache.gravitino.listener.api.EventListenerPlugin} — not only the two
built-in audit-log
+ * formatters — receives these parameters unredacted; a plugin that forwards
{@code customInfo()}
+ * elsewhere is responsible for its own redaction if that matters for its
destination.
+ *
+ * <p>{@link #customInfo()} itself is {@code final}: a subclass that wants to
contribute its own
+ * facts must override {@link #ownCustomInfo()} instead, never {@code
customInfo()} directly. This
+ * is deliberate — an earlier version of this class let subclasses override
{@code customInfo()}
+ * directly, which let several of them (accidentally) discard the
automatically captured query
+ * parameters instead of merging with them. Sealing the merge here makes that
specific class of bug
+ * impossible to reintroduce for any subclass of {@code Event} (this class
does not affect {@link
+ * BaseEvent#customInfo()} directly, which stays overridable, or {@link
PreEvent}, which extends
+ * {@code BaseEvent} rather than this class). Note for API consumers: this
class is annotated {@link
+ * DeveloperApi}, and sealing an existing non-final method is a source- and
binary-incompatible
+ * change for any external subclass that overrode {@code customInfo()}
directly.
*/
@DeveloperApi
public abstract class Event extends BaseEvent {
private final String remoteAddress;
+ private final Map<String, String> autoCustomInfo;
protected Event(String user, NameIdentifier identifier) {
super(user, identifier);
String addr = RequestContext.getRemoteAddress();
this.remoteAddress = StringUtils.isNoneBlank(addr) ? addr : "unknown";
+ this.autoCustomInfo = RequestContext.getRequestQueryParams();
}
/**
@@ -51,4 +76,34 @@ public abstract class Event extends BaseEvent {
public String remoteAddress() {
return remoteAddress;
}
+
+ /**
+ * Returns the current request's (raw) query parameters merged with this
event's own facts (from
+ * {@link #ownCustomInfo()}); the subclass's own values win on a key
collision. This method is
+ * {@code final} — override {@link #ownCustomInfo()} to contribute
subclass-specific facts.
+ *
+ * @return the merged custom info, or just the automatically captured query
parameters if the
+ * subclass contributes nothing of its own.
+ */
+ @Override
+ public final Map<String, String> customInfo() {
+ Map<String, String> own = ownCustomInfo();
+ if (own.isEmpty()) {
+ return autoCustomInfo;
+ }
+ Map<String, String> merged = new LinkedHashMap<>(autoCustomInfo);
+ merged.putAll(own);
+ return ImmutableMap.copyOf(merged);
+ }
+
+ /**
+ * Returns this event's own explicit facts, to be merged with the
automatically captured request
+ * query parameters by {@link #customInfo()}. Override this — not {@code
customInfo()} — to
+ * contribute subclass-specific audit facts.
+ *
+ * @return this event's own facts, or an empty map if it has none of its own.
+ */
+ protected Map<String, String> ownCustomInfo() {
+ return ImmutableMap.of();
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesEvent.java
index e3d181357d..dfdf992192 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesEvent.java
@@ -68,7 +68,7 @@ public class GrantGroupRolesEvent extends GroupEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesFailureEvent.java
index 23c0b4eb02..3006ece5df 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantGroupRolesFailureEvent.java
@@ -74,7 +74,7 @@ public class GrantGroupRolesFailureEvent extends
GroupFailureEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesEvent.java
index 705877b484..508c3455f9 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesEvent.java
@@ -69,7 +69,7 @@ public class GrantUserRolesEvent extends UserEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesFailureEvent.java
index 2cf48293e1..e88f2e3774 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/GrantUserRolesFailureEvent.java
@@ -68,7 +68,7 @@ public class GrantUserRolesFailureEvent extends
UserFailureEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesEvent.java
index 6a7af8106c..c69985bbd6 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesEvent.java
@@ -69,7 +69,7 @@ public class RevokeGroupRolesEvent extends GroupEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesFailureEvent.java
index 8d1d993fce..f05ac9688d 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeGroupRolesFailureEvent.java
@@ -74,7 +74,7 @@ public class RevokeGroupRolesFailureEvent extends
GroupFailureEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesEvent.java
index 76091ab704..172d6f6b5c 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesEvent.java
@@ -69,7 +69,7 @@ public class RevokeUserRolesEvent extends UserEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesFailureEvent.java
index 4f25e51395..4f9d34d5e1 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/RevokeUserRolesFailureEvent.java
@@ -69,7 +69,7 @@ public class RevokeUserRolesFailureEvent extends
UserFailureEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return RoleAssignmentAuditInfos.of(roles);
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
index 1574dbbcac..cf4175e8d4 100644
--- a/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
+++ b/core/src/main/java/org/apache/gravitino/listener/api/event/TableEvent.java
@@ -70,7 +70,7 @@ public abstract class TableEvent extends Event {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return customInfo;
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
index d8ad097ad0..e800e6536c 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/TableFailureEvent.java
@@ -70,7 +70,7 @@ public abstract class TableFailureEvent extends FailureEvent {
/** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return customInfo;
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/server/AuthorizationDenialFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/server/AuthorizationDenialFailureEvent.java
index ef9613ec20..60b9f3446a 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/server/AuthorizationDenialFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/server/AuthorizationDenialFailureEvent.java
@@ -108,6 +108,9 @@ public final class AuthorizationDenialFailureEvent extends
FailureEvent {
/**
* Returns authorization-specific context that distinguishes this event from
HTTP-level events.
+ * Merged automatically by {@link
org.apache.gravitino.listener.api.event.Event#customInfo()} with
+ * the request's automatically captured query parameters; these keys always
win on a collision
+ * with a query parameter of the same name.
*
* <ul>
* <li>{@code auth.method} — the intercepted Java method name
@@ -119,7 +122,7 @@ public final class AuthorizationDenialFailureEvent extends
FailureEvent {
* here to avoid duplication.
*/
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return ImmutableMap.of(
"auth.method", methodName,
"auth.expression", expression);
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestEvent.java
similarity index 56%
copy from
core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestFailureEvent.java
copy to
core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestEvent.java
index 2556dba7bd..3347574c38 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestEvent.java
@@ -22,27 +22,28 @@ package org.apache.gravitino.listener.api.event.server;
import com.google.common.collect.ImmutableMap;
import java.util.Map;
import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.listener.api.event.Event;
import org.apache.gravitino.listener.api.event.EventSource;
-import org.apache.gravitino.listener.api.event.FailureEvent;
+import org.apache.gravitino.listener.api.event.OperationStatus;
import org.apache.gravitino.listener.api.event.OperationType;
/**
- * Represents an HTTP-level request failure that occurred before or outside
the operation dispatcher
- * layer — for example, a 401 authentication rejection, a 400 malformed JSON
body, or a 404 unknown
- * route. It is emitted by {@code HttpAuditFilter} when the HTTP response
status is 4xx or 5xx and
- * no operation-layer {@link FailureEvent} was already dispatched for the same
request.
+ * A fallback audit event for HTTP requests that complete with a 2xx/3xx
status but for which no
+ * operation-layer success {@link Event} was dispatched — for example, a REST
endpoint that has not
+ * (yet) been wired into the operation dispatcher/event system. It is emitted
by {@code
+ * HttpAuditFilter} so that such endpoints still produce an audit record
(method, URI, status, query
+ * parameters) instead of none at all.
*
- * <p>Unlike operation-layer failure events, this event carries no {@link
- * org.apache.gravitino.NameIdentifier} (the resource was never resolved) and
its {@link
- * #operationType()} is always {@link OperationType#UNKNOWN}. HTTP-specific
context (method, URI,
- * status code) is available via {@link #customInfo()}.
+ * <p>Unlike operation-layer success events, this event carries no {@link
+ * org.apache.gravitino.NameIdentifier} (the resource was never resolved as a
structured entity) and
+ * its {@link #operationType()} is always {@link OperationType#UNKNOWN}. Once
an endpoint gains its
+ * own structured operation-layer event, that event suppresses this fallback
for the same request —
+ * see {@code RequestContext#markOperationSuccessFired()}.
*
- * <p>This event extends {@link FailureEvent} so it is routed through {@code
- * EventBus.dispatchFailureEvent()}, which swallows listener exceptions and
prevents audit failures
- * from masking the original HTTP error.
+ * <p>This is the success-path counterpart of {@link HttpRequestFailureEvent}.
*/
@DeveloperApi
-public final class HttpRequestFailureEvent extends FailureEvent {
+public final class HttpRequestEvent extends Event {
private final String explicitRemoteAddress;
private final String httpMethod;
@@ -51,25 +52,28 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
private final EventSource explicitEventSource;
/**
- * Constructs an {@code HttpRequestFailureEvent}.
+ * Constructs an {@code HttpRequestEvent}.
*
* @param user the authenticated user, or {@code "unknown"} if
authentication had not completed.
* @param remoteAddress the client IP resolved by the filter
(X-Forwarded-For or raw socket
- * address). Stored explicitly because Iceberg and Lance servers do not
install {@code
- * RequestContextFilter}, so {@code RequestContext.getRemoteAddress()}
may be unset.
+ * address). Stored explicitly, rather than relying on the base {@link
Event} behaviour of
+ * reading {@code RequestContext.getRemoteAddress()} at construction
time, so this event still
+ * carries a correct address even on a server that does not install
{@code
+ * RequestContextFilter} at all (all servers in this codebase currently
do, but this keeps the
+ * event resilient to one that doesn't).
* @param httpMethod the HTTP method (e.g. {@code "GET"}, {@code "POST"}).
- * @param requestUri the request URI path (e.g. {@code
"/api/metalakes/m1/catalogs"}).
- * @param statusCode the HTTP response status code (e.g. {@code 401}, {@code
404}).
+ * @param requestUri the request URI path (e.g. {@code "/search/query"}).
+ * @param statusCode the HTTP response status code (e.g. {@code 200}).
* @param eventSource identifies which server produced the event.
*/
- public HttpRequestFailureEvent(
+ public HttpRequestEvent(
String user,
String remoteAddress,
String httpMethod,
String requestUri,
int statusCode,
EventSource eventSource) {
- super(user, null, new HttpRequestException(httpMethod, requestUri,
statusCode));
+ super(user, null);
this.explicitRemoteAddress = remoteAddress != null ? remoteAddress :
"unknown";
this.httpMethod = httpMethod;
this.requestUri = requestUri;
@@ -83,11 +87,17 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
return OperationType.UNKNOWN;
}
+ /** {@inheritDoc} */
+ @Override
+ public OperationStatus operationStatus() {
+ return OperationStatus.SUCCESS;
+ }
+
/**
- * Returns the explicitly-resolved client remote address. This overrides the
base {@link
- * org.apache.gravitino.listener.api.event.Event} behaviour (which reads
from {@link
- * org.apache.gravitino.utils.RequestContext}) so that events emitted by
servers that do not
- * install {@code RequestContextFilter} still carry a correct address.
+ * Returns the explicitly-resolved client remote address supplied at
construction time, rather
+ * than the base {@link Event} behaviour of reading it from {@link
+ * org.apache.gravitino.utils.RequestContext}. See the constructor's {@code
remoteAddress}
+ * parameter doc for why.
*/
@Override
public String remoteAddress() {
@@ -101,7 +111,9 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
}
/**
- * Returns HTTP-specific context that distinguishes this event from
operation-layer events.
+ * Returns HTTP-specific context that distinguishes this event from
operation-layer events. Merged
+ * automatically by {@link Event#customInfo()} with the request's
automatically captured query
+ * parameters; these keys always win on a collision with a query parameter
of the same name.
*
* <ul>
* <li>{@code http.method} — the HTTP verb
@@ -110,7 +122,7 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
* </ul>
*/
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return ImmutableMap.of(
"http.method", httpMethod,
"http.uri", requestUri,
@@ -131,21 +143,4 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
public int statusCode() {
return statusCode;
}
-
- /**
- * A lightweight synthetic exception used as the {@link
FailureEvent#exception()} carrier for
- * {@link HttpRequestFailureEvent}. It encodes the HTTP method, URI, and
status so that log
- * formatters that render the exception message get readable output without
needing to inspect
- * {@link HttpRequestFailureEvent#customInfo()} separately.
- */
- public static final class HttpRequestException extends RuntimeException {
-
- private HttpRequestException(String httpMethod, String requestUri, int
statusCode) {
- super(
- String.format("HTTP request failed: %s %s -> %d", httpMethod,
requestUri, statusCode),
- null,
- true,
- false /* suppress stack trace — this is a synthetic carrier, not a
real exception */);
- }
- }
}
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestFailureEvent.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestFailureEvent.java
index 2556dba7bd..22485e12f9 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestFailureEvent.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/server/HttpRequestFailureEvent.java
@@ -55,8 +55,12 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
*
* @param user the authenticated user, or {@code "unknown"} if
authentication had not completed.
* @param remoteAddress the client IP resolved by the filter
(X-Forwarded-For or raw socket
- * address). Stored explicitly because Iceberg and Lance servers do not
install {@code
- * RequestContextFilter}, so {@code RequestContext.getRemoteAddress()}
may be unset.
+ * address). Stored explicitly, rather than relying on the base {@link
+ * org.apache.gravitino.listener.api.event.Event} behaviour of reading
{@code
+ * RequestContext.getRemoteAddress()} at construction time, so this
event still carries a
+ * correct address even on a server that does not install {@code
RequestContextFilter} at all
+ * (all servers in this codebase currently do, but this keeps the event
resilient to one that
+ * doesn't).
* @param httpMethod the HTTP method (e.g. {@code "GET"}, {@code "POST"}).
* @param requestUri the request URI path (e.g. {@code
"/api/metalakes/m1/catalogs"}).
* @param statusCode the HTTP response status code (e.g. {@code 401}, {@code
404}).
@@ -84,10 +88,10 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
}
/**
- * Returns the explicitly-resolved client remote address. This overrides the
base {@link
- * org.apache.gravitino.listener.api.event.Event} behaviour (which reads
from {@link
- * org.apache.gravitino.utils.RequestContext}) so that events emitted by
servers that do not
- * install {@code RequestContextFilter} still carry a correct address.
+ * Returns the explicitly-resolved client remote address supplied at
construction time, rather
+ * than the base {@link org.apache.gravitino.listener.api.event.Event}
behaviour of reading it
+ * from {@link org.apache.gravitino.utils.RequestContext}. See the
constructor's {@code
+ * remoteAddress} parameter doc for why.
*/
@Override
public String remoteAddress() {
@@ -101,7 +105,10 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
}
/**
- * Returns HTTP-specific context that distinguishes this event from
operation-layer events.
+ * Returns HTTP-specific context that distinguishes this event from
operation-layer events. Merged
+ * automatically by {@link
org.apache.gravitino.listener.api.event.Event#customInfo()} with the
+ * request's automatically captured query parameters; these keys always win
on a collision with a
+ * query parameter of the same name.
*
* <ul>
* <li>{@code http.method} — the HTTP verb
@@ -110,7 +117,7 @@ public final class HttpRequestFailureEvent extends
FailureEvent {
* </ul>
*/
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return ImmutableMap.of(
"http.method", httpMethod,
"http.uri", requestUri,
diff --git a/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
index e674dba595..01b6e4f607 100644
--- a/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
+++ b/core/src/main/java/org/apache/gravitino/utils/RequestContext.java
@@ -26,18 +26,32 @@ import java.util.Map;
* Holds per-request context data in a {@link ThreadLocal} so that event
classes constructed on the
* servlet thread can capture it without carrying a servlet dependency.
*
- * <p>Currently tracks three pieces of state:
+ * <p>Currently tracks four pieces of state:
*
* <ul>
* <li><b>remoteAddress</b> — the client IP resolved from {@code
X-Forwarded-For} or {@link
* javax.servlet.http.HttpServletRequest#getRemoteAddr()}.
- * <li><b>operationFailureFired</b> — set to {@code true} by {@link
- * org.apache.gravitino.listener.EventBus} when an operation-layer {@link
+ * <li><b>operationOutcome</b> — set by {@link
org.apache.gravitino.listener.EventBus} when an
+ * operation-layer {@link org.apache.gravitino.listener.api.event.Event}
or {@link
* org.apache.gravitino.listener.api.event.FailureEvent} is dispatched,
so that {@code
- * HttpAuditFilter} can skip emitting a redundant HTTP-level failure
event for the same
- * request.
+ * HttpAuditFilter} can skip emitting a redundant HTTP-level fallback
event for the same
+ * request. Exposed as two independent-looking flags ({@code
operationFailureFired}/{@code
+ * operationSuccessFired}) for callers, but backed by a single tri-state
value — normally a
+ * request's operation layer dispatches at most one terminal event
(success or failure), never
+ * both, so there is nothing to track independently. That is an
expectation on callers, not
+ * something this class enforces, so failure is treated as sticky: {@link
+ * #markOperationSuccessFired()} is a no-op once a failure has been
recorded on this thread,
+ * so a later success dispatch can never erase it and cause a 4xx/5xx
response to be
+ * double-logged as if no operation-layer failure had fired. {@code
HttpAuditFilter} still
+ * produces both a success and a failure entry for the "operation
succeeded but HTTP delivery
+ * failed" case, because that failure entry comes from the filter's own
HTTP-status check, not
+ * from a second operation-layer flag.
* <li><b>auditExtras</b> — optional {@code customInfo} facts stashed by an
inner dispatcher and
* consumed by the outer event dispatcher so one operation still
produces one event.
+ * <li><b>requestQueryParams</b> — the current request's query parameters,
captured raw (not
+ * redacted — see {@code AuditLogRedactor}) once per request by {@code
RequestContextFilter}
+ * and read (non-destructively) by every {@link
org.apache.gravitino.listener.api.event.Event}
+ * constructor.
* </ul>
*
* <p><b>Threading contract:</b> values must be set and cleared on the same
(servlet) thread. Event
@@ -46,9 +60,16 @@ import java.util.Map;
*/
public class RequestContext {
+ /** The operation-layer event outcome recorded for the current request, if
any. */
+ private enum OperationOutcome {
+ SUCCESS,
+ FAILURE
+ }
+
private static final ThreadLocal<String> REMOTE_ADDRESS = new
ThreadLocal<>();
- private static final ThreadLocal<Boolean> OPERATION_FAILURE_FIRED = new
ThreadLocal<>();
+ private static final ThreadLocal<OperationOutcome> OPERATION_OUTCOME = new
ThreadLocal<>();
private static final ThreadLocal<Map<String, String>> AUDIT_EXTRAS = new
ThreadLocal<>();
+ private static final ThreadLocal<Map<String, String>> REQUEST_QUERY_PARAMS =
new ThreadLocal<>();
private RequestContext() {}
@@ -77,7 +98,7 @@ public class RequestContext {
* {@code HttpRequestFailureEvent}.
*/
public static void markOperationFailureFired() {
- OPERATION_FAILURE_FIRED.set(Boolean.TRUE);
+ OPERATION_OUTCOME.set(OperationOutcome.FAILURE);
}
/**
@@ -87,16 +108,58 @@ public class RequestContext {
* @return {@code true} if the flag is set, {@code false} otherwise.
*/
public static boolean isOperationFailureFired() {
- return Boolean.TRUE.equals(OPERATION_FAILURE_FIRED.get());
+ return OPERATION_OUTCOME.get() == OperationOutcome.FAILURE;
}
/**
- * Clears the operation-failure flag for the current request thread. Must be
called in a {@code
- * finally} block by {@code HttpAuditFilter} at the end of each request to
prevent stale values
- * from leaking to the next request on the same Jetty thread.
+ * Clears the recorded operation outcome for the current request thread.
Must be called in a
+ * {@code finally} block by {@code HttpAuditFilter} at the end of each
request to prevent stale
+ * values from leaking to the next request on the same Jetty thread.
+ *
+ * <p>Named after the failure flag for symmetry with {@link
#resetOperationSuccessFired()}, but
+ * clears the single underlying outcome either way — see the class-level doc.
*/
public static void resetOperationFailureFired() {
- OPERATION_FAILURE_FIRED.remove();
+ OPERATION_OUTCOME.remove();
+ }
+
+ /**
+ * Marks that an operation-layer success {@code Event} has been dispatched
for the current
+ * request. Called by {@code EventBus.dispatchPostEvent()} for every success
event that is not
+ * itself an {@code HttpRequestEvent}.
+ *
+ * <p>A no-op if a failure was already recorded on this thread: the failure
outcome is monotonic
+ * (mirroring the pre-existing {@code operationFailureFired} boolean this
replaced), so a success
+ * event dispatched after a failure event for the same request — which
should not normally happen,
+ * but is not structurally prevented — can never erase it and cause {@code
HttpAuditFilter} to
+ * double-log a 4xx/5xx response as if no operation-layer failure had fired.
+ */
+ public static void markOperationSuccessFired() {
+ if (OPERATION_OUTCOME.get() != OperationOutcome.FAILURE) {
+ OPERATION_OUTCOME.set(OperationOutcome.SUCCESS);
+ }
+ }
+
+ /**
+ * Returns {@code true} if an operation-layer success event has already been
dispatched on this
+ * thread for the current request.
+ *
+ * @return {@code true} if the flag is set, {@code false} otherwise.
+ */
+ public static boolean isOperationSuccessFired() {
+ return OPERATION_OUTCOME.get() == OperationOutcome.SUCCESS;
+ }
+
+ /**
+ * Clears the recorded operation outcome for the current request thread.
Must be called in a
+ * {@code finally} block by {@code HttpAuditFilter} at the end of each
request to prevent stale
+ * values from leaking to the next request on the same Jetty thread.
+ *
+ * <p>Named after the success flag for symmetry with {@link
#resetOperationFailureFired()}, but
+ * clears the single underlying outcome either way — see the class-level doc.
+ */
+ public static void resetOperationSuccessFired() {
+ OPERATION_OUTCOME.remove();
}
/**
@@ -128,13 +191,45 @@ public class RequestContext {
return extras == null ? ImmutableMap.of() : extras;
}
+ /**
+ * Stashes the current request's raw (not redacted) query parameters for the
current request
+ * thread. Called once per request by {@code RequestContextFilter}.
Redaction happens later, at
+ * audit-log format time, applied uniformly to the full merged {@code
customInfo()} map — see
+ * {@code AuditLogRedactor}'s class doc for why.
+ *
+ * <p>Unlike {@link #setAuditExtras(Map)}/{@link #takeAuditExtras()}, this
is read
+ * non-destructively: every {@link
org.apache.gravitino.listener.api.event.Event} constructed on
+ * this thread during the request reads the same snapshot.
+ *
+ * @param params the raw query parameters, or {@code null} to clear
+ */
+ public static void setRequestQueryParams(Map<String, String> params) {
+ if (params == null || params.isEmpty()) {
+ REQUEST_QUERY_PARAMS.remove();
+ return;
+ }
+ REQUEST_QUERY_PARAMS.set(ImmutableMap.copyOf(params));
+ }
+
+ /**
+ * Returns the current request's raw (not redacted) query parameters
previously set on this
+ * thread, without clearing them.
+ *
+ * @return an immutable query-parameter map, or an empty map when none were
set
+ */
+ public static Map<String, String> getRequestQueryParams() {
+ Map<String, String> params = REQUEST_QUERY_PARAMS.get();
+ return params == null ? ImmutableMap.of() : params;
+ }
+
/**
* Removes all per-request bindings 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();
- OPERATION_FAILURE_FIRED.remove();
+ OPERATION_OUTCOME.remove();
AUDIT_EXTRAS.remove();
+ REQUEST_QUERY_PARAMS.remove();
}
}
diff --git
a/core/src/test/java/org/apache/gravitino/audit/TestAuditLogRedactor.java
b/core/src/test/java/org/apache/gravitino/audit/TestAuditLogRedactor.java
new file mode 100644
index 0000000000..99623a68bf
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/audit/TestAuditLogRedactor.java
@@ -0,0 +1,159 @@
+/*
+ * 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.Collections;
+import java.util.Map;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestAuditLogRedactor {
+
+ @Test
+ public void testNullCustomInfoReturnsEmptyMap() {
+ Assertions.assertTrue(AuditLogRedactor.redactCustomInfo(null).isEmpty());
+ }
+
+ @Test
+ public void testNonSensitiveKeyKeptAsIs() {
+ Map<String, String> info = Collections.singletonMap("details", "true");
+ Assertions.assertEquals("true",
AuditLogRedactor.redactCustomInfo(info).get("details"));
+ }
+
+ // ─── exact-match (MASKED_CUSTOM_INFO_KEYS)
───────────────────────────────────
+
+ @Test
+ public void testExactMatchKeyIsRedacted() {
+ Map<String, String> info = Collections.singletonMap("authorization",
"Bearer secret-token");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
+ AuditLogRedactor.redactCustomInfo(info).get("authorization"));
+ }
+
+ @Test
+ public void testExactMatchIsCaseInsensitive() {
+ Map<String, String> info = Collections.singletonMap("AUTHORIZATION",
"Bearer secret-token");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
+ AuditLogRedactor.redactCustomInfo(info).get("AUTHORIZATION"));
+ }
+
+ /**
+ * "cookie" is a known internal key with no sensitive substring in its name
(it doesn't contain
+ * "password", "token", etc.), so only the exact-match list catches it.
+ */
+ @Test
+ public void testExactMatchCatchesKeyWithNoSensitiveSubstring() {
+ Map<String, String> info = Collections.singletonMap("cookie",
"session=abc123");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
AuditLogRedactor.redactCustomInfo(info).get("cookie"));
+ }
+
+ // ─── substring match (SENSITIVE_KEY_SUBSTRINGS)
──────────────────────────────
+
+ /**
+ * Request query-parameter names become customInfo entries via {@link
+ * org.apache.gravitino.listener.api.event.Event#customInfo()} with whatever
naming convention the
+ * caller chose, so redaction here must also catch names the exact-match
list was never designed
+ * for (e.g. "accessToken" isn't in MASKED_CUSTOM_INFO_KEYS, but it contains
"token").
+ */
+ @Test
+ public void testSubstringMatchCatchesCallerSuppliedKeyNames() {
+ Map<String, String> info = Collections.singletonMap("accessToken",
"abcd1234");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
+ AuditLogRedactor.redactCustomInfo(info).get("accessToken"));
+ }
+
+ @Test
+ public void testSubstringMatchCatchesHyphenatedKeyName() {
+ Map<String, String> info =
Collections.singletonMap("s3-secret-access-key", "abcd1234");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
+ AuditLogRedactor.redactCustomInfo(info).get("s3-secret-access-key"));
+ }
+
+ @Test
+ public void testSubstringMatchIsCaseInsensitive() {
+ Map<String, String> info = Collections.singletonMap("PASSWORD", "hunter2");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
AuditLogRedactor.redactCustomInfo(info).get("PASSWORD"));
+ }
+
+ /**
+ * The substring list has multi-word entries ("apikey", "accesskey",
"privatekey"), but
+ * caller-supplied key names almost always separate those words with a
hyphen or underscore
+ * ("api-key", "x-api-key", "access-key-id", "private_key"). Pins that the
match is done against a
+ * separator-stripped form of the key, not just a lowercased one, so these
realistic spellings are
+ * actually caught instead of silently passing through in cleartext.
+ */
+ @Test
+ public void testSubstringMatchIgnoresSeparators() {
+ Map<String, String> info = Collections.singletonMap("api-key", "abcd1234");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
AuditLogRedactor.redactCustomInfo(info).get("api-key"));
+
+ Map<String, String> info2 = Collections.singletonMap("x-api-key",
"abcd1234");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
AuditLogRedactor.redactCustomInfo(info2).get("x-api-key"));
+
+ Map<String, String> info3 = Collections.singletonMap("access-key-id",
"abcd1234");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
+ AuditLogRedactor.redactCustomInfo(info3).get("access-key-id"));
+
+ Map<String, String> info4 = Collections.singletonMap("private_key",
"abcd1234");
+ Assertions.assertEquals(
+ AuditLogRedactor.REDACTED_VALUE,
+ AuditLogRedactor.redactCustomInfo(info4).get("private_key"));
+ }
+
+ @Test
+ public void testKeyIsPreservedEvenWhenValueIsRedacted() {
+ Map<String, String> info = Collections.singletonMap("token",
"secretvalue");
+
Assertions.assertTrue(AuditLogRedactor.redactCustomInfo(info).containsKey("token"));
+ }
+
+ // ─── NEVER_SENSITIVE_KEYS (codebase-chosen keys that coincidentally
contain a substring) ────
+
+ /**
+ * "auth.method"/"auth.expression" are fixed key literals
AuthorizationDenialFailureEvent always
+ * uses — not caller-supplied data — but they contain "auth", which is in
+ * SENSITIVE_KEY_SUBSTRINGS. Without an explicit exemption, the substring
check (added to also
+ * cover caller-supplied query-parameter names) would redact genuinely
useful audit data: which
+ * method was denied and what expression was evaluated, exactly what this
event exists to record.
+ */
+ @Test
+ public void testCodebaseChosenAuthKeysAreNeverRedacted() {
+ Map<String, String> info = Collections.singletonMap("auth.method",
"loadTable");
+ Assertions.assertEquals(
+ "loadTable",
AuditLogRedactor.redactCustomInfo(info).get("auth.method"));
+
+ Map<String, String> info2 = Collections.singletonMap("auth.expression",
"TABLE:LOAD");
+ Assertions.assertEquals(
+ "TABLE:LOAD",
AuditLogRedactor.redactCustomInfo(info2).get("auth.expression"));
+ }
+
+ @Test
+ public void testCodebaseChosenHttpKeysAreNeverRedacted() {
+ Map<String, String> info = Collections.singletonMap("http.method", "GET");
+ Assertions.assertEquals("GET",
AuditLogRedactor.redactCustomInfo(info).get("http.method"));
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/audit/TestJsonAuditFormatter.java
b/core/src/test/java/org/apache/gravitino/audit/TestJsonAuditFormatter.java
index a22e953dfd..6178efaac2 100644
--- a/core/src/test/java/org/apache/gravitino/audit/TestJsonAuditFormatter.java
+++ b/core/src/test/java/org/apache/gravitino/audit/TestJsonAuditFormatter.java
@@ -138,7 +138,7 @@ public class TestJsonAuditFormatter {
static class StubEventWithSensitiveCustomInfo extends StubEvent {
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return ImmutableMap.<String, String>builder()
.put("Authorization", "Bearer token")
.put("cookie", "a=b")
@@ -161,7 +161,7 @@ public class TestJsonAuditFormatter {
}
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return ImmutableMap.of("env", "prod");
}
}
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
index 8c68859ef1..7c2d89d420 100644
--- a/core/src/test/java/org/apache/gravitino/audit/v2/TestSimpleAuditLogV2.java
+++ b/core/src/test/java/org/apache/gravitino/audit/v2/TestSimpleAuditLogV2.java
@@ -35,11 +35,18 @@ import
org.apache.gravitino.listener.api.event.OperationStatus;
import org.apache.gravitino.listener.api.event.OperationType;
import
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
import org.apache.gravitino.listener.api.event.server.HttpRequestFailureEvent;
+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 TestSimpleAuditLogV2 {
+ @AfterEach
+ void cleanup() {
+ RequestContext.clear();
+ }
+
@Test
public void testTimestampHasMillisecondPrecision() {
SimpleAuditLogV2 log = new SimpleAuditLogV2(new StubEvent());
@@ -70,6 +77,28 @@ public class TestSimpleAuditLogV2 {
Assertions.assertEquals("", fields[7], "Last field should be empty when
customInfo is absent");
}
+ /**
+ * End-to-end proof that redaction happens exactly once, at format time: an
auto-captured query
+ * parameter is stashed raw (as RequestContextFilter now does), reaches
Event.customInfo()
+ * unredacted, and is only masked here, when SimpleAuditLogV2 renders it via
AuditLogRedactor.
+ */
+ @Test
+ public void testAutoCapturedQueryParamIsRedactedAtFormatTime() {
+ RequestContext.setRequestQueryParams(ImmutableMap.of("token",
"raw-secret-value"));
+ SimpleAuditLogV2 log = new SimpleAuditLogV2(new StubEvent());
+
+ // Not redacted yet on the event itself — redaction is the formatter's
job, not capture's.
+ Assertions.assertEquals("raw-secret-value", log.customInfo().get("token"));
+
+ String output = log.toString();
+ String[] fields = output.split("\t", -1);
+ Assertions.assertTrue(
+ fields[7].contains("***"), "Rendered output must redact the token
value: " + fields[7]);
+ Assertions.assertFalse(
+ fields[7].contains("raw-secret-value"),
+ "Rendered output must not contain the raw token value: " + fields[7]);
+ }
+
@Test
public void testRoleAssignmentIncludesRoleNames() {
GrantUserRolesFailureEvent event =
@@ -315,7 +344,7 @@ public class TestSimpleAuditLogV2 {
static class StubEventWithCustomInfo extends StubEvent {
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return ImmutableMap.of("k1", "v1");
}
}
diff --git a/core/src/test/java/org/apache/gravitino/listener/TestEventBus.java
b/core/src/test/java/org/apache/gravitino/listener/TestEventBus.java
index a1b5c5ae0f..5fab308bc7 100644
--- a/core/src/test/java/org/apache/gravitino/listener/TestEventBus.java
+++ b/core/src/test/java/org/apache/gravitino/listener/TestEventBus.java
@@ -29,6 +29,7 @@ import org.apache.gravitino.listener.api.event.EventSource;
import org.apache.gravitino.listener.api.event.FailureEvent;
import org.apache.gravitino.listener.api.event.OperationStatus;
import org.apache.gravitino.listener.api.event.PreEvent;
+import org.apache.gravitino.listener.api.event.server.HttpRequestEvent;
import org.apache.gravitino.listener.api.event.server.HttpRequestFailureEvent;
import org.apache.gravitino.utils.RequestContext;
import org.junit.jupiter.api.AfterEach;
@@ -101,6 +102,7 @@ public class TestEventBus {
@AfterEach
void clearRequestContext() {
RequestContext.resetOperationFailureFired();
+ RequestContext.resetOperationSuccessFired();
RequestContext.clear();
}
@@ -140,6 +142,41 @@ public class TestEventBus {
+ "it is the HTTP-layer event and must not suppress itself");
}
+ // ─── operationSuccessFired flag marking
──────────────────────────────────────
+
+ @Test
+ void testOperationLayerSuccessEventSetsOperationSuccessFiredFlag() {
+ EventBus eventBus = new EventBus(Collections.emptyList());
+ Event successEvent =
+ new Event("user", NameIdentifier.of("test")) {
+ @Override
+ public OperationStatus operationStatus() {
+ return OperationStatus.SUCCESS;
+ }
+ };
+
+ Assertions.assertFalse(RequestContext.isOperationSuccessFired(), "Flag
must start as false");
+ eventBus.dispatchEvent(successEvent);
+ Assertions.assertTrue(
+ RequestContext.isOperationSuccessFired(),
+ "Flag must be set after dispatching an operation-layer success event");
+ }
+
+ @Test
+ void testHttpRequestEventDoesNotSetOperationSuccessFiredFlag() {
+ EventBus eventBus = new EventBus(Collections.emptyList());
+ HttpRequestEvent httpEvent =
+ new HttpRequestEvent(
+ "alice", "1.2.3.4", "GET", "/api/metalakes", 200,
EventSource.GRAVITINO_SERVER);
+
+ eventBus.dispatchEvent(httpEvent);
+
+ Assertions.assertFalse(
+ RequestContext.isOperationSuccessFired(),
+ "Flag must NOT be set when dispatching HttpRequestEvent — "
+ + "it is the HTTP-layer fallback event and must not suppress
itself");
+ }
+
static class ThrowingEventListener implements EventListenerPlugin {
private final RuntimeException exceptionToThrow;
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestEvent.java
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestEvent.java
new file mode 100644
index 0000000000..b17931c0f7
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/listener/api/event/TestEvent.java
@@ -0,0 +1,102 @@
+/*
+ * 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 com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.utils.RequestContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Exercises {@link Event}'s automatic capture of the request's query
parameters at construction
+ * time. Uses {@link ListCatalogEvent} as a representative concrete subclass
that does not override
+ * {@code customInfo()} itself — any of the ~300 event classes without their
own {@code customInfo}
+ * go through the exact same {@link Event} constructor, so this one test
stands in for all of them.
+ */
+public class TestEvent {
+
+ @AfterEach
+ void cleanup() {
+ RequestContext.clear();
+ }
+
+ @Test
+ void testCustomInfoCapturesQueryParamsAtConstructionTime() {
+ RequestContext.setRequestQueryParams(ImmutableMap.of("details", "true"));
+ Event event = new ListCatalogEvent("user", Namespace.of("metalake"), 3);
+ Assertions.assertEquals("true", event.customInfo().get("details"));
+ }
+
+ @Test
+ void testCustomInfoIsEmptyWhenNoQueryParamsCaptured() {
+ Event event = new ListCatalogEvent("user", Namespace.of("metalake"), 3);
+ Assertions.assertTrue(event.customInfo().isEmpty());
+ }
+
+ @Test
+ void testCustomInfoSnapshotIsFixedAtConstructionNotAtReadTime() {
+ RequestContext.setRequestQueryParams(ImmutableMap.of("details", "true"));
+ Event event = new ListCatalogEvent("user", Namespace.of("metalake"), 3);
+ RequestContext.setRequestQueryParams(ImmutableMap.of("details", "false"));
+ Assertions.assertEquals(
+ "true",
+ event.customInfo().get("details"),
+ "event must keep the snapshot taken at construction time, not a later
thread-local value");
+ }
+
+ /**
+ * customInfo() is final in Event precisely so this merge direction can't be
gotten wrong by a
+ * subclass: a subclass's own fact must win over an automatically captured
query parameter of the
+ * same name, otherwise a caller could overwrite an audit-critical field
(e.g. a query parameter
+ * literally named "http.status") just by naming a query parameter after it.
+ */
+ @Test
+ void testOwnCustomInfoOverridesAutomaticQueryParamOnKeyCollision() {
+ RequestContext.setRequestQueryParams(ImmutableMap.of("outcome",
"attacker-supplied"));
+ Event event =
+ new Event("user", NameIdentifier.of("metalake")) {
+ @Override
+ protected Map<String, String> ownCustomInfo() {
+ return ImmutableMap.of("outcome", "real-value");
+ }
+ };
+
+ Assertions.assertEquals("real-value", event.customInfo().get("outcome"));
+ }
+
+ @Test
+ void testOwnCustomInfoIsMergedAlongsideAutomaticQueryParams() {
+ RequestContext.setRequestQueryParams(ImmutableMap.of("details", "true"));
+ Event event =
+ new Event("user", NameIdentifier.of("metalake")) {
+ @Override
+ protected Map<String, String> ownCustomInfo() {
+ return ImmutableMap.of("http.status", "200");
+ }
+ };
+
+ Assertions.assertEquals("true", event.customInfo().get("details"));
+ Assertions.assertEquals("200", event.customInfo().get("http.status"));
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
index 271911ee7b..f39c355515 100644
---
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
+++
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestTableEvent.java
@@ -291,6 +291,27 @@ public class TestTableEvent {
dummyEventListener.popPreEvent();
}
+ /**
+ * customInfo now has two contributors: the request's automatically captured
query parameters
+ * (from {@code Event}) and this dispatcher's explicitly stashed extras.
Pins that both are
+ * visible on the event and that an explicit key wins over an automatic one
of the same name.
+ */
+ @Test
+ void testCustomInfoMergesAutomaticQueryParamsWithExplicitExtras() {
+ NameIdentifier identifier = NameIdentifier.of("metalake", "catalog",
table.name());
+ RequestContext.setRequestQueryParams(
+ ImmutableMap.of("details", "true", "audit.reason",
"from-query-param"));
+ RequestContext.setAuditExtras(ImmutableMap.of("audit.reason",
"policy-applied"));
+ dispatcher.loadTable(identifier);
+
+ Event event = dummyEventListener.popPostEvent();
+ Assertions.assertEquals("true", event.customInfo().get("details"));
+ Assertions.assertEquals(
+ "policy-applied",
+ event.customInfo().get("audit.reason"),
+ "explicit extras must override the automatically captured value for
the same key");
+ }
+
@Test
void testCreateTableFailureEvent() {
NameIdentifier identifier = NameIdentifier.of("metalake", "catalog",
table.name());
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/event/server/TestAuthorizationDenialFailureEvent.java
b/core/src/test/java/org/apache/gravitino/listener/api/event/server/TestAuthorizationDenialFailureEvent.java
index b91696fc4d..d03d470f9d 100644
---
a/core/src/test/java/org/apache/gravitino/listener/api/event/server/TestAuthorizationDenialFailureEvent.java
+++
b/core/src/test/java/org/apache/gravitino/listener/api/event/server/TestAuthorizationDenialFailureEvent.java
@@ -19,10 +19,13 @@
package org.apache.gravitino.listener.api.event.server;
+import com.google.common.collect.ImmutableMap;
import java.util.Map;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.listener.api.event.EventSource;
import org.apache.gravitino.listener.api.event.OperationType;
+import org.apache.gravitino.utils.RequestContext;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -31,6 +34,11 @@ public class TestAuthorizationDenialFailureEvent {
private static final NameIdentifier TABLE_IDENT =
NameIdentifier.of("metalake1", "catalog1", "schema1", "table1");
+ @AfterEach
+ void cleanup() {
+ RequestContext.clear();
+ }
+
@Test
public void testFieldsStoredCorrectly() {
AuthorizationDenialFailureEvent event =
@@ -70,6 +78,23 @@ public class TestAuthorizationDenialFailureEvent {
Assertions.assertFalse(info.containsKey("auth.resource"));
}
+ /**
+ * Pins the fix for a bug where customInfo() overrode and discarded Event's
automatically captured
+ * request query parameters, exactly like the bug already fixed in
TableEvent,
+ * HttpRequestFailureEvent, and Iceberg's event classes.
+ */
+ @Test
+ public void testCustomInfoMergesAutomaticQueryParamsWithAuthFields() {
+ RequestContext.setRequestQueryParams(ImmutableMap.of("details", "true"));
+ AuthorizationDenialFailureEvent event =
+ new AuthorizationDenialFailureEvent("alice", TABLE_IDENT, "loadTable",
"TABLE:LOAD");
+
+ Map<String, String> info = event.customInfo();
+ Assertions.assertEquals("true", info.get("details"));
+ Assertions.assertEquals("loadTable", info.get("auth.method"));
+ Assertions.assertEquals("TABLE:LOAD", info.get("auth.expression"));
+ }
+
@Test
public void testNullExpressionNormalisedToEmptyString() {
AuthorizationDenialFailureEvent event =
diff --git
a/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
index faf707e168..253280a360 100644
--- a/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
+++ b/core/src/test/java/org/apache/gravitino/utils/TestRequestContext.java
@@ -140,4 +140,78 @@ public class TestRequestContext {
RequestContext.takeAuditExtras().get("audit.reason"),
"Child thread must not consume the parent's stash");
}
+
+ /**
+ * Unlike audit extras, the query-param snapshot is read by every {@code
Event} constructed during
+ * the request, not consumed once. Pins that reading it does not clear it.
+ */
+ @Test
+ public void testGetRequestQueryParamsDoesNotClear() {
+ RequestContext.setRequestQueryParams(Collections.singletonMap("details",
"true"));
+ Assertions.assertEquals("true",
RequestContext.getRequestQueryParams().get("details"));
+ Assertions.assertEquals(
+ "true",
+ RequestContext.getRequestQueryParams().get("details"),
+ "a second read must still see the value");
+ }
+
+ @Test
+ public void testGetRequestQueryParamsReturnsEmptyWhenUnset() {
+ Assertions.assertTrue(RequestContext.getRequestQueryParams().isEmpty());
+ }
+
+ @Test
+ public void testEmptyOrNullRequestQueryParamsClearsStash() {
+ RequestContext.setRequestQueryParams(Collections.singletonMap("k", "v"));
+ RequestContext.setRequestQueryParams(Collections.emptyMap());
+ Assertions.assertTrue(RequestContext.getRequestQueryParams().isEmpty());
+
+ RequestContext.setRequestQueryParams(Collections.singletonMap("k", "v"));
+ RequestContext.setRequestQueryParams(null);
+ Assertions.assertTrue(RequestContext.getRequestQueryParams().isEmpty());
+ }
+
+ @Test
+ public void testClearRemovesRequestQueryParams() {
+ RequestContext.setRequestQueryParams(Collections.singletonMap("k", "v"));
+ RequestContext.clear();
+ Assertions.assertTrue(RequestContext.getRequestQueryParams().isEmpty());
+ }
+
+ /**
+ * Mirrors the existing {@code operationFailureFired} flag on the success
path. Kept as an
+ * independent flag (not merged with the failure one) so that an operation
which succeeds but
+ * whose HTTP response delivery later fails can still produce both a success
and a failure audit
+ * entry.
+ */
+ @Test
+ public void testOperationSuccessFiredLifecycle() {
+ Assertions.assertFalse(RequestContext.isOperationSuccessFired());
+ RequestContext.markOperationSuccessFired();
+ Assertions.assertTrue(RequestContext.isOperationSuccessFired());
+ RequestContext.resetOperationSuccessFired();
+ Assertions.assertFalse(RequestContext.isOperationSuccessFired());
+ }
+
+ @Test
+ public void testClearRemovesOperationSuccessFired() {
+ RequestContext.markOperationSuccessFired();
+ RequestContext.clear();
+ Assertions.assertFalse(RequestContext.isOperationSuccessFired());
+ }
+
+ /**
+ * Failure is sticky: a success dispatch that happens to follow a failure
dispatch on the same
+ * thread (not expected in practice, but not structurally prevented either)
must not overwrite it
+ * — otherwise HttpAuditFilter would see isOperationFailureFired() == false
and double-log a
+ * 4xx/5xx response with its own fallback failure event, on top of the real
operation-layer one.
+ */
+ @Test
+ public void testMarkOperationSuccessFiredDoesNotOverwriteFailure() {
+ RequestContext.markOperationFailureFired();
+ RequestContext.markOperationSuccessFired();
+ Assertions.assertTrue(RequestContext.isOperationFailureFired(), "failure
must remain recorded");
+ Assertions.assertFalse(
+ RequestContext.isOperationSuccessFired(), "success must not overwrite
a recorded failure");
+ }
}
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 679f9385ba..bc05401fc2 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -402,9 +402,27 @@ pipeline can replace either.
`SimpleFormatterV2` is the default formatter. `JsonAuditFormatter` is
available where structured
output is wanted: it emits one JSON object per line, serializes `customInfo`,
and writes timestamps
-as ISO 8601 with millisecond precision and a zone offset. Both formatters
replace the value of a
-sensitive `customInfo` key with `***`. The masked keys are `authorization`,
`cookie`,
-`x-amz-security-token`, `s3.access-key-id`, and `jdbc-password`.
+as ISO 8601 with millisecond precision and a zone offset.
+
+`customInfo` always includes the request's query parameters, captured
automatically for every
+event — not just the ones an operation dispatcher explicitly reports. For
example, a listing
+endpoint's `?details=true` shows up in the audit entry for that request even
though no dispatcher
+code added it. Both formatters redact a `customInfo` value, replacing it with
`***`, when its key
+either exactly matches `authorization`, `cookie`, `x-amz-security-token`,
`s3.access-key-id`, or
+`jdbc-password`, or contains (case-insensitively) `password`, `secret`,
`token`, `credential`,
+`apikey`, `accesskey`, `privatekey`, `auth`, or `signature` — so a
caller-named parameter like
+`?token=...` or `?myApiKey=...` is masked even though its exact name was never
enumerated. A short,
+fixed list of keys the server itself always uses (e.g. `http.method`,
`http.status`, `auth.method`)
+is exempt from that substring check, since otherwise `auth.method` would be
masked for merely
+containing "auth".
+
+Every request that reaches the server produces at least one audit entry, even
one whose operation
+has no dedicated `Event` subclass: `HttpAuditFilter` dispatches a generic
fallback event (method,
+URI, status code, and the same auto-captured query parameters) for any request
where no
+operation-layer event fired. On a server that sees a high rate of
otherwise-unaudited calls to the
+same endpoint — for example an Iceberg REST catalog's `/v1/config`, which some
clients poll
+frequently — this can measurably increase audit log volume; size log rotation
and retention in
+`conf/log4j2.properties` (below) accordingly.
`FileAuditWriter` is the default writer, and it manages no files itself.
Rotation, compression, and
retention are delegated to Log4j2 through a logger named `gravitino.audit`,
configured by the
@@ -456,6 +474,12 @@ package.
Throwing a `ForbiddenException` from a pre-event handler stops the operation
before it runs, which
makes pre-events a veto point rather than a notification.
+`customInfo()` on every event includes the request's query parameters, and a
custom listener
+receives them **unredacted** — the masking described under "Audit Logging"
above is applied only by
+the two built-in audit-log formatters at format time, not to the event object
itself. A listener
+that forwards `customInfo()` elsewhere (logs, a metrics pipeline, a downstream
service) is
+responsible for its own redaction if that matters for its destination.
+
A plugin declares how its events are dispatched:
| Mode | Behavior
|
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index 7e37b184cb..af732fbea1 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -60,6 +60,7 @@ import org.apache.gravitino.server.web.HttpAuditFilter;
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.RequestContextFilter;
import
org.apache.gravitino.server.web.filter.IcebergRESTAuthInterceptionService;
import org.glassfish.hk2.api.InterceptionService;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
@@ -204,6 +205,9 @@ public class RESTService implements
GravitinoAuxiliaryService {
Servlet servlet = new ServletContainer(config);
server.addServlet(servlet, ICEBERG_SPEC);
+ // Registered before HttpAuditFilter so audit events dispatched during
this request carry the
+ // request's query parameters and remote address, exactly as on the main
server.
+ server.addFilter(new RequestContextFilter(eventBus), ICEBERG_SPEC);
server.addFilter(
new HttpAuditFilter(
eventBus,
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
index 044000e831..546501913c 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergEvent.java
@@ -53,8 +53,9 @@ public abstract class IcebergEvent extends Event {
return icebergRequestContext.remoteHostName();
}
+ /** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return icebergRequestContext.customInfo();
}
}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
index 403299db12..ea1ef3ea1c 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergFailureEvent.java
@@ -53,8 +53,9 @@ public abstract class IcebergFailureEvent extends
FailureEvent {
return icebergRequestContext.remoteHostName();
}
+ /** {@inheritDoc} */
@Override
- public Map<String, String> customInfo() {
+ protected Map<String, String> ownCustomInfo() {
return icebergRequestContext.customInfo();
}
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableEventDispatcher.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableEventDispatcher.java
index 4bbc70bb18..8456cef293 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableEventDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableEventDispatcher.java
@@ -135,10 +135,33 @@ public class TestIcebergTableEventDispatcher {
Event event = listener.popPostEvent();
Assertions.assertEquals(IcebergCreateTableEvent.class, event.getClass());
- Assertions.assertSame(context.httpHeaders(), event.customInfo());
+ // Content equality, not identity: Event.customInfo() now merges in the
request's
+ // automatically captured query parameters, so the result is always a
freshly built map even
+ // when that automatic contribution is empty.
+ Assertions.assertEquals(context.httpHeaders(), event.customInfo());
Assertions.assertFalse(event.customInfo().containsKey(EXTRA_KEY));
}
+ /**
+ * {@code IcebergEvent}/{@code IcebergFailureEvent} used to override {@code
customInfo()} to
+ * return only the request-context's own facts (headers ∪ extras), silently
discarding {@link
+ * Event}'s automatically captured query parameters. Pins that the two are
now merged, for both
+ * the success and failure event.
+ */
+ @Test
+ void testCustomInfoMergesAutomaticQueryParamsWithRequestContextFacts() {
+ RecordingListener listener = new RecordingListener();
+ IcebergTableEventDispatcher dispatcher = dispatcher(listener,
succeedingInner());
+ RequestContext.setRequestQueryParams(ImmutableMap.of("details", "true"));
+
+ createTable(dispatcher);
+
+ Event event = listener.popPostEvent();
+ Assertions.assertEquals(IcebergCreateTableEvent.class, event.getClass());
+ Assertions.assertEquals("true", event.customInfo().get("details"));
+ Assertions.assertEquals(REQUEST_HEADER_VALUE,
event.customInfo().get(REQUEST_HEADER));
+ }
+
/**
* A contributor that rejected the operation is exactly the case where the
reason matters most, so
* extras have to survive the exception path and reach the failure event.
@@ -232,7 +255,10 @@ public class TestIcebergTableEventDispatcher {
Assertions.assertFalse(
second.customInfo().containsKey(EXTRA_KEY),
"Extras must not survive into a later operation");
- Assertions.assertSame(
+ // Content equality, not identity: Event.customInfo() now merges in the
request's
+ // automatically captured query parameters, so the result is always a
freshly built map even
+ // when that automatic contribution is empty.
+ Assertions.assertEquals(
((IcebergEvent) second).icebergRequestContext().httpHeaders(),
second.customInfo());
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
index 95035cf649..aacb26bfd8 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/listener/api/event/TestIcebergRequestContext.java
@@ -113,8 +113,8 @@ class TestIcebergRequestContext {
/**
* Failure events are the case where the reason matters most. Pins that
{@link
- * IcebergFailureEvent#customInfo()} is headers ∪ extras and that extras
stay off {@code
- * httpHeaders()}.
+ * IcebergFailureEvent#customInfo()} is headers ∪ extras ∪ any automatically
captured request
+ * query parameters, and that extras stay off {@code httpHeaders()}.
*/
@Test
void testFailureEventCustomInfoMergesHeadersAndExtras() {
@@ -137,7 +137,10 @@ class TestIcebergRequestContext {
IcebergLoadTableFailureEvent event =
new IcebergLoadTableFailureEvent(
context, NameIdentifier.of("ml", "cat", "ns", "t"), new
RuntimeException("boom"));
- Assertions.assertSame(context.httpHeaders(), event.customInfo());
+ // Content equality, not identity: Event.customInfo() now merges in the
request's
+ // automatically captured query parameters, so the result is always a
freshly built map even
+ // when that automatic contribution is empty.
+ Assertions.assertEquals(context.httpHeaders(), event.customInfo());
}
private static HttpServletRequest requestWithoutHeader() {
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
index b073c5bde6..67b4065406 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
@@ -45,6 +45,7 @@ import org.apache.gravitino.server.web.HttpAuditFilter;
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.RequestContextFilter;
import org.glassfish.hk2.api.InterceptionService;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.jackson.JacksonFeature;
@@ -129,6 +130,9 @@ public class LanceRESTService implements
GravitinoAuxiliaryService {
Servlet container = new ServletContainer(resourceConfig);
server.addServlet(container, LANCE_SPEC);
+ // Registered before HttpAuditFilter so audit events dispatched during
this request carry the
+ // request's query parameters and remote address, exactly as on the main
server.
+ server.addFilter(new RequestContextFilter(eventBus), LANCE_SPEC);
server.addFilter(
new HttpAuditFilter(
eventBus, EventSource.GRAVITINO_LANCE_REST_SERVER, new
LanceHealthCheckPathMatcher()),
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/HttpAuditFilter.java
b/server-common/src/main/java/org/apache/gravitino/server/web/HttpAuditFilter.java
index 6f6d53202f..29f401bd92 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/web/HttpAuditFilter.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/HttpAuditFilter.java
@@ -37,6 +37,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.listener.EventBus;
import org.apache.gravitino.listener.api.event.EventSource;
+import org.apache.gravitino.listener.api.event.server.HttpRequestEvent;
import org.apache.gravitino.listener.api.event.server.HttpRequestFailureEvent;
import org.apache.gravitino.utils.RequestContext;
import org.slf4j.Logger;
@@ -44,8 +45,10 @@ import org.slf4j.LoggerFactory;
/**
* A servlet filter that emits an {@link HttpRequestFailureEvent} for every
HTTP request that
- * completes with a 4xx or 5xx status code and for which no operation-layer
failure event was
- * already dispatched on the same request thread.
+ * completes with a 4xx or 5xx status code, or an {@link HttpRequestEvent} for
one that completes
+ * with a 2xx/3xx status code, and for which no matching operation-layer event
was already
+ * dispatched on the same request thread. This guarantees every request
produces at least one audit
+ * entry, even for endpoints that are not (yet) wired into the operation-layer
event system.
*
* <p><strong>Filter chain position:</strong> this filter must be registered
<em>after</em> {@link
* RequestContextFilter} (so the remote address is already populated) but
<em>before</em> both
@@ -61,17 +64,21 @@ import org.slf4j.LoggerFactory;
* with {@link DispatcherType#ERROR} to render an error page. This filter
detects that case and
* passes the request straight through without emitting a second event,
preventing double-logging.
*
- * <p><strong>Double-logging prevention:</strong> when an operation-layer
failure event (e.g. {@code
- * LoadTableFailureEvent}, {@code AuthorizationDenialFailureEvent}) has
already been dispatched via
- * {@link EventBus}, {@link RequestContext#markOperationFailureFired()} is set
on the request
- * thread. This filter checks that flag in the {@code finally} block and skips
emitting its own
- * {@link HttpRequestFailureEvent} if the flag is set.
+ * <p><strong>Double-logging prevention:</strong> when an operation-layer
event has already been
+ * dispatched via {@link EventBus} for this request — a failure (e.g. {@code
LoadTableFailureEvent},
+ * {@code AuthorizationDenialFailureEvent}) via {@link
RequestContext#markOperationFailureFired()},
+ * or a success via {@link RequestContext#markOperationSuccessFired()} — this
filter skips emitting
+ * its own fallback event for the same outcome: no {@link
HttpRequestFailureEvent} on a 4xx/5xx
+ * response if a failure was already recorded, and no {@link HttpRequestEvent}
on a 2xx/3xx response
+ * if either a success or a failure was already recorded (a recorded failure
means the fallback
+ * success event would be misleading even if the HTTP layer ultimately
returned a non-error status).
*
- * <p><strong>Success + 5xx edge case:</strong> if an operation dispatcher
emits a {@code
- * SuccessEvent} (flag not set) but the HTTP layer subsequently fails with a
5xx (e.g. JSON
- * serialization error), this filter will emit an {@link
HttpRequestFailureEvent} in addition to the
- * success event already in the audit log. Both entries are correct — the
operation itself succeeded
- * but the response delivery failed — and are intentionally preserved.
+ * <p><strong>Success + 5xx edge case:</strong> if an operation dispatcher
emits a success event but
+ * the HTTP layer subsequently fails with a 5xx (e.g. JSON serialization
error), this filter still
+ * emits an {@link HttpRequestFailureEvent} in addition to the success event
already in the audit
+ * log. Both entries are correct — the operation itself succeeded but the
response delivery failed —
+ * and are intentionally preserved: the 5xx branch above checks only the
failure flag, not the
+ * success one, so a recorded success never suppresses this filter's own
failure fallback.
*
* <p><strong>Health check exclusion:</strong> requests matched by the
configured {@link
* HealthCheckPathMatcher} are silently passed through without audit logging
to avoid polluting the
@@ -96,11 +103,11 @@ public class HttpAuditFilter implements Filter {
/**
* Constructs an {@code HttpAuditFilter} using the default {@link
HealthCheckPathMatcher}.
*
- * @param eventBus the event bus used to dispatch {@link
HttpRequestFailureEvent}s; may be {@code
- * null}, in which case the filter is a pass-through no-op (useful when
no audit listener is
- * configured).
+ * @param eventBus the event bus used to dispatch {@link
HttpRequestFailureEvent}s and {@link
+ * HttpRequestEvent}s; may be {@code null}, in which case the filter is
a pass-through no-op
+ * (useful when no audit listener is configured).
* @param eventSource identifies which server this filter instance is
installed on; included in
- * every emitted {@link HttpRequestFailureEvent}.
+ * every emitted event.
*/
public HttpAuditFilter(@Nullable EventBus eventBus, EventSource eventSource)
{
this(eventBus, eventSource, new HealthCheckPathMatcher());
@@ -109,8 +116,8 @@ public class HttpAuditFilter implements Filter {
/**
* Constructs an {@code HttpAuditFilter} with a custom {@link
HealthCheckPathMatcher}.
*
- * @param eventBus the event bus used to dispatch {@link
HttpRequestFailureEvent}s; may be {@code
- * null}, in which case the filter is a pass-through no-op.
+ * @param eventBus the event bus used to dispatch {@link
HttpRequestFailureEvent}s and {@link
+ * HttpRequestEvent}s; may be {@code null}, in which case the filter is
a pass-through no-op.
* @param eventSource identifies which server this filter instance is
installed on.
* @param healthCheckMatcher determines which URI paths are health check
probes; those paths are
* excluded from audit logging to avoid polluting the audit log with
probe traffic.
@@ -146,11 +153,13 @@ public class HttpAuditFilter implements Filter {
}
// Defensive cleanup at request entry in case a pooled thread leaked stale
state.
RequestContext.resetOperationFailureFired();
+ RequestContext.resetOperationSuccessFired();
if (healthCheckMatcher.isHealthCheckPath(httpRequest.getRequestURI())) {
try {
chain.doFilter(request, response);
} finally {
RequestContext.resetOperationFailureFired();
+ RequestContext.resetOperationSuccessFired();
}
return;
}
@@ -168,9 +177,9 @@ public class HttpAuditFilter implements Filter {
}
} finally {
try {
- if (!RequestContext.isOperationFailureFired()) {
- int status = wrappedResponse.getCapturedStatus();
- if (status >= 400) {
+ int status = wrappedResponse.getCapturedStatus();
+ if (status >= 400) {
+ if (!RequestContext.isOperationFailureFired()) {
String user = resolveUser(httpRequest);
String remoteAddress = resolveClientAddress(httpRequest);
HttpRequestFailureEvent event =
@@ -183,6 +192,19 @@ public class HttpAuditFilter implements Filter {
eventSource);
eventBus.get().dispatchEvent(event);
}
+ } else if (!RequestContext.isOperationFailureFired()
+ && !RequestContext.isOperationSuccessFired()) {
+ String user = resolveUser(httpRequest);
+ String remoteAddress = resolveClientAddress(httpRequest);
+ HttpRequestEvent event =
+ new HttpRequestEvent(
+ user,
+ remoteAddress,
+ httpRequest.getMethod(),
+ httpRequest.getRequestURI(),
+ status,
+ eventSource);
+ eventBus.get().dispatchEvent(event);
}
} catch (Exception e) {
LOG.error(
@@ -191,8 +213,9 @@ public class HttpAuditFilter implements Filter {
httpRequest.getRequestURI(),
e);
} finally {
- // Always clear the flag to prevent ThreadLocal leaks across pooled
threads.
+ // Always clear the flags to prevent ThreadLocal leaks across pooled
threads.
RequestContext.resetOperationFailureFired();
+ RequestContext.resetOperationSuccessFired();
}
}
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
index f5de5185bf..78016bcedb 100644
---
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
@@ -19,7 +19,17 @@
package org.apache.gravitino.server.web;
+import com.google.common.collect.ImmutableMap;
import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.Nullable;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
@@ -28,11 +38,14 @@ import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.audit.AuditLogRedactor;
+import org.apache.gravitino.listener.EventBus;
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.
+ * A servlet filter that captures the client remote address and (raw,
unredacted) query parameters
+ * from each HTTP request and stores them in {@link RequestContext} so that
audit event constructors
+ * can read them 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
@@ -40,12 +53,59 @@ import org.apache.gravitino.utils.RequestContext;
* 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.
+ * <p>Query parameters are parsed from {@link
HttpServletRequest#getQueryString()}, not from {@link
+ * HttpServletRequest#getParameterMap()}. The latter also parses an {@code
+ * application/x-www-form-urlencoded} request body when present, consuming the
request input stream
+ * in the process — a resource endpoint that later reads the body itself (a
future OAuth2
+ * token-style endpoint, for example) would see an empty body with no
indication this filter is the
+ * cause. Parsing the raw query string avoids that hazard entirely and matches
what this class
+ * actually claims to capture.
+ *
+ * <p>Query parameters are captured as-is, not redacted here. Redaction
happens exactly once, at
+ * audit-log format time, via {@link AuditLogRedactor}, applied uniformly to
the fully-merged {@code
+ * customInfo()} map regardless of which layer contributed which key — see
{@link
+ * AuditLogRedactor}'s class doc for why that single pass replaces redacting
at every source
+ * separately. Capturing the query string is skipped entirely when no {@link
EventBus} is supplied,
+ * since nothing will ever read the result.
+ *
+ * <p>The number of distinct parameter names captured is capped at {@value
#MAX_PARAMETERS}, and any
+ * single value is truncated to {@value #MAX_VALUE_LENGTH} characters (marked
with {@value
+ * #TRUNCATED_SUFFIX}) — a caller cannot grow every event constructed during a
request, and every
+ * audit line written for it, by attaching an arbitrarily large query string.
+ *
+ * <p>The stored values are 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";
+ /** Maximum number of distinct query-parameter names captured per request. */
+ static final int MAX_PARAMETERS = 50;
+
+ /** Maximum length of a single captured (post-join) parameter value, before
truncation. */
+ static final int MAX_VALUE_LENGTH = 256;
+
+ /** Appended to a value that was cut short at {@link #MAX_VALUE_LENGTH}. */
+ static final String TRUNCATED_SUFFIX = "...(truncated)";
+
+ private final Optional<EventBus> eventBus;
+
+ /** Constructs a {@code RequestContextFilter} that never captures query
parameters. */
+ public RequestContextFilter() {
+ this(null);
+ }
+
+ /**
+ * Constructs a {@code RequestContextFilter}.
+ *
+ * @param eventBus the event bus that will consume the captured query
parameters; may be {@code
+ * null}, in which case query-parameter capture is skipped
(remote-address capture still
+ * happens, since {@code HttpAuditFilter}'s own fallback events need it
regardless).
+ */
+ public RequestContextFilter(@Nullable EventBus eventBus) {
+ this.eventBus = Optional.ofNullable(eventBus);
+ }
+
@Override
public void init(FilterConfig filterConfig) {}
@@ -54,7 +114,11 @@ public class RequestContextFilter implements Filter {
throws IOException, ServletException {
try {
if (request instanceof HttpServletRequest) {
-
RequestContext.setRemoteAddress(resolveClientAddress((HttpServletRequest)
request));
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
+ RequestContext.setRemoteAddress(resolveClientAddress(httpRequest));
+ if (eventBus.isPresent()) {
+
RequestContext.setRequestQueryParams(parseQueryString(httpRequest.getQueryString()));
+ }
}
chain.doFilter(request, response);
} finally {
@@ -72,4 +136,62 @@ public class RequestContextFilter implements Filter {
}
return request.getRemoteAddr();
}
+
+ /**
+ * Parses a raw HTTP query string into a name-to-value map (joining
multi-valued parameters with a
+ * comma), with no redaction — see the class doc for why that happens later.
Bounded by {@link
+ * #MAX_PARAMETERS} and {@link #MAX_VALUE_LENGTH} so a caller cannot inflate
every event
+ * constructed during this request with an arbitrarily large query string.
+ *
+ * @param queryString the raw (still percent-encoded) query string, or
{@code null}
+ * @return an immutable, bounded, decoded parameter map; empty if {@code
queryString} is {@code
+ * null} or blank
+ */
+ private static Map<String, String> parseQueryString(String queryString) {
+ if (StringUtils.isBlank(queryString)) {
+ return ImmutableMap.of();
+ }
+ Map<String, List<String>> multiValued = new LinkedHashMap<>();
+ for (String pair : queryString.split("&")) {
+ if (pair.isEmpty()) {
+ continue;
+ }
+ int equalsIndex = pair.indexOf('=');
+ String rawName = equalsIndex >= 0 ? pair.substring(0, equalsIndex) :
pair;
+ String rawValue = equalsIndex >= 0 ? pair.substring(equalsIndex + 1) :
"";
+ String name = decode(rawName);
+ // Once the cap is hit, only accumulate more values for names already
being tracked — never
+ // start tracking a new name.
+ if (!multiValued.containsKey(name) && multiValued.size() >=
MAX_PARAMETERS) {
+ continue;
+ }
+ multiValued.computeIfAbsent(name, k -> new
ArrayList<>()).add(decode(rawValue));
+ }
+ ImmutableMap.Builder<String, String> flattened = ImmutableMap.builder();
+ multiValued.forEach((name, values) -> flattened.put(name,
truncate(String.join(",", values))));
+ return flattened.build();
+ }
+
+ private static String truncate(String value) {
+ return value.length() > MAX_VALUE_LENGTH
+ ? value.substring(0, MAX_VALUE_LENGTH) + TRUNCATED_SUFFIX
+ : value;
+ }
+
+ /**
+ * Decodes an {@code application/x-www-form-urlencoded} query-string
component (the encoding every
+ * servlet container assumes for a query string). Malformed percent-encoding
— always possible,
+ * since this is attacker-influenced input — falls back to the raw component
instead of throwing,
+ * so one bad parameter cannot fail the whole request just to be audited.
+ */
+ private static String decode(String value) {
+ try {
+ return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
+ } catch (UnsupportedEncodingException e) {
+ // UTF-8 is always supported by the JVM; unreachable in practice.
+ return value;
+ } catch (IllegalArgumentException e) {
+ return value;
+ }
+ }
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpAuditFilter.java
b/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpAuditFilter.java
index 5cd2c651bf..dd025492ab 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpAuditFilter.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpAuditFilter.java
@@ -38,6 +38,7 @@ import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.listener.EventBus;
import org.apache.gravitino.listener.api.event.BaseEvent;
import org.apache.gravitino.listener.api.event.EventSource;
+import org.apache.gravitino.listener.api.event.server.HttpRequestEvent;
import org.apache.gravitino.listener.api.event.server.HttpRequestFailureEvent;
import org.apache.gravitino.utils.RequestContext;
import org.junit.jupiter.api.AfterEach;
@@ -50,6 +51,7 @@ public class TestHttpAuditFilter {
@AfterEach
public void cleanup() {
RequestContext.resetOperationFailureFired();
+ RequestContext.resetOperationSuccessFired();
RequestContext.clear();
}
@@ -127,10 +129,13 @@ public class TestHttpAuditFilter {
verify(eventBus, never()).dispatchEvent(any());
}
- // ─── Successful 200 — no event
───────────────────────────────────────────────
+ // ─── Successful 200 — fallback HttpRequestEvent
──────────────────────────────
@Test
- public void test200ResponseNoEvent() throws Exception {
+ public void test200ResponseWithNoOperationEventEmitsHttpRequestEvent()
throws Exception {
+ // No operation-layer event was dispatched for this request (e.g. an
endpoint that is not yet
+ // wired into the event system), so the filter must emit a fallback
HttpRequestEvent rather than
+ // leaving the request completely unaudited.
EventBus eventBus = mock(EventBus.class);
HttpAuditFilter filter = new HttpAuditFilter(eventBus,
EventSource.GRAVITINO_SERVER);
HttpServletRequest req = mockRequest("GET", "/api/metalakes", null,
"1.2.3.4");
@@ -139,9 +144,76 @@ public class TestHttpAuditFilter {
filter.doFilter(req, resp, chain);
+ ArgumentCaptor<BaseEvent> captor =
ArgumentCaptor.forClass(BaseEvent.class);
+ verify(eventBus).dispatchEvent(captor.capture());
+ Assertions.assertInstanceOf(HttpRequestEvent.class, captor.getValue());
+ HttpRequestEvent event = (HttpRequestEvent) captor.getValue();
+ Assertions.assertEquals(200, event.statusCode());
+ Assertions.assertEquals("GET", event.httpMethod());
+ Assertions.assertEquals("/api/metalakes", event.requestUri());
+ Assertions.assertEquals(EventSource.GRAVITINO_SERVER, event.eventSource());
+ }
+
+ @Test
+ public void testOperationSuccessFiredFlagPreventsHttpRequestEvent() throws
Exception {
+ // An operation-layer success event (e.g. LoadTableEvent) was already
dispatched for this
+ // request, so the filter must not emit a redundant fallback event.
+ EventBus eventBus = mock(EventBus.class);
+ HttpAuditFilter filter = new HttpAuditFilter(eventBus,
EventSource.GRAVITINO_SERVER);
+ HttpServletRequest req = mockRequest("GET", "/api/metalakes/m1", null,
"1.2.3.4");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ FilterChain chain =
+ (request, response) -> {
+ RequestContext.markOperationSuccessFired();
+ ((HttpServletResponse) response).setStatus(200);
+ };
+
+ filter.doFilter(req, resp, chain);
+
verify(eventBus, never()).dispatchEvent(any());
}
+ /**
+ * Pins a fix to the success-fallback guard: it now also checks the failure
flag, not just the
+ * success flag, so a recorded operation-layer failure never gets a
contradictory success-shaped
+ * fallback event just because the HTTP layer happened to resolve to a
non-error status.
+ */
+ @Test
+ public void testOperationFailureFiredFlagAlsoPreventsHttpRequestEvent()
throws Exception {
+ EventBus eventBus = mock(EventBus.class);
+ HttpAuditFilter filter = new HttpAuditFilter(eventBus,
EventSource.GRAVITINO_SERVER);
+ HttpServletRequest req = mockRequest("POST",
"/api/catalogs/test-connection", null, "1.2.3.4");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ FilterChain chain =
+ (request, response) -> {
+ RequestContext.markOperationFailureFired();
+ ((HttpServletResponse) response).setStatus(200);
+ };
+
+ filter.doFilter(req, resp, chain);
+
+ verify(eventBus, never()).dispatchEvent(any());
+ }
+
+ @Test
+ public void testSuccessFlagClearedAfterNormalCompletion() throws Exception {
+ EventBus eventBus = mock(EventBus.class);
+ HttpAuditFilter filter = new HttpAuditFilter(eventBus,
EventSource.GRAVITINO_SERVER);
+ HttpServletRequest req = mockRequest("GET", "/api/metalakes/m1", null,
"1.2.3.4");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ FilterChain chain =
+ (request, response) -> {
+ RequestContext.markOperationSuccessFired();
+ ((HttpServletResponse) response).setStatus(200);
+ };
+
+ filter.doFilter(req, resp, chain);
+
+ Assertions.assertFalse(
+ RequestContext.isOperationSuccessFired(),
+ "operationSuccessFired flag must be cleared after filter completes");
+ }
+
// ─── 4xx via sendError
───────────────────────────────────────────────────────
@Test
@@ -371,15 +443,20 @@ public class TestHttpAuditFilter {
@Test
public void testSuccessEventFollowedBy5xxStillEmitsHttpFailureEvent() throws
Exception {
- // An operation dispatcher emits a SuccessEvent (operationFailureFired
stays false),
- // but the HTTP response ends up as 500 (e.g. JSON serialization failure).
- // HttpAuditFilter must still emit HttpRequestFailureEvent for the
HTTP-layer failure.
+ // An operation dispatcher emits a SuccessEvent (operationSuccessFired set,
+ // operationFailureFired
+ // stays false), but the HTTP response ends up as 500 (e.g. JSON
serialization failure).
+ // HttpAuditFilter must still emit HttpRequestFailureEvent for the
HTTP-layer failure — the
+ // success flag must not suppress it, since the two flags are tracked
independently.
EventBus eventBus = mock(EventBus.class);
HttpAuditFilter filter = new HttpAuditFilter(eventBus,
EventSource.GRAVITINO_SERVER);
HttpServletRequest req = mockRequest("GET", "/api/metalakes/m1", null,
"1.2.3.4");
HttpServletResponse resp = mock(HttpServletResponse.class);
- // operationFailureFired is NOT set (success event path)
- FilterChain chain = (request, response) -> ((HttpServletResponse)
response).sendError(500);
+ FilterChain chain =
+ (request, response) -> {
+ RequestContext.markOperationSuccessFired();
+ ((HttpServletResponse) response).sendError(500);
+ };
filter.doFilter(req, resp, chain);
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
index 1b69f03ac4..14ab58acaf 100644
---
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
@@ -23,11 +23,16 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.listener.EventBus;
import org.apache.gravitino.utils.RequestContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
@@ -35,7 +40,11 @@ import org.junit.jupiter.api.Test;
public class TestRequestContextFilter {
+ // No EventBus: exercises the remote-address-only path shared by every
request.
private final RequestContextFilter filter = new RequestContextFilter();
+ // With an EventBus configured: exercises query-parameter capture, which is
otherwise skipped.
+ private final RequestContextFilter filterWithEventBus =
+ new RequestContextFilter(new EventBus(Collections.emptyList()));
@AfterEach
public void cleanup() {
@@ -117,4 +126,194 @@ public class TestRequestContextFilter {
Assertions.assertNull(
RequestContext.getRemoteAddress(), "ThreadLocal must be cleared even
when chain throws");
}
+
+ /**
+ * Query parameters are captured raw here, not redacted — redaction happens
once, uniformly, at
+ * audit-log format time (see AuditLogRedactor's class doc). So a
sensitive-looking parameter like
+ * "token" must come through as its real value at this layer, not already
masked.
+ */
+ @Test
+ public void testQueryParamsCapturedRaw() 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");
+ when(req.getQueryString()).thenReturn("details=true&token=secret-value");
+
+ AtomicReference<Map<String, String>> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRequestQueryParams());
+
+ filterWithEventBus.doFilter(req, resp, chain);
+
+ Assertions.assertEquals("true", captured.get().get("details"));
+ Assertions.assertEquals("secret-value", captured.get().get("token"));
+ }
+
+ @Test
+ public void testMultiValuedQueryParamIsJoinedWithComma() 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");
+ when(req.getQueryString()).thenReturn("keyword=a&keyword=b&keyword=c");
+
+ AtomicReference<Map<String, String>> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRequestQueryParams());
+
+ filterWithEventBus.doFilter(req, resp, chain);
+
+ Assertions.assertEquals("a,b,c", captured.get().get("keyword"));
+ }
+
+ /**
+ * Query strings are percent-encoded ({@code
application/x-www-form-urlencoded}); both the name
+ * and the value must come back decoded, including {@code +} decoding to a
space.
+ */
+ @Test
+ public void testQueryParamsAreUrlDecoded() 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");
+ when(req.getQueryString()).thenReturn("full+name=Alice+Smith&sym=%26");
+
+ AtomicReference<Map<String, String>> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRequestQueryParams());
+
+ filterWithEventBus.doFilter(req, resp, chain);
+
+ Assertions.assertEquals("Alice Smith", captured.get().get("full name"));
+ Assertions.assertEquals("&", captured.get().get("sym"));
+ }
+
+ /**
+ * A name with no {@code =} (e.g. {@code ?flag}) is a valid query string; it
must be captured with
+ * an empty value rather than crashing the whole request.
+ */
+ @Test
+ public void testValuelessParamDoesNotThrow() 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");
+ when(req.getQueryString()).thenReturn("flag");
+
+ AtomicReference<Map<String, String>> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRequestQueryParams());
+
+ Assertions.assertDoesNotThrow(() -> filterWithEventBus.doFilter(req, resp,
chain));
+ Assertions.assertEquals("", captured.get().get("flag"));
+ }
+
+ /**
+ * The query string is attacker-influenced input; malformed percent-encoding
(e.g. a truncated
+ * {@code %} escape) must not crash the whole request — it falls back to the
raw component.
+ */
+ @Test
+ public void testMalformedPercentEncodingDoesNotThrow() 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");
+ when(req.getQueryString()).thenReturn("bad=100%");
+
+ AtomicReference<Map<String, String>> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRequestQueryParams());
+
+ Assertions.assertDoesNotThrow(() -> filterWithEventBus.doFilter(req, resp,
chain));
+ Assertions.assertEquals("100%", captured.get().get("bad"));
+ }
+
+ /**
+ * Pins the bound on parameter count: a query string with more distinct
names than {@link
+ * RequestContextFilter#MAX_PARAMETERS} must not grow the captured map
without limit.
+ */
+ @Test
+ public void testParameterCountIsCapped() 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");
+ String queryString =
+ IntStream.range(0, RequestContextFilter.MAX_PARAMETERS + 20)
+ .mapToObj(i -> "p" + i + "=v")
+ .collect(Collectors.joining("&"));
+ when(req.getQueryString()).thenReturn(queryString);
+
+ AtomicReference<Map<String, String>> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRequestQueryParams());
+
+ filterWithEventBus.doFilter(req, resp, chain);
+
+ Assertions.assertEquals(RequestContextFilter.MAX_PARAMETERS,
captured.get().size());
+ }
+
+ /**
+ * Pins the bound on value length: a single oversized value must be
truncated with an explicit
+ * marker, not copied in full into every event constructed during the
request.
+ */
+ @Test
+ public void testValueLengthIsTruncated() 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");
+ String hugeValue =
+ String.join("",
Collections.nCopies(RequestContextFilter.MAX_VALUE_LENGTH + 100, "a"));
+ when(req.getQueryString()).thenReturn("big=" + hugeValue);
+
+ AtomicReference<Map<String, String>> captured = new AtomicReference<>();
+ FilterChain chain = (request, response) ->
captured.set(RequestContext.getRequestQueryParams());
+
+ filterWithEventBus.doFilter(req, resp, chain);
+
+ String capturedBig = captured.get().get("big");
+
Assertions.assertTrue(capturedBig.endsWith(RequestContextFilter.TRUNCATED_SUFFIX),
capturedBig);
+ Assertions.assertEquals(
+ RequestContextFilter.MAX_VALUE_LENGTH +
RequestContextFilter.TRUNCATED_SUFFIX.length(),
+ capturedBig.length());
+ }
+
+ @Test
+ public void testQueryParamsClearedAfterChain() 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");
+ when(req.getQueryString()).thenReturn("details=true");
+
+ filterWithEventBus.doFilter(req, resp, (request, response) -> {});
+
+ Assertions.assertTrue(
+ RequestContext.getRequestQueryParams().isEmpty(),
+ "query-param ThreadLocal must be cleared after chain completes");
+ }
+
+ /**
+ * Pins the efficiency fix: when no EventBus is configured, nothing will
ever read the captured
+ * query parameters, so capture (and its redaction-list scanning) is skipped
entirely — remote
+ * address is still captured, since HttpAuditFilter's own fallback events
need it regardless of
+ * whether any listener is configured.
+ */
+ @Test
+ public void testQueryParamsNotCapturedWithoutEventBus() 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");
+ when(req.getQueryString()).thenReturn("details=true");
+
+ AtomicReference<Map<String, String>> capturedParams = new
AtomicReference<>();
+ AtomicReference<String> capturedAddress = new AtomicReference<>();
+ FilterChain chain =
+ (request, response) -> {
+ capturedParams.set(RequestContext.getRequestQueryParams());
+ capturedAddress.set(RequestContext.getRemoteAddress());
+ };
+
+ filter.doFilter(req, resp, chain);
+
+ Assertions.assertTrue(capturedParams.get().isEmpty());
+ Assertions.assertEquals("1.2.3.4", capturedAddress.get());
+ }
}
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 880639e318..c4891ba859 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -202,7 +202,7 @@ public class GravitinoServer extends ResourceConfig {
server.addServlet(new HealthAliasServlet(), "/health/*");
server.addServlet(new HealthAliasServlet(), "/health.html");
- server.addFilter(new RequestContextFilter(), API_ANY_PATH);
+ server.addFilter(new RequestContextFilter(gravitinoEnv.eventBus()),
API_ANY_PATH);
server.addFilter(
new HttpAuditFilter(gravitinoEnv.eventBus(),
EventSource.GRAVITINO_SERVER), API_ANY_PATH);
server.addCustomFilters(API_ANY_PATH);