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 a69d93ec24 [Cherry-pick to branch-1.3] [#14473] fix(iceberg-rest): 
keep the owner property in step with Gravitino ownership (#14475)
a69d93ec24 is described below

commit a69d93ec244a1e7ea31e72a7425d8a6410beaa6d
Author: Mark Hoerth <[email protected]>
AuthorDate: Mon Nov 2 01:28:56 2026 -0800

    [Cherry-pick to branch-1.3] [#14473] fix(iceberg-rest): keep the owner 
property in step with Gravitino ownership (#14475)
    
    Backport to branch-1.3 of the fix for #14473. Same change as the PR to
    main; the dispatcher is wired where branch-1.3 builds its owner
    dispatcher.
    
    ### What changes were proposed in this pull request?
    
    - New `OwnerPropertySyncDispatcher` in the public owner dispatcher chain
    (`OwnerEventManager` -> `OwnerPropertySyncDispatcher` ->
    `OwnerManager`). After `setOwner` or `setOwners` succeeds, it rewrites
    the `owner` property of each namespace or table in a `lakehouse-iceberg`
    catalog that has one, through the internal schema and table dispatchers.
    Objects without the property, such as those created through the
    Gravitino API, are left alone. Ownership has already changed when this
    runs, so a failed update is logged and does not fail the transfer. The
    internal owner dispatcher used by the IRC create hooks is unchanged,
    since the property already names the first owner.
    - New `IcebergOwnerPropertyGuard`, applied by the IRC table and
    namespace executors: `owner` is dropped from `set-properties` and
    `remove-properties` in table commits and from namespace property updates
    and removals. A commit that creates a table (staged create) sets it to
    the committing user, as a direct create does. The key is dropped rather
    than rejected because engines send it on their own, for example Spark
    adds the current user as `owner` on CREATE OR REPLACE, and rejecting it
    would fail those statements.
    
    ### Why are the changes needed?
    
    Gravitino and engines gave different answers for an object's owner, and
    the property could be set to any name.
    
    Fixes #14473
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. An ownership transfer updates the `owner` property of IRC-created
    namespaces and tables; IRC property updates no longer change it. Objects
    transferred before this change keep their old value until their next
    transfer.
    
    ### How was this patch tested?
    
    - `TestOwnerPropertySyncDispatcher`: a transfer rewrites the property of
    an Iceberg table and namespace; objects without it or outside Iceberg
    catalogs are untouched; a failed property update does not fail the
    transfer; a failed transfer does not touch the property.
    - `TestIcebergOwnerPropertyGuard`: commits and namespace updates cannot
    set or remove `owner`; a create commit sets it to the committer; other
    requests are unchanged.
    
    `./gradlew :core:test --tests '*Owner*'
    :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/GravitinoEnv.java    |   7 +-
 .../authorization/OwnerPropertySyncDispatcher.java | 143 +++++++++++++++++++
 .../TestOwnerPropertySyncDispatcher.java           | 154 +++++++++++++++++++++
 .../IcebergNamespaceOperationExecutor.java         |   5 +-
 .../dispatcher/IcebergOwnerPropertyGuard.java      | 132 ++++++++++++++++++
 .../dispatcher/IcebergTableOperationExecutor.java  |   4 +-
 .../dispatcher/TestIcebergOwnerPropertyGuard.java  | 105 ++++++++++++++
 7 files changed, 547 insertions(+), 3 deletions(-)

diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java 
b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
index 4fee8c7d27..f39a2ecc25 100644
--- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
+++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java
@@ -27,6 +27,7 @@ import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.authorization.OwnerDispatcher;
 import org.apache.gravitino.authorization.OwnerEventManager;
 import org.apache.gravitino.authorization.OwnerManager;
+import org.apache.gravitino.authorization.OwnerPropertySyncDispatcher;
 import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
 import org.apache.gravitino.catalog.CatalogDispatcher;
 import org.apache.gravitino.catalog.CatalogManager;
@@ -885,7 +886,11 @@ public class GravitinoEnv {
           new AccessControlEventDispatcher(eventBus, 
accessControlHookDispatcher);
       OwnerDispatcher ownerManager = new OwnerManager(entityStore);
       this.internalOwnerDispatcher = ownerManager;
-      this.ownerDispatcher = new OwnerEventManager(eventBus, ownerManager);
+      // Transfers through the public dispatcher also update the owner 
property of Iceberg
+      // namespaces and tables. Internal callers, such as the Iceberg REST 
catalog's create hooks,
+      // set the first owner, which the property already names.
+      this.ownerDispatcher =
+          new OwnerEventManager(eventBus, new 
OwnerPropertySyncDispatcher(ownerManager));
       this.futureGrantManager = new FutureGrantManager(entityStore, 
ownerManager);
     } else {
       this.accessControlDispatcher = null;
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/OwnerPropertySyncDispatcher.java
 
b/core/src/main/java/org/apache/gravitino/authorization/OwnerPropertySyncDispatcher.java
new file mode 100644
index 0000000000..4337920152
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/OwnerPropertySyncDispatcher.java
@@ -0,0 +1,143 @@
+/*
+ *  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.authorization;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.SchemaChange;
+import org.apache.gravitino.catalog.SchemaDispatcher;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Keeps the {@code owner} property of Iceberg namespaces and tables in step 
with Gravitino
+ * ownership. The Iceberg REST catalog writes that property, the conventional 
Iceberg and Hive owner
+ * key that engines display, when it creates a namespace or table. After an 
ownership transfer this
+ * dispatcher rewrites the property to the new owner, so engines and Gravitino 
give the same answer.
+ * Objects without the property, such as those created through the Gravitino 
API, are left alone.
+ */
+public class OwnerPropertySyncDispatcher implements OwnerDispatcher {
+
+  /** The Iceberg and Hive property that names an object's owner. */
+  @VisibleForTesting static final String OWNER_PROPERTY = "owner";
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(OwnerPropertySyncDispatcher.class);
+  private static final String ICEBERG_PROVIDER = "lakehouse-iceberg";
+
+  private final OwnerDispatcher dispatcher;
+
+  /**
+   * Creates a dispatcher that sets owners through {@code dispatcher} and then 
updates the owner
+   * property.
+   *
+   * @param dispatcher the dispatcher that records ownership
+   */
+  public OwnerPropertySyncDispatcher(OwnerDispatcher dispatcher) {
+    this.dispatcher = dispatcher;
+  }
+
+  @Override
+  public void setOwner(
+      String metalake, MetadataObject metadataObject, String ownerName, 
Owner.Type ownerType) {
+    dispatcher.setOwner(metalake, metadataObject, ownerName, ownerType);
+    syncOwnerProperty(metalake, metadataObject, ownerName);
+  }
+
+  @Override
+  public void setOwners(
+      String metalake,
+      List<MetadataObject> metadataObjects,
+      String ownerName,
+      Owner.Type ownerType) {
+    dispatcher.setOwners(metalake, metadataObjects, ownerName, ownerType);
+    if (metadataObjects != null) {
+      metadataObjects.forEach(object -> syncOwnerProperty(metalake, object, 
ownerName));
+    }
+  }
+
+  @Override
+  public Optional<Owner> getOwner(String metalake, MetadataObject 
metadataObject) {
+    return dispatcher.getOwner(metalake, metadataObject);
+  }
+
+  /**
+   * Rewrites the owner property of an Iceberg namespace or table that has 
one. Ownership has
+   * already changed when this runs, so a failure is logged rather than 
failing the transfer.
+   */
+  @VisibleForTesting
+  void syncOwnerProperty(String metalake, MetadataObject metadataObject, 
String ownerName) {
+    MetadataObject.Type type = metadataObject.type();
+    if (type != MetadataObject.Type.SCHEMA && type != 
MetadataObject.Type.TABLE) {
+      return;
+    }
+    NameIdentifier identifier = MetadataObjectUtil.toEntityIdent(metalake, 
metadataObject);
+    try {
+      if 
(!isIcebergCatalog(NameIdentifierUtil.getCatalogIdentifier(identifier))) {
+        return;
+      }
+      GravitinoEnv env = GravitinoEnv.getInstance();
+      if (type == MetadataObject.Type.SCHEMA) {
+        SchemaDispatcher schemas = env.internalSchemaDispatcher();
+        Schema schema = schemas.loadSchema(identifier);
+        if (needsUpdate(schema.properties(), ownerName)) {
+          schemas.alterSchema(identifier, 
SchemaChange.setProperty(OWNER_PROPERTY, ownerName));
+        }
+      } else {
+        TableDispatcher tables = env.internalTableDispatcher();
+        Table table = tables.loadTable(identifier);
+        if (needsUpdate(table.properties(), ownerName)) {
+          tables.alterTable(identifier, 
TableChange.setProperty(OWNER_PROPERTY, ownerName));
+        }
+      }
+    } catch (Exception e) {
+      LOG.warn(
+          "Ownership of {} {} moved to {}, but its {} property could not be 
updated",
+          type,
+          identifier,
+          ownerName,
+          OWNER_PROPERTY,
+          e);
+    }
+  }
+
+  private static boolean isIcebergCatalog(NameIdentifier catalogIdent) {
+    Catalog catalog =
+        
GravitinoEnv.getInstance().internalCatalogDispatcher().loadCatalog(catalogIdent);
+    return ICEBERG_PROVIDER.equalsIgnoreCase(catalog.provider());
+  }
+
+  @VisibleForTesting
+  static boolean needsUpdate(Map<String, String> properties, String ownerName) 
{
+    return properties != null
+        && properties.containsKey(OWNER_PROPERTY)
+        && !ownerName.equals(properties.get(OWNER_PROPERTY));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestOwnerPropertySyncDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestOwnerPropertySyncDispatcher.java
new file mode 100644
index 0000000000..5a9f0389b7
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestOwnerPropertySyncDispatcher.java
@@ -0,0 +1,154 @@
+/*
+ * 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.authorization;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+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.util.Arrays;
+import java.util.Collections;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.SchemaChange;
+import org.apache.gravitino.catalog.CatalogDispatcher;
+import org.apache.gravitino.catalog.SchemaDispatcher;
+import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableChange;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class TestOwnerPropertySyncDispatcher {
+
+  private static final String METALAKE = "m";
+  private static final MetadataObject TABLE =
+      MetadataObjects.of(Arrays.asList("cat", "ns", "t"), 
MetadataObject.Type.TABLE);
+  private static final MetadataObject SCHEMA =
+      MetadataObjects.of(Arrays.asList("cat", "ns"), 
MetadataObject.Type.SCHEMA);
+  private static final NameIdentifier TABLE_IDENT = 
NameIdentifier.of(METALAKE, "cat", "ns", "t");
+  private static final NameIdentifier SCHEMA_IDENT = 
NameIdentifier.of(METALAKE, "cat", "ns");
+
+  private final GravitinoEnv env = GravitinoEnv.getInstance();
+  private Object previousCatalogs;
+  private Object previousSchemas;
+  private Object previousTables;
+  private OwnerDispatcher delegate;
+  private Catalog catalog;
+  private SchemaDispatcher schemas;
+  private TableDispatcher tables;
+  private OwnerPropertySyncDispatcher dispatcher;
+
+  @BeforeEach
+  public void setUp() throws IllegalAccessException {
+    previousCatalogs = FieldUtils.readField(env, "internalCatalogDispatcher", 
true);
+    previousSchemas = FieldUtils.readField(env, "internalSchemaDispatcher", 
true);
+    previousTables = FieldUtils.readField(env, "internalTableDispatcher", 
true);
+    CatalogDispatcher catalogs = mock(CatalogDispatcher.class);
+    catalog = mock(Catalog.class);
+    when(catalogs.loadCatalog(NameIdentifier.of(METALAKE, 
"cat"))).thenReturn(catalog);
+    when(catalog.provider()).thenReturn("lakehouse-iceberg");
+    schemas = mock(SchemaDispatcher.class);
+    tables = mock(TableDispatcher.class);
+    FieldUtils.writeField(env, "internalCatalogDispatcher", catalogs, true);
+    FieldUtils.writeField(env, "internalSchemaDispatcher", schemas, true);
+    FieldUtils.writeField(env, "internalTableDispatcher", tables, true);
+    delegate = mock(OwnerDispatcher.class);
+    dispatcher = new OwnerPropertySyncDispatcher(delegate);
+  }
+
+  @AfterEach
+  public void tearDown() throws IllegalAccessException {
+    FieldUtils.writeField(env, "internalCatalogDispatcher", previousCatalogs, 
true);
+    FieldUtils.writeField(env, "internalSchemaDispatcher", previousSchemas, 
true);
+    FieldUtils.writeField(env, "internalTableDispatcher", previousTables, 
true);
+  }
+
+  @Test
+  public void testTransferRewritesTheOwnerPropertyOfAnIcebergTable() {
+    Table table = mock(Table.class);
+    when(table.properties()).thenReturn(ImmutableMap.of("owner", "admin"));
+    when(tables.loadTable(TABLE_IDENT)).thenReturn(table);
+
+    dispatcher.setOwner(METALAKE, TABLE, "writer", Owner.Type.USER);
+
+    verify(delegate).setOwner(METALAKE, TABLE, "writer", Owner.Type.USER);
+    verify(tables).alterTable(TABLE_IDENT, TableChange.setProperty("owner", 
"writer"));
+  }
+
+  @Test
+  public void testTransferRewritesTheOwnerPropertyOfIcebergNamespaces() {
+    Schema schema = mock(Schema.class);
+    when(schema.properties()).thenReturn(ImmutableMap.of("owner", "admin"));
+    when(schemas.loadSchema(SCHEMA_IDENT)).thenReturn(schema);
+
+    dispatcher.setOwners(METALAKE, Collections.singletonList(SCHEMA), 
"writers", Owner.Type.GROUP);
+
+    verify(schemas).alterSchema(SCHEMA_IDENT, 
SchemaChange.setProperty("owner", "writers"));
+  }
+
+  @Test
+  public void testObjectsWithoutThePropertyOrOutsideIcebergAreLeftAlone() {
+    Table table = mock(Table.class);
+    when(table.properties()).thenReturn(ImmutableMap.of("k", "v"));
+    when(tables.loadTable(TABLE_IDENT)).thenReturn(table);
+    dispatcher.setOwner(METALAKE, TABLE, "writer", Owner.Type.USER);
+
+    when(catalog.provider()).thenReturn("hive");
+    when(table.properties()).thenReturn(ImmutableMap.of("owner", "admin"));
+    dispatcher.setOwner(METALAKE, TABLE, "writer", Owner.Type.USER);
+
+    verify(tables, never()).alterTable(any(), any());
+  }
+
+  @Test
+  public void testAFailedPropertyUpdateDoesNotFailTheTransfer() {
+    Table table = mock(Table.class);
+    when(table.properties()).thenReturn(ImmutableMap.of("owner", "admin"));
+    when(tables.loadTable(TABLE_IDENT)).thenReturn(table);
+    when(tables.alterTable(any(), any())).thenThrow(new 
IllegalStateException("catalog down"));
+
+    Assertions.assertDoesNotThrow(
+        () -> dispatcher.setOwner(METALAKE, TABLE, "writer", Owner.Type.USER));
+    verify(delegate).setOwner(METALAKE, TABLE, "writer", Owner.Type.USER);
+  }
+
+  @Test
+  public void testAFailedTransferDoesNotTouchTheProperty() {
+    Mockito.doThrow(new IllegalArgumentException("no such user"))
+        .when(delegate)
+        .setOwner(METALAKE, TABLE, "nobody", Owner.Type.USER);
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> dispatcher.setOwner(METALAKE, TABLE, "nobody", Owner.Type.USER));
+    verify(tables, never()).loadTable(any());
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
index 353d46b68e..c0be4675ac 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceOperationExecutor.java
@@ -104,7 +104,10 @@ public class IcebergNamespaceOperationExecutor implements 
IcebergNamespaceOperat
     validatePropertyUpdate(updateNamespacePropertiesRequest);
     return icebergCatalogWrapperManager
         .getCatalogWrapper(context.catalogName())
-        .updateNamespaceProperties(namespace, 
updateNamespacePropertiesRequest);
+        .updateNamespaceProperties(
+            namespace,
+            IcebergOwnerPropertyGuard.protect(
+                updateNamespacePropertiesRequest, context.userName()));
   }
 
   @Override
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergOwnerPropertyGuard.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergOwnerPropertyGuard.java
new file mode 100644
index 0000000000..6085cacdd2
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergOwnerPropertyGuard.java
@@ -0,0 +1,132 @@
+/*
+ *  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.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
+import org.apache.iceberg.MetadataUpdate;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+
+/**
+ * Keeps the {@code owner} property of namespaces and tables under Gravitino's 
control. The Iceberg
+ * REST catalog sets it to the creator, and an ownership transfer in Gravitino 
rewrites it; a
+ * property update through the Iceberg REST catalog cannot change it.
+ *
+ * <p>Updates to the property are dropped rather than rejected: engines send 
it on their own, for
+ * example Spark adds the current user as {@code owner} when it creates or 
replaces a table, and
+ * rejecting it would fail those statements.
+ */
+final class IcebergOwnerPropertyGuard {
+
+  private IcebergOwnerPropertyGuard() {}
+
+  /**
+   * Returns the request without changes to the owner property. A commit that 
creates the table sets
+   * the property to the authenticated user instead, as a direct create does.
+   *
+   * @param request the table commit
+   * @param user the authenticated user
+   * @return the request to apply
+   */
+  static UpdateTableRequest protect(UpdateTableRequest request, String user) {
+    if (AuthConstants.ANONYMOUS_USER.equals(user) || !touchesOwner(request)) {
+      return request;
+    }
+    boolean creates =
+        request.requirements().stream()
+            
.anyMatch(UpdateRequirement.AssertTableDoesNotExist.class::isInstance);
+    List<MetadataUpdate> updates = new ArrayList<>();
+    for (MetadataUpdate update : request.updates()) {
+      if (update instanceof MetadataUpdate.SetProperties) {
+        Map<String, String> properties =
+            new HashMap<>(((MetadataUpdate.SetProperties) update).updated());
+        if (creates) {
+          properties.put(IcebergConstants.OWNER, user);
+        } else {
+          properties.remove(IcebergConstants.OWNER);
+        }
+        if (!properties.isEmpty()) {
+          updates.add(new MetadataUpdate.SetProperties(properties));
+        }
+      } else if (update instanceof MetadataUpdate.RemoveProperties) {
+        List<String> removed =
+            ((MetadataUpdate.RemoveProperties) update)
+                .removed().stream()
+                    .filter(key -> !IcebergConstants.OWNER.equals(key))
+                    .collect(Collectors.toList());
+        if (!removed.isEmpty()) {
+          updates.add(new MetadataUpdate.RemoveProperties(new 
HashSet<>(removed)));
+        }
+      } else {
+        updates.add(update);
+      }
+    }
+    return request.identifier() == null
+        ? new UpdateTableRequest(request.requirements(), updates)
+        : UpdateTableRequest.create(request.identifier(), 
request.requirements(), updates);
+  }
+
+  /**
+   * Returns the request without changes to the owner property.
+   *
+   * @param request the namespace property update
+   * @param user the authenticated user
+   * @return the request to apply
+   */
+  static UpdateNamespacePropertiesRequest protect(
+      UpdateNamespacePropertiesRequest request, String user) {
+    if (AuthConstants.ANONYMOUS_USER.equals(user)
+        || (!request.updates().containsKey(IcebergConstants.OWNER)
+            && !request.removals().contains(IcebergConstants.OWNER))) {
+      return request;
+    }
+    Map<String, String> updates = new HashMap<>(request.updates());
+    updates.remove(IcebergConstants.OWNER);
+    List<String> removals =
+        request.removals().stream()
+            .filter(key -> !IcebergConstants.OWNER.equals(key))
+            .collect(Collectors.toList());
+    return UpdateNamespacePropertiesRequest.builder()
+        .updateAll(updates)
+        .removeAll(removals)
+        .build();
+  }
+
+  private static boolean touchesOwner(UpdateTableRequest request) {
+    return request.updates().stream()
+        .anyMatch(
+            update ->
+                (update instanceof MetadataUpdate.SetProperties
+                        && ((MetadataUpdate.SetProperties) update)
+                            .updated()
+                            .containsKey(IcebergConstants.OWNER))
+                    || (update instanceof MetadataUpdate.RemoveProperties
+                        && ((MetadataUpdate.RemoveProperties) update)
+                            .removed()
+                            .contains(IcebergConstants.OWNER)));
+  }
+}
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
index d95ee792e8..4fd9c39709 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableOperationExecutor.java
@@ -124,7 +124,9 @@ public class IcebergTableOperationExecutor implements 
IcebergTableOperationDispa
     IcebergColumnFieldValidator.validateUpdate(updateTableRequest);
     return icebergCatalogWrapperManager
         .getCatalogWrapper(context.catalogName())
-        .updateTable(tableIdentifier, updateTableRequest);
+        .updateTable(
+            tableIdentifier,
+            IcebergOwnerPropertyGuard.protect(updateTableRequest, 
context.userName()));
   }
 
   @Override
diff --git 
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergOwnerPropertyGuard.java
 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergOwnerPropertyGuard.java
new file mode 100644
index 0000000000..ffbe4f11a9
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergOwnerPropertyGuard.java
@@ -0,0 +1,105 @@
+/*
+ *  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.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.iceberg.MetadataUpdate;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIcebergOwnerPropertyGuard {
+
+  @Test
+  public void testCommitCannotChangeTheOwnerProperty() {
+    UpdateTableRequest request =
+        new UpdateTableRequest(
+            Collections.emptyList(),
+            Arrays.asList(
+                new MetadataUpdate.SetProperties(ImmutableMap.of("owner", 
"mallory", "k", "v")),
+                new MetadataUpdate.RemoveProperties(ImmutableSet.of("owner")),
+                new MetadataUpdate.SetCurrentSchema(0)));
+
+    List<MetadataUpdate> updates = IcebergOwnerPropertyGuard.protect(request, 
"bob").updates();
+
+    Assertions.assertEquals(2, updates.size());
+    Assertions.assertEquals(
+        ImmutableMap.of("k", "v"), ((MetadataUpdate.SetProperties) 
updates.get(0)).updated());
+    Assertions.assertInstanceOf(MetadataUpdate.SetCurrentSchema.class, 
updates.get(1));
+  }
+
+  @Test
+  public void testCreateCommitSetsTheOwnerToTheCommitter() {
+    UpdateTableRequest request =
+        new UpdateTableRequest(
+            Collections.singletonList(new 
UpdateRequirement.AssertTableDoesNotExist()),
+            Collections.singletonList(
+                new MetadataUpdate.SetProperties(ImmutableMap.of("owner", 
"spark_user"))));
+
+    MetadataUpdate.SetProperties update =
+        (MetadataUpdate.SetProperties)
+            IcebergOwnerPropertyGuard.protect(request, "bob").updates().get(0);
+
+    Assertions.assertEquals(ImmutableMap.of("owner", "bob"), update.updated());
+  }
+
+  @Test
+  public void testRequestsWithoutTheOwnerPropertyOrUserAreUnchanged() {
+    UpdateTableRequest request =
+        new UpdateTableRequest(
+            Collections.emptyList(),
+            Collections.singletonList(new 
MetadataUpdate.SetProperties(ImmutableMap.of("k", "v"))));
+    Assertions.assertSame(request, IcebergOwnerPropertyGuard.protect(request, 
"bob"));
+
+    UpdateTableRequest withOwner =
+        new UpdateTableRequest(
+            Collections.emptyList(),
+            Collections.singletonList(
+                new MetadataUpdate.SetProperties(ImmutableMap.of("owner", 
"x"))));
+    Assertions.assertSame(withOwner, 
IcebergOwnerPropertyGuard.protect(withOwner, "anonymous"));
+  }
+
+  @Test
+  public void testNamespaceUpdateCannotChangeTheOwnerProperty() {
+    UpdateNamespacePropertiesRequest request =
+        UpdateNamespacePropertiesRequest.builder()
+            .update("owner", "mallory")
+            .update("k", "v")
+            .remove("old")
+            .build();
+
+    UpdateNamespacePropertiesRequest protectedRequest =
+        IcebergOwnerPropertyGuard.protect(request, "bob");
+
+    Assertions.assertEquals(ImmutableMap.of("k", "v"), 
protectedRequest.updates());
+    Assertions.assertEquals(Collections.singletonList("old"), 
protectedRequest.removals());
+
+    UpdateNamespacePropertiesRequest removal =
+        IcebergOwnerPropertyGuard.protect(
+            
UpdateNamespacePropertiesRequest.builder().remove("owner").remove("old").build(),
+            "bob");
+    Assertions.assertEquals(Collections.singletonList("old"), 
removal.removals());
+  }
+}

Reply via email to