This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new fed540e080 [Cherry-pick to branch-1.3] [#14468] fix(iceberg-rest):
record IRC creators, modifiers, denials and metrics reports in audit (#14470)
fed540e080 is described below
commit fed540e080b7b400c629603ab31678578d0485ae
Author: Mark Hoerth <[email protected]>
AuthorDate: Sun Nov 1 19:28:03 2026 -0800
[Cherry-pick to branch-1.3] [#14468] fix(iceberg-rest): record IRC
creators, modifiers, denials and metrics reports in audit (#14470)
Backport to branch-1.3 of the fix for #14468. Same change as the PR to
main, except that branch-1.3 has no view registration, so that hook is
unchanged.
### What changes were proposed in this pull request?
- **Creator:** the namespace, table and view hooks record the
authenticated IRC user and the create time on the imported entity when
the catalog reports no creator
(`IcebergEntityAuditUtils.recordCreator`). A creator the catalog reports
is kept. Every namespace a nested create makes gets its creator.
Registration does the same. The object already exists at that point, so
a failure to record the audit is logged rather than failing the request.
- **Modifier:** a commit that adds or selects a schema brings the stored
columns up to date before the request returns, while it runs as the
committing user. A later background sync or read then finds nothing to
change. Commits that only add snapshots do no extra load.
- **Denials:** `BaseMetadataAuthorizationMethodInterceptor` dispatches
an `AuthorizationDenialFailureEvent` for each `ForbiddenException` when
the protocol returns an event source from the new
`authorizationDenialEventSource()` hook. The event carries the target
object, or the metalake for a failed user check. The IRC interceptor
returns `GRAVITINO_ICEBERG_REST_SERVER`. Lance keeps the default and
records no event. Dispatch failures are logged and never change the
response.
- **Metrics:** a new default method
`IcebergTableOperationDispatcher.checkTableMetricsReport` holds the
existence and table-name checks. `IcebergTableEventDispatcher` wraps it
in the new `IcebergReportTableMetricsPreEvent`,
`IcebergReportTableMetricsEvent` and
`IcebergReportTableMetricsFailureEvent`, with the new
`OperationType.REPORT_TABLE_METRICS`, mapped to audit
`Operation.REPORT_TABLE_METRICS`. The check no longer fires a
table-exists event.
### Why are the changes needed?
Audit reviews could not see who created or changed IRC objects, could
not find IRC denials by operation or object, and saw table-existence
checks that no client made.
Fixes #14468
### Does this PR introduce _any_ user-facing change?
Yes. IRC-created objects show `creator` and `createTime`; schema changes
are attributed to the committing user; IRC denials are audited as
`AUTHORIZATION_DENIAL`; metrics reports are audited as
`REPORT_TABLE_METRICS`.
### How was this patch tested?
- `TestIcebergTableHookDispatcher`: the creator is recorded on create; a
schema-change commit loads the table to sync columns, other commits do
not.
- `TestIcebergNamespaceHookDispatcher`, `TestIcebergViewHookDispatcher`:
the creator is recorded for each created namespace and for a created
view.
- `TestIcebergEntityAuditUtils`: a reported creator is kept.
- `TestBaseMetadataAuthorizationMethodInterceptor`: expression and
user-validation denials dispatch the event with the right object,
expression and source; no event without a source or for internal
failures.
- `TestIcebergTableOperations#testReportTableMetrics`: a report fires
only `REPORT_TABLE_METRICS` events, and a missing table fires the
failure event.
`./gradlew :core:test --tests 'org.apache.gravitino.audit.*'
:server-common:test :iceberg:iceberg-rest-server:test -PskipITs`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Eu6fb9pLfmrSJzGQDVLi1g
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../java/org/apache/gravitino/audit/AuditLog.java | 2 +
.../gravitino/audit/v2/CompatibilityUtils.java | 1 +
.../listener/api/event/OperationType.java | 1 +
.../dispatcher/IcebergEntityAuditUtils.java | 146 +++++++++++++++++++++
.../dispatcher/IcebergNamespaceHookDispatcher.java | 16 +++
.../dispatcher/IcebergTableEventDispatcher.java | 23 ++++
.../dispatcher/IcebergTableHookDispatcher.java | 24 ++++
.../IcebergTableOperationDispatcher.java | 39 ++++++
.../dispatcher/IcebergViewHookDispatcher.java | 8 ++
.../service/rest/IcebergTableOperations.java | 23 +---
.../api/event/IcebergReportTableMetricsEvent.java | 43 ++++++
.../IcebergReportTableMetricsFailureEvent.java | 44 +++++++
.../event/IcebergReportTableMetricsPreEvent.java | 43 ++++++
...bergMetadataAuthorizationMethodInterceptor.java | 6 +
.../dispatcher/TestIcebergEntityAuditUtils.java | 47 +++++++
.../TestIcebergNamespaceHookDispatcher.java | 12 +-
.../dispatcher/TestIcebergTableHookDispatcher.java | 76 +++++++++++
.../dispatcher/TestIcebergViewHookDispatcher.java | 4 +
.../service/rest/TestIcebergTableOperations.java | 17 +++
...BaseMetadataAuthorizationMethodInterceptor.java | 50 +++++++
...BaseMetadataAuthorizationMethodInterceptor.java | 82 ++++++++++++
21 files changed, 684 insertions(+), 23 deletions(-)
diff --git a/core/src/main/java/org/apache/gravitino/audit/AuditLog.java
b/core/src/main/java/org/apache/gravitino/audit/AuditLog.java
index 5ec07983c7..76b96759f1 100644
--- a/core/src/main/java/org/apache/gravitino/audit/AuditLog.java
+++ b/core/src/main/java/org/apache/gravitino/audit/AuditLog.java
@@ -272,6 +272,8 @@ public interface AuditLog {
TABLE_EXISTS,
+ REPORT_TABLE_METRICS,
+
PARTITION_EXIST,
ADD_PARTITION,
diff --git
a/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
b/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
index 3c4c905997..fdce3f45d0 100644
--- a/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
+++ b/core/src/main/java/org/apache/gravitino/audit/v2/CompatibilityUtils.java
@@ -60,6 +60,7 @@ public class CompatibilityUtils {
.put(OperationType.RENAME_TABLE, Operation.RENAME_TABLE)
.put(OperationType.REGISTER_TABLE, Operation.REGISTER_TABLE)
.put(OperationType.TABLE_EXISTS, Operation.TABLE_EXISTS)
+ .put(OperationType.REPORT_TABLE_METRICS,
Operation.REPORT_TABLE_METRICS)
.put(OperationType.CREATE_TAG, Operation.CREATE_TAG)
.put(OperationType.GET_TAG, Operation.GET_TAG)
.put(OperationType.GET_TAG_FOR_METADATA_OBJECT,
Operation.GET_TAG_FOR_METADATA_OBJECT)
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
b/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
index e6cf6f81bb..2ffaf3701f 100644
---
a/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
+++
b/core/src/main/java/org/apache/gravitino/listener/api/event/OperationType.java
@@ -32,6 +32,7 @@ public enum OperationType {
RENAME_TABLE,
REGISTER_TABLE,
TABLE_EXISTS,
+ REPORT_TABLE_METRICS,
// Tag operations
CREATE_TAG,
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergEntityAuditUtils.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergEntityAuditUtils.java
new file mode 100644
index 0000000000..76a668c9e6
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergEntityAuditUtils.java
@@ -0,0 +1,146 @@
+/*
+ * 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.iceberg.service.dispatcher;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.time.Instant;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.SchemaEntity;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.meta.ViewEntity;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Records who created an object through the Iceberg REST catalog. The Iceberg
catalog reports no
+ * creator or create time, so an object imported into Gravitino after an IRC
create would otherwise
+ * have an empty audit.
+ */
+final class IcebergEntityAuditUtils {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(IcebergEntityAuditUtils.class);
+
+ private IcebergEntityAuditUtils() {}
+
+ /**
+ * Sets the creator and create time of an imported schema, table or view
whose stored audit has no
+ * creator. A creator the catalog already reported is kept. The object
already exists when this is
+ * called, so a failure to record the audit is logged rather than failing
the request.
+ *
+ * @param identifier the Gravitino identifier of the object
+ * @param type {@link Entity.EntityType#SCHEMA}, {@link
Entity.EntityType#TABLE} or {@link
+ * Entity.EntityType#VIEW}
+ * @param user the authenticated user who created the object
+ */
+ static void recordCreator(NameIdentifier identifier, Entity.EntityType type,
String user) {
+ EntityStore store = GravitinoEnv.getInstance().entityStore();
+ if (store == null) {
+ return;
+ }
+ Instant createTime = Instant.now();
+ try {
+ switch (type) {
+ case SCHEMA:
+ store.update(
+ identifier,
+ SchemaEntity.class,
+ type,
+ schema -> withCreator(schema, withCreator(schema.auditInfo(),
user, createTime)));
+ break;
+ case TABLE:
+ store.update(
+ identifier,
+ TableEntity.class,
+ type,
+ table -> withCreator(table, withCreator(table.auditInfo(), user,
createTime)));
+ break;
+ case VIEW:
+ store.update(
+ identifier,
+ ViewEntity.class,
+ type,
+ view -> withCreator(view, withCreator(view.auditInfo(), user,
createTime)));
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported entity type: " +
type);
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to record the creator of {} {} created by {}", type,
identifier, user, e);
+ }
+ }
+
+ @VisibleForTesting
+ static AuditInfo withCreator(AuditInfo audit, String user, Instant
createTime) {
+ if (audit != null && audit.creator() != null) {
+ return audit;
+ }
+ return AuditInfo.builder()
+ .withCreator(user)
+ .withCreateTime(createTime)
+ .withLastModifier(audit == null ? null : audit.lastModifier())
+ .withLastModifiedTime(audit == null ? null : audit.lastModifiedTime())
+ .build();
+ }
+
+ private static SchemaEntity withCreator(SchemaEntity schema, AuditInfo
audit) {
+ return SchemaEntity.builder()
+ .withId(schema.id())
+ .withName(schema.name())
+ .withNamespace(schema.namespace())
+ .withComment(schema.comment())
+ .withProperties(schema.properties())
+ .withAuditInfo(audit)
+ .build();
+ }
+
+ private static TableEntity withCreator(TableEntity table, AuditInfo audit) {
+ return TableEntity.builder()
+ .withId(table.id())
+ .withName(table.name())
+ .withNamespace(table.namespace())
+ .withComment(table.comment())
+ .withProperties(table.properties())
+ .withColumns(table.columns())
+ .withPartitioning(table.partitioning())
+ .withDistribution(table.distribution())
+ .withSortOrders(table.sortOrders())
+ .withIndexes(table.indexes())
+ .withAuditInfo(audit)
+ .build();
+ }
+
+ private static ViewEntity withCreator(ViewEntity view, AuditInfo audit) {
+ return ViewEntity.builder()
+ .withId(view.id())
+ .withName(view.name())
+ .withNamespace(view.namespace())
+ .withComment(view.comment())
+ .withColumns(view.columns())
+ .withRepresentations(view.representations())
+ .withDefaultCatalog(view.defaultCatalog())
+ .withDefaultSchema(view.defaultSchema())
+ .withProperties(view.properties())
+ .withAuditInfo(audit)
+ .build();
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
index 635f766256..6cc7169a82 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.iceberg.service.dispatcher;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import org.apache.gravitino.Entity;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.catalog.SchemaDispatcher;
@@ -86,6 +87,13 @@ public class IcebergNamespaceHookDispatcher implements
IcebergNamespaceOperation
// getMissingAncestors() only returns the not-yet-existing ancestors;
append the leaf so that
// every newly-created namespace in this request gets an owner assigned.
newlyOwned.add(leaf);
+ for (Namespace created : newlyOwned) {
+ IcebergEntityAuditUtils.recordCreator(
+ IcebergIdentifierUtils.toGravitinoSchemaIdentifier(
+ metalake, catalogName, created,
HierarchicalSchemaUtil.schemaSeparator()),
+ Entity.EntityType.SCHEMA,
+ context.userName());
+ }
IcebergOwnershipUtils.setSchemaOwners(
metalake,
catalogName,
@@ -187,6 +195,14 @@ public class IcebergNamespaceHookDispatcher implements
IcebergNamespaceOperation
// but not in Gravitino, and silently swallowing that would mislead
callers into thinking the
// entity is registered. Surface the failure so the caller can react.
importTable(context.catalogName(), namespace, registerTableRequest.name());
+ IcebergEntityAuditUtils.recordCreator(
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ metalake,
+ context.catalogName(),
+ TableIdentifier.of(namespace, registerTableRequest.name()),
+ HierarchicalSchemaUtil.schemaSeparator()),
+ Entity.EntityType.TABLE,
+ context.userName());
IcebergOwnershipUtils.setTableOwner(
metalake,
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
index 9a6cb21ee2..834b137ecc 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableEventDispatcher.java
@@ -46,6 +46,9 @@ import
org.apache.gravitino.listener.api.event.IcebergPlanTableScanPreEvent;
import org.apache.gravitino.listener.api.event.IcebergRenameTableEvent;
import org.apache.gravitino.listener.api.event.IcebergRenameTableFailureEvent;
import org.apache.gravitino.listener.api.event.IcebergRenameTablePreEvent;
+import org.apache.gravitino.listener.api.event.IcebergReportTableMetricsEvent;
+import
org.apache.gravitino.listener.api.event.IcebergReportTableMetricsFailureEvent;
+import
org.apache.gravitino.listener.api.event.IcebergReportTableMetricsPreEvent;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.listener.api.event.IcebergTableExistsEvent;
import org.apache.gravitino.listener.api.event.IcebergTableExistsFailureEvent;
@@ -55,6 +58,7 @@ import
org.apache.gravitino.listener.api.event.IcebergUpdateTableFailureEvent;
import org.apache.gravitino.listener.api.event.IcebergUpdateTablePreEvent;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.metrics.MetricsReport;
import org.apache.iceberg.rest.requests.CreateTableRequest;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
@@ -224,6 +228,25 @@ public class IcebergTableEventDispatcher implements
IcebergTableOperationDispatc
return isExists;
}
+ @Override
+ public void checkTableMetricsReport(
+ IcebergRequestContext context, TableIdentifier tableIdentifier,
MetricsReport report) {
+ NameIdentifier gravitinoNameIdentifier =
+ IcebergRESTUtils.getGravitinoNameIdentifier(
+ metalakeName, context.catalogName(), tableIdentifier);
+ eventBus.dispatchEvent(new IcebergReportTableMetricsPreEvent(context,
gravitinoNameIdentifier));
+ try {
+ // The delegate checks existence without a table-exists event of its
own: the client
+ // reported metrics, it did not ask whether the table exists.
+ icebergTableOperationDispatcher.checkTableMetricsReport(context,
tableIdentifier, report);
+ } catch (Exception e) {
+ eventBus.dispatchEvent(
+ new IcebergReportTableMetricsFailureEvent(context,
gravitinoNameIdentifier, e));
+ throw e;
+ }
+ eventBus.dispatchEvent(new IcebergReportTableMetricsEvent(context,
gravitinoNameIdentifier));
+ }
+
@Override
public void renameTable(IcebergRequestContext context, RenameTableRequest
renameTableRequest) {
TableIdentifier sourceTable = renameTableRequest.source();
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
index bf95e0a5a3..9a6689bdc4 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
@@ -18,6 +18,7 @@
*/
package org.apache.gravitino.iceberg.service.dispatcher;
+import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.time.Instant;
import java.util.Optional;
@@ -34,6 +35,7 @@ import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.TableEntity;
import org.apache.gravitino.utils.HierarchicalSchemaUtil;
import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.iceberg.MetadataUpdate;
import org.apache.iceberg.UpdateRequirement;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
@@ -92,6 +94,11 @@ public class IcebergTableHookDispatcher implements
IcebergTableOperationDispatch
.anyMatch(UpdateRequirement.AssertTableDoesNotExist.class::isInstance);
if (isStagedCreateCommit) {
importTableAndSetOwner(context, tableIdentifier.namespace(),
tableIdentifier.name());
+ } else if (changesSchema(updateTableRequest)) {
+ // Bring the stored columns up to date now, while the request runs as
the committing user, so
+ // the change is recorded as theirs. Left to the next load, it is
recorded as whoever loads
+ // the table first, or as anonymous when that is a background listener.
+ importTableEntity(context.catalogName(), tableIdentifier.namespace(),
tableIdentifier.name());
}
return response;
}
@@ -203,12 +210,29 @@ public class IcebergTableHookDispatcher implements
IcebergTableOperationDispatch
return dispatcher.getTableMetadataLocation(context, tableIdentifier);
}
+ @VisibleForTesting
+ static boolean changesSchema(UpdateTableRequest updateTableRequest) {
+ return updateTableRequest.updates().stream()
+ .anyMatch(
+ update ->
+ update instanceof MetadataUpdate.AddSchema
+ || update instanceof MetadataUpdate.SetCurrentSchema);
+ }
+
private void importTableAndSetOwner(
IcebergRequestContext context, Namespace namespace, String tableName) {
// Import is intentionally NOT wrapped in try-catch: if it fails the table
exists in Iceberg
// but not in Gravitino, and silently swallowing that would mislead
callers into thinking the
// entity is registered. Surface the failure so the caller can react.
importTableEntity(context.catalogName(), namespace, tableName);
+ IcebergEntityAuditUtils.recordCreator(
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ metalake,
+ context.catalogName(),
+ TableIdentifier.of(namespace, tableName),
+ HierarchicalSchemaUtil.schemaSeparator()),
+ Entity.EntityType.TABLE,
+ context.userName());
IcebergOwnershipUtils.setTableOwner(
metalake,
context.catalogName(),
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
index 5a90842dbb..f4c02acae4 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationDispatcher.java
@@ -23,6 +23,10 @@ import java.util.Optional;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.apache.iceberg.metrics.CommitReport;
+import org.apache.iceberg.metrics.MetricsReport;
+import org.apache.iceberg.metrics.ScanReport;
import org.apache.iceberg.rest.requests.CreateTableRequest;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
import org.apache.iceberg.rest.requests.RenameTableRequest;
@@ -99,6 +103,41 @@ public interface IcebergTableOperationDispatcher {
*/
boolean tableExists(IcebergRequestContext context, TableIdentifier
tableIdentifier);
+ /**
+ * Checks that a metrics report can be recorded for an Iceberg table: the
table must exist and the
+ * report must name it.
+ *
+ * @param context Iceberg REST request context information.
+ * @param tableIdentifier The Iceberg table identifier from the request path.
+ * @param report The metrics report sent by the client.
+ * @throws NoSuchTableException if the table does not exist.
+ * @throws IllegalArgumentException if the report is of an unsupported type
or names another
+ * table.
+ */
+ default void checkTableMetricsReport(
+ IcebergRequestContext context, TableIdentifier tableIdentifier,
MetricsReport report) {
+ if (!tableExists(context, tableIdentifier)) {
+ throw new NoSuchTableException("Table %s not found", tableIdentifier);
+ }
+ String reportedTableName;
+ if (report instanceof CommitReport) {
+ reportedTableName = ((CommitReport) report).tableName();
+ } else if (report instanceof ScanReport) {
+ reportedTableName = ((ScanReport) report).tableName();
+ } else {
+ throw new IllegalArgumentException("Unsupported metrics report type");
+ }
+ String tableName = tableIdentifier.name();
+ String qualifiedTableName = tableIdentifier.toString();
+ if (reportedTableName == null
+ || (!tableName.equals(reportedTableName)
+ && !qualifiedTableName.equals(reportedTableName)
+ && !reportedTableName.endsWith("." + qualifiedTableName))) {
+ throw new IllegalArgumentException(
+ "Metrics report table name does not match the requested table");
+ }
+ }
+
/**
* Rename an Iceberg table.
*
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
index bce140877b..650276d8eb 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
@@ -73,6 +73,14 @@ public class IcebergViewHookDispatcher implements
IcebergViewOperationDispatcher
// Then import it into Gravitino so Gravitino is aware of the view
importView(context.catalogName(), namespace, createViewRequest.name());
+ IcebergEntityAuditUtils.recordCreator(
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ metalake,
+ context.catalogName(),
+ TableIdentifier.of(namespace, createViewRequest.name()),
+ HierarchicalSchemaUtil.schemaSeparator()),
+ Entity.EntityType.VIEW,
+ context.userName());
// Set ownership for the newly created view.
IcebergOwnershipUtils.setViewOwner(
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
index 9be8e9aceb..d468207259 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergTableOperations.java
@@ -77,10 +77,7 @@ import org.apache.iceberg.SnapshotRef;
import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
-import org.apache.iceberg.exceptions.NoSuchTableException;
-import org.apache.iceberg.metrics.CommitReport;
import org.apache.iceberg.metrics.MetricsReport;
-import org.apache.iceberg.metrics.ScanReport;
import org.apache.iceberg.rest.RESTUtil;
import org.apache.iceberg.rest.requests.CreateTableRequest;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
@@ -435,26 +432,8 @@ public class IcebergTableOperations {
IcebergRequestContext context =
new IcebergRequestContext(httpServletRequest(), catalogName);
TableIdentifier tableIdentifier = TableIdentifier.of(icebergNS,
tableName);
- if (!tableOperationDispatcher.tableExists(context,
tableIdentifier)) {
- throw new NoSuchTableException("Table %s not found",
tableIdentifier);
- }
MetricsReport report = request.report();
- String reportedTableName;
- if (report instanceof CommitReport) {
- reportedTableName = ((CommitReport) report).tableName();
- } else if (report instanceof ScanReport) {
- reportedTableName = ((ScanReport) report).tableName();
- } else {
- throw new IllegalArgumentException("Unsupported metrics report
type");
- }
- String qualifiedTableName = tableIdentifier.toString();
- if (reportedTableName == null
- || (!tableName.equals(reportedTableName)
- && !qualifiedTableName.equals(reportedTableName)
- && !reportedTableName.endsWith("." + qualifiedTableName)))
{
- throw new IllegalArgumentException(
- "Metrics report table name does not match the requested
table");
- }
+ tableOperationDispatcher.checkTableMetricsReport(context,
tableIdentifier, report);
boolean accepted = icebergMetricsManager.recordMetric(catalogName,
icebergNS, report);
if (accepted) {
return IcebergRESTUtils.noContent();
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsEvent.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsEvent.java
new file mode 100644
index 0000000000..2b297011a8
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsEvent.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represents an event after an Iceberg table metrics report is accepted for
a table. */
+@DeveloperApi
+public class IcebergReportTableMetricsEvent extends IcebergTableEvent {
+
+ /**
+ * Creates the event.
+ *
+ * @param icebergRequestContext the Iceberg REST request context
+ * @param tableIdentifier the Gravitino identifier of the table the report
is for
+ */
+ public IcebergReportTableMetricsEvent(
+ IcebergRequestContext icebergRequestContext, NameIdentifier
tableIdentifier) {
+ super(icebergRequestContext, tableIdentifier);
+ }
+
+ @Override
+ public OperationType operationType() {
+ return OperationType.REPORT_TABLE_METRICS;
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsFailureEvent.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsFailureEvent.java
new file mode 100644
index 0000000000..6e9396df03
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsFailureEvent.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represents a failure event when an Iceberg table metrics report is
rejected. */
+@DeveloperApi
+public class IcebergReportTableMetricsFailureEvent extends
IcebergTableFailureEvent {
+
+ /**
+ * Creates the event.
+ *
+ * @param icebergRequestContext the Iceberg REST request context
+ * @param tableIdentifier the Gravitino identifier of the table the report
is for
+ * @param e the reason the report was rejected
+ */
+ public IcebergReportTableMetricsFailureEvent(
+ IcebergRequestContext icebergRequestContext, NameIdentifier
tableIdentifier, Exception e) {
+ super(icebergRequestContext, tableIdentifier, e);
+ }
+
+ @Override
+ public OperationType operationType() {
+ return OperationType.REPORT_TABLE_METRICS;
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsPreEvent.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsPreEvent.java
new file mode 100644
index 0000000000..eb1a12a291
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/listener/api/event/IcebergReportTableMetricsPreEvent.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.listener.api.event;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/** Represents a pre event before an Iceberg table metrics report is checked.
*/
+@DeveloperApi
+public class IcebergReportTableMetricsPreEvent extends IcebergTablePreEvent {
+
+ /**
+ * Creates the event.
+ *
+ * @param icebergRequestContext the Iceberg REST request context
+ * @param tableIdentifier the Gravitino identifier of the table the report
is for
+ */
+ public IcebergReportTableMetricsPreEvent(
+ IcebergRequestContext icebergRequestContext, NameIdentifier
tableIdentifier) {
+ super(icebergRequestContext, tableIdentifier);
+ }
+
+ @Override
+ public OperationType operationType() {
+ return OperationType.REPORT_TABLE_METRICS;
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
index c6c67f24b5..f7667247a5 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/server/web/filter/IcebergMetadataAuthorizationMethodInterceptor.java
@@ -36,6 +36,7 @@ import
org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
import org.apache.gravitino.iceberg.service.IcebergRESTUtils;
import
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
import org.apache.gravitino.iceberg.service.rest.IcebergTableOperations;
+import org.apache.gravitino.listener.api.event.EventSource;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
import
org.apache.gravitino.server.authorization.annotations.IcebergAuthorizationMetadata;
@@ -170,6 +171,11 @@ public class IcebergMetadataAuthorizationMethodInterceptor
return Optional.empty();
}
+ @Override
+ protected Optional<EventSource> authorizationDenialEventSource() {
+ return Optional.of(EventSource.GRAVITINO_ICEBERG_REST_SERVER);
+ }
+
@Override
protected boolean isExceptionPropagate(Exception exception) {
return
exception.getClass().getName().startsWith("org.apache.iceberg.exceptions");
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergEntityAuditUtils.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergEntityAuditUtils.java
new file mode 100644
index 0000000000..52abb08efd
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergEntityAuditUtils.java
@@ -0,0 +1,47 @@
+/*
+ * 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.iceberg.service.dispatcher;
+
+import java.time.Instant;
+import org.apache.gravitino.meta.AuditInfo;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIcebergEntityAuditUtils {
+
+ @Test
+ public void testCreatorIsSetOnlyWhenMissing() {
+ Instant now = Instant.now();
+ AuditInfo empty = IcebergEntityAuditUtils.withCreator(AuditInfo.EMPTY,
"alice", now);
+ Assertions.assertEquals("alice", empty.creator());
+ Assertions.assertEquals(now, empty.createTime());
+ Assertions.assertNull(empty.lastModifier());
+
+ AuditInfo modifiedOnly =
+
AuditInfo.builder().withLastModifier("bob").withLastModifiedTime(now).build();
+ AuditInfo filled = IcebergEntityAuditUtils.withCreator(modifiedOnly,
"alice", now);
+ Assertions.assertEquals("alice", filled.creator());
+ Assertions.assertEquals("bob", filled.lastModifier());
+
+ AuditInfo reported =
+
AuditInfo.builder().withCreator("carol").withCreateTime(Instant.EPOCH).build();
+ Assertions.assertSame(
+ reported, IcebergEntityAuditUtils.withCreator(reported, "alice",
Instant.now()));
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
index 7d5e76922b..3a7b4ea66e 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergNamespaceHookDispatcher.java
@@ -28,6 +28,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
@@ -47,6 +48,7 @@ import
org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.lock.LockManager;
import org.apache.gravitino.lock.TreeLock;
+import org.apache.gravitino.meta.SchemaEntity;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.rest.requests.CreateNamespaceRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
@@ -297,7 +299,7 @@ public class TestIcebergNamespaceHookDispatcher {
}
@Test
- public void testCreateNestedNamespaceOwnsMissingAncestorsAndLeaf() {
+ public void testCreateNestedNamespaceOwnsMissingAncestorsAndLeaf() throws
IOException {
// Depth-4 leaf, only A exists. Expect setOwners with [A:B, A:B:C,
A:B:C:D] —
// missing ancestors (outermost first) + leaf; existing A is not re-owned.
Namespace leaf = Namespace.of("A", "B", "C", "D");
@@ -326,6 +328,14 @@ public class TestIcebergNamespaceHookDispatcher {
Assertions.assertEquals(
Arrays.asList(TEST_CATALOG + ".A:B", TEST_CATALOG + ".A:B:C",
TEST_CATALOG + ".A:B:C:D"),
names);
+
+ // Each namespace the request created gets its creator recorded; the
existing A does not.
+ ArgumentCaptor<NameIdentifier> recorded =
ArgumentCaptor.forClass(NameIdentifier.class);
+ verify(mockEntityStore, times(3))
+ .update(recorded.capture(), eq(SchemaEntity.class),
eq(Entity.EntityType.SCHEMA), any());
+ Assertions.assertEquals(
+ Arrays.asList("A:B", "A:B:C", "A:B:C:D"),
+
recorded.getAllValues().stream().map(NameIdentifier::name).collect(Collectors.toList()));
}
@Test
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
index 27cf388ffd..e0ad27dfc6 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
@@ -26,11 +26,13 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.function.Function;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Config;
import org.apache.gravitino.Configs;
@@ -48,6 +50,7 @@ import
org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.TableEntity;
+import org.apache.iceberg.MetadataUpdate;
import org.apache.iceberg.Schema;
import org.apache.iceberg.UpdateRequirement;
import org.apache.iceberg.catalog.Namespace;
@@ -483,6 +486,79 @@ public class TestIcebergTableHookDispatcher {
Assertions.assertEquals(TEST_USER, userCaptor.getValue());
}
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testCreateTableRecordsTheCreator() throws IOException {
+ Namespace namespace = Namespace.of("test_schema");
+ CreateTableRequest request =
+
CreateTableRequest.builder().withName("test_table").withSchema(TABLE_SCHEMA).build();
+ when(mockDispatcher.createTable(mockContext, namespace, request))
+ .thenReturn(mock(LoadTableResponse.class));
+
+ hookDispatcher.createTable(mockContext, namespace, request);
+
+ NameIdentifier identifier =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ TEST_METALAKE, TEST_CATALOG, TableIdentifier.of(namespace,
"test_table"), ":");
+ ArgumentCaptor<Function<TableEntity, TableEntity>> updater =
+ ArgumentCaptor.forClass(Function.class);
+ verify(mockEntityStore)
+ .update(
+ eq(identifier), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), updater.capture());
+ TableEntity imported =
+ TableEntity.builder()
+ .withId(1L)
+ .withName("test_table")
+ .withNamespace(identifier.namespace())
+ .withColumns(Collections.emptyList())
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build();
+ TableEntity recorded = updater.getValue().apply(imported);
+ Assertions.assertEquals(TEST_USER, recorded.auditInfo().creator());
+ Assertions.assertNotNull(recorded.auditInfo().createTime());
+ Assertions.assertEquals(imported.id(), recorded.id());
+ }
+
+ @Test
+ public void testSchemaChangeCommitSyncsColumnsWhileRunningAsTheCommitter() {
+ TableIdentifier tableId = TableIdentifier.of("test_schema", "test_table");
+ Schema newSchema =
+ new Schema(
+ 1,
+ NestedField.required(1, "test_field", StringType.get()),
+ NestedField.optional(2, "c", StringType.get()));
+ UpdateTableRequest request =
+ new UpdateTableRequest(
+ Collections.emptyList(),
+ Arrays.asList(
+ new MetadataUpdate.AddSchema(newSchema), new
MetadataUpdate.SetCurrentSchema(-1)));
+ when(mockDispatcher.updateTable(mockContext, tableId, request))
+ .thenReturn(mock(LoadTableResponse.class));
+
+ hookDispatcher.updateTable(mockContext, tableId, request);
+
+ verify(mockInternalTableDispatcher)
+ .loadTable(
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ TEST_METALAKE, TEST_CATALOG, tableId, ":"));
+ verify(mockInternalOwnerDispatcher, never()).setOwner(any(), any(), any(),
any());
+ }
+
+ @Test
+ public void testOnlySchemaUpdatesCountAsSchemaChanges() {
+ Assertions.assertFalse(
+ IcebergTableHookDispatcher.changesSchema(
+ new UpdateTableRequest(
+ Collections.emptyList(),
+ Collections.singletonList(
+ new MetadataUpdate.SetProperties(ImmutableMap.of("k",
"v"))))));
+ Assertions.assertTrue(
+ IcebergTableHookDispatcher.changesSchema(
+ new UpdateTableRequest(
+ Collections.emptyList(),
+ Collections.singletonList(new
MetadataUpdate.SetCurrentSchema(0)))));
+ }
+
@Test
public void testUpdateTableSkipsImportForRegularUpdate() {
TableIdentifier tableId = TableIdentifier.of("test_schema", "test_table");
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
index d81cf9fee0..bec9071dc1 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
@@ -184,6 +184,10 @@ public class TestIcebergViewHookDispatcher {
verify(mockInternalViewDispatcher, times(1)).loadView(eq(expectedIdent));
verify(mockViewDispatcher, never()).loadView(any());
+ // Verify the creator was recorded on the imported view
+ verify(mockEntityStore)
+ .update(eq(expectedIdent), eq(ViewEntity.class),
eq(Entity.EntityType.VIEW), any());
+
// Verify ownership was set
verify(mockInternalOwnerDispatcher, times(1)).setOwner(any(), any(),
eq(USER), any());
verify(mockOwnerDispatcher, never()).setOwner(any(), any(), any(), any());
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
index 58cd3a0d75..db8a253f4b 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergTableOperations.java
@@ -59,12 +59,16 @@ import
org.apache.gravitino.listener.api.event.IcebergPlanTableScanPreEvent;
import org.apache.gravitino.listener.api.event.IcebergRenameTableEvent;
import org.apache.gravitino.listener.api.event.IcebergRenameTableFailureEvent;
import org.apache.gravitino.listener.api.event.IcebergRenameTablePreEvent;
+import org.apache.gravitino.listener.api.event.IcebergReportTableMetricsEvent;
+import
org.apache.gravitino.listener.api.event.IcebergReportTableMetricsFailureEvent;
+import
org.apache.gravitino.listener.api.event.IcebergReportTableMetricsPreEvent;
import org.apache.gravitino.listener.api.event.IcebergRequestContext;
import org.apache.gravitino.listener.api.event.IcebergTableExistsEvent;
import org.apache.gravitino.listener.api.event.IcebergTableExistsPreEvent;
import org.apache.gravitino.listener.api.event.IcebergUpdateTableEvent;
import org.apache.gravitino.listener.api.event.IcebergUpdateTableFailureEvent;
import org.apache.gravitino.listener.api.event.IcebergUpdateTablePreEvent;
+import org.apache.gravitino.listener.api.event.OperationType;
import org.apache.gravitino.server.ServerConfig;
import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
import org.apache.iceberg.GenericBlobMetadata;
@@ -106,6 +110,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
+import org.opentest4j.AssertionFailedError;
@SuppressWarnings("deprecation")
public class TestIcebergTableOperations extends IcebergNamespaceTestBase {
@@ -433,11 +438,19 @@ public class TestIcebergTableOperations extends
IcebergNamespaceTestBase {
.commitMetrics(commitMetrics)
.build();
ReportMetricsRequest request = ReportMetricsRequest.of(commitReport);
+ dummyEventListener.clearEvent();
Response response =
getReportMetricsClientBuilder("metrics_foo1", namespace)
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
Assertions.assertEquals(Status.NO_CONTENT.getStatusCode(),
response.getStatus());
+ // The report is recorded as its own operation, with no table-exists event
for the check.
+ Assertions.assertInstanceOf(
+ IcebergReportTableMetricsPreEvent.class,
dummyEventListener.popPreEvent());
+ Assertions.assertInstanceOf(
+ IcebergReportTableMetricsEvent.class,
dummyEventListener.popPostEvent());
+ Assertions.assertThrows(AssertionFailedError.class,
dummyEventListener::popPostEvent);
+ Assertions.assertThrows(AssertionFailedError.class,
dummyEventListener::popPreEvent);
ReportMetricsRequest qualifiedRequest =
ReportMetricsRequest.of(
@@ -453,10 +466,14 @@ public class TestIcebergTableOperations extends
IcebergNamespaceTestBase {
.post(Entity.entity(qualifiedRequest,
MediaType.APPLICATION_JSON_TYPE));
Assertions.assertEquals(Status.NO_CONTENT.getStatusCode(),
qualifiedResponse.getStatus());
+ dummyEventListener.clearEvent();
Response missingTableResponse =
getReportMetricsClientBuilder("missing_metrics_table", namespace)
.post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
Assertions.assertEquals(Status.NOT_FOUND.getStatusCode(),
missingTableResponse.getStatus());
+ Event failure = dummyEventListener.popPostEvent();
+ Assertions.assertInstanceOf(IcebergReportTableMetricsFailureEvent.class,
failure);
+ Assertions.assertEquals(OperationType.REPORT_TABLE_METRICS,
failure.operationType());
ReportMetricsRequest mismatchedRequest =
ReportMetricsRequest.of(
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
index 21a1fca4a0..528d62b528 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/filter/BaseMetadataAuthorizationMethodInterceptor.java
@@ -27,10 +27,14 @@ import java.util.Objects;
import java.util.Optional;
import javax.ws.rs.HttpMethod;
import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.listener.EventBus;
+import org.apache.gravitino.listener.api.event.EventSource;
+import
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
import org.apache.gravitino.server.web.Utils;
@@ -206,6 +210,17 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor {
return shouldSkipAuthorization(nameIdentifierMap);
}
+ /**
+ * Returns the server to which authorization denials are attributed in the
audit log. A protocol
+ * that returns a source here records each denied request as an {@link
+ * AuthorizationDenialFailureEvent}, the same as the Gravitino API does.
+ *
+ * @return the event source of this protocol, or empty to record no denial
events
+ */
+ protected Optional<EventSource> authorizationDenialEventSource() {
+ return Optional.empty();
+ }
+
/**
* Hook for requests that must validate the current user but do not have a
metadata object on
* which to evaluate an expression. A protocol root-list request is a
typical example: its
@@ -233,18 +248,23 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor {
*/
protected final Object authorizeMethod(Method method, Object[] args,
MethodInvoker methodInvoker)
throws Throwable {
+ // The object and expression a denial is recorded against, set as
authorization proceeds.
+ NameIdentifier deniedObject = null;
+ String deniedExpression = "";
try {
Parameter[] parameters = method.getParameters();
AuthorizationExpression expressionAnnotation =
method.getAnnotation(AuthorizationExpression.class);
if (expressionAnnotation != null) {
String expression = expressionAnnotation.expression();
+ deniedExpression = expression;
AuthorizationTarget target =
resolveAuthorizationTarget(method, expressionAnnotation,
parameters, args);
Map<Entity.EntityType, NameIdentifier> nameIdentifierMap =
target.nameIdentifiers();
boolean skipStandardCheck = shouldSkipAuthorization(method,
nameIdentifierMap);
NameIdentifier metalakeIdent =
nameIdentifierMap.get(Entity.EntityType.METALAKE);
+ deniedObject = metalakeIdent;
AuthorizationRequestContext authorizationRequestContext = new
AuthorizationRequestContext();
if (!skipStandardCheck && metalakeIdent != null) {
@@ -274,6 +294,9 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor {
}
}
+ if (nameIdentifierMap.get(target.entityType()) != null) {
+ deniedObject = nameIdentifierMap.get(target.entityType());
+ }
Optional<AuthorizationHandler> handler =
createAuthorizationHandler(method, parameters, args);
@@ -314,6 +337,9 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor {
}
}
} catch (Exception ex) {
+ if (ex instanceof ForbiddenException) {
+ dispatchAuthorizationDenial(deniedObject, method.getName(),
deniedExpression);
+ }
if (ex instanceof ForbiddenException || isExceptionPropagate(ex)) {
return toErrorResponse(method, args, ex);
}
@@ -333,4 +359,28 @@ public abstract class
BaseMetadataAuthorizationMethodInterceptor {
return toErrorResponse(method, args, e);
}
}
+
+ private void dispatchAuthorizationDenial(
+ NameIdentifier deniedObject, String methodName, String expression) {
+ Optional<EventSource> eventSource = authorizationDenialEventSource();
+ if (!eventSource.isPresent()) {
+ return;
+ }
+ String user = PrincipalUtils.getCurrentUserName();
+ // Recording the denial must never change the response the client gets.
+ try {
+ EventBus eventBus = GravitinoEnv.getInstance().eventBus();
+ if (eventBus != null) {
+ eventBus.dispatchEvent(
+ new AuthorizationDenialFailureEvent(
+ user, deniedObject, methodName, expression,
eventSource.get()));
+ }
+ } catch (Exception e) {
+ LOG.error(
+ "Failed to dispatch authorization denial event for user {} on
operation {}",
+ user,
+ methodName,
+ e);
+ }
+ }
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
index 6954f69731..54d24d4890 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/filter/TestBaseMetadataAuthorizationMethodInterceptor.java
@@ -38,6 +38,7 @@ import java.util.Map;
import java.util.Optional;
import javax.ws.rs.GET;
import org.apache.gravitino.Entity;
+import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.UserPrincipal;
@@ -46,6 +47,10 @@ import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.authorization.GravitinoAuthorizer;
import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.exceptions.ForbiddenException;
+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.AuthorizationDenialFailureEvent;
import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -53,6 +58,7 @@ import org.apache.gravitino.utils.PrincipalUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
/** Tests for {@link BaseMetadataAuthorizationMethodInterceptor}. */
@@ -158,6 +164,76 @@ public class
TestBaseMetadataAuthorizationMethodInterceptor {
}
@Test
+ public void testExpressionDenialIsRecordedAgainstTheTarget() throws
Throwable {
+ EventBus eventBus = mock(EventBus.class);
+ try (MockedStatic<GravitinoEnv> envStatic = mockGravitinoEnv(eventBus)) {
+ TestInterceptor interceptor = new
TestInterceptor(Entity.EntityType.SCHEMA);
+ interceptor.denialEventSource =
Optional.of(EventSource.GRAVITINO_ICEBERG_REST_SERVER);
+
+ assertInstanceOf(
+ ForbiddenException.class,
+ interceptor.invoke(invocation("canAccessMetadata", "authorized")));
+
+ ArgumentCaptor<BaseEvent> event =
ArgumentCaptor.forClass(BaseEvent.class);
+ verify(eventBus).dispatchEvent(event.capture());
+ AuthorizationDenialFailureEvent denial =
+ assertInstanceOf(AuthorizationDenialFailureEvent.class,
event.getValue());
+ assertEquals("tester", denial.user());
+ assertEquals(
+ NameIdentifierUtil.ofSchema("metalake", "catalog", "schema"),
denial.identifier());
+ assertEquals("canAccessMetadata", denial.methodName());
+ assertEquals(CAN_ACCESS_METADATA, denial.expression());
+ assertEquals(EventSource.GRAVITINO_ICEBERG_REST_SERVER,
denial.eventSource());
+ }
+ }
+
+ @Test
+ public void testUserValidationDenialIsRecordedAgainstTheMetalake() throws
Throwable {
+ authorizationUtils
+ .when(
+ () ->
+ AuthorizationUtils.checkCurrentUser(
+ eq("metalake"), eq("tester"),
any(AuthorizationRequestContext.class)))
+ .thenThrow(new ForbiddenException("not a metalake user"));
+ EventBus eventBus = mock(EventBus.class);
+ try (MockedStatic<GravitinoEnv> envStatic = mockGravitinoEnv(eventBus)) {
+ TestInterceptor interceptor = new
TestInterceptor(Entity.EntityType.SCHEMA);
+ interceptor.denialEventSource =
Optional.of(EventSource.GRAVITINO_ICEBERG_REST_SERVER);
+
+ interceptor.invoke(invocation("canAccessMetadata", "authorized"));
+
+ ArgumentCaptor<BaseEvent> event =
ArgumentCaptor.forClass(BaseEvent.class);
+ verify(eventBus).dispatchEvent(event.capture());
+ assertEquals(
+ NameIdentifierUtil.ofMetalake("metalake"),
+ assertInstanceOf(AuthorizationDenialFailureEvent.class,
event.getValue()).identifier());
+ }
+ }
+
+ @Test
+ public void testNoDenialEventWithoutEventSourceOrForOtherFailures() throws
Throwable {
+ EventBus eventBus = mock(EventBus.class);
+ try (MockedStatic<GravitinoEnv> envStatic = mockGravitinoEnv(eventBus)) {
+ new TestInterceptor(Entity.EntityType.SCHEMA)
+ .invoke(invocation("canAccessMetadata", "authorized"));
+
+ TestInterceptor failing = new TestInterceptor(Entity.EntityType.SCHEMA);
+ failing.denialEventSource =
Optional.of(EventSource.GRAVITINO_ICEBERG_REST_SERVER);
+ failing.resolutionFailure = new IllegalStateException("resolver failed");
+ failing.invoke(invocation("canAccessMetadata", "authorized"));
+
+ verify(eventBus, never()).dispatchEvent(any());
+ }
+ }
+
+ private static MockedStatic<GravitinoEnv> mockGravitinoEnv(EventBus
eventBus) {
+ MockedStatic<GravitinoEnv> envStatic = mockStatic(GravitinoEnv.class);
+ GravitinoEnv env = mock(GravitinoEnv.class);
+ envStatic.when(GravitinoEnv::getInstance).thenReturn(env);
+ when(env.eventBus()).thenReturn(eventBus);
+ return envStatic;
+ }
+
public void testCompletedHandlerSkipsExpressionEvaluation() throws Throwable
{
TestInterceptor interceptor = new
TestInterceptor(Entity.EntityType.SCHEMA);
interceptor.handler =
@@ -296,6 +372,7 @@ public class TestBaseMetadataAuthorizationMethodInterceptor
{
private boolean includeMetalake = true;
private boolean skipAuthorization;
private boolean skipExpressionEvaluation;
+ private Optional<EventSource> denialEventSource = Optional.empty();
private TestInterceptor(Entity.EntityType targetType) {
this.targetType = targetType;
@@ -329,6 +406,11 @@ public class
TestBaseMetadataAuthorizationMethodInterceptor {
return throwable;
}
+ @Override
+ protected Optional<EventSource> authorizationDenialEventSource() {
+ return denialEventSource;
+ }
+
@Override
protected Optional<AuthorizationHandler> createAuthorizationHandler(
Method method, Parameter[] parameters, Object[] args) {