This is an automated email from the ASF dual-hosted git repository. emaynard pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push: new da23e669d Introduce reserved-properties setting; reserve "polaris." by default (#1417) da23e669d is described below commit da23e669dcceed15d328d5e1bf324e1a641023cd Author: Eric Maynard <eric.maynard+...@snowflake.com> AuthorDate: Fri May 9 14:22:47 2025 -0700 Introduce reserved-properties setting; reserve "polaris." by default (#1417) * initial commit * initial commit * try to test * quarkus fixes * fix a bunch of callsites * Start applying changes * autolint * chase todos * autolint * bugfix * stable * add one test * stable with more tests * autolint * more tests * autolint * stable tests * clean up * oops * stabilize on main * autolint * more changes per review --- .../PolarisManagementServiceIntegrationTest.java | 124 +++++++++++++++++++ .../it/test/PolarisRestCatalogIntegrationTest.java | 110 ++++++++++++++++- .../quarkus/config/QuarkusReservedProperties.java | 31 +++++ .../admin/PolarisAdminServiceAuthzTest.java | 3 +- .../quarkus/admin/PolarisAuthzTestBase.java | 5 +- .../quarkus/catalog/GenericTableCatalogTest.java | 8 +- .../catalog/IcebergCatalogHandlerAuthzTest.java | 9 +- .../quarkus/catalog/IcebergCatalogTest.java | 51 +++++++- .../quarkus/catalog/IcebergCatalogViewTest.java | 7 +- .../service/quarkus/catalog/PolicyCatalogTest.java | 8 +- .../polaris/service/admin/PolarisAdminService.java | 27 +++- .../polaris/service/admin/PolarisServiceImpl.java | 41 ++++-- .../generic/GenericTableCatalogAdapter.java | 8 +- .../catalog/iceberg/IcebergCatalogAdapter.java | 68 ++++++++-- .../catalog/iceberg/IcebergCatalogHandler.java | 34 +++-- .../polaris/service/config/ReservedProperties.java | 137 +++++++++++++++++++++ .../org/apache/polaris/service/TestServices.java | 9 +- 17 files changed, 628 insertions(+), 52 deletions(-) diff --git a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java index 573623021..1b3899c31 100644 --- a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java +++ b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisManagementServiceIntegrationTest.java @@ -84,6 +84,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.shaded.com.google.common.collect.ImmutableMap; import org.testcontainers.shaded.org.awaitility.Awaitility; /** @@ -2102,6 +2103,129 @@ public class PolarisManagementServiceIntegrationTest { } } + @Test + public void testCreateAndUpdateCatalogRoleWithReservedProperties() { + String catalogName = client.newEntityName("mycatalog1"); + Catalog catalog = + PolarisCatalog.builder() + .setType(Catalog.TypeEnum.INTERNAL) + .setName(catalogName) + .setProperties(new CatalogProperties("s3://required/base/location")) + .setStorageConfigInfo( + new AwsStorageConfigInfo( + "arn:aws:iam::012345678901:role/jdoe", StorageConfigInfo.StorageTypeEnum.S3)) + .build(); + managementApi.createCatalog(catalog); + + CatalogRole badCatalogRole = + new CatalogRole("mycatalogrole", Map.of("polaris.reserved", "foo"), 0L, 0L, 1); + try (Response response = + managementApi + .request("v1/catalogs/{cat}/catalog-roles", Map.of("cat", catalogName)) + .post(Entity.json(new CreateCatalogRoleRequest(badCatalogRole)))) { + assertThat(response) + .returns(Response.Status.BAD_REQUEST.getStatusCode(), Response::getStatus); + } + + CatalogRole okCatalogRole = new CatalogRole("mycatalogrole", Map.of("foo", "bar"), 0L, 0L, 1); + try (Response response = + managementApi + .request("v1/catalogs/{cat}/catalog-roles", Map.of("cat", catalogName)) + .post(Entity.json(new CreateCatalogRoleRequest(okCatalogRole)))) { + assertThat(response).returns(Response.Status.CREATED.getStatusCode(), Response::getStatus); + } + + UpdateCatalogRoleRequest updateRequest = + new UpdateCatalogRoleRequest( + okCatalogRole.getEntityVersion(), Map.of("polaris.reserved", "true")); + try (Response response = + managementApi + .request("v1/catalogs/{cat}/catalog-roles/mycatalogrole", Map.of("cat", catalogName)) + .put(Entity.json(updateRequest))) { + assertThat(response) + .returns(Response.Status.BAD_REQUEST.getStatusCode(), Response::getStatus); + } + } + + @Test + public void testCreateAndUpdatePrincipalRoleWithReservedProperties() { + String principal = "testCreateAndUpdatePrincipalRoleWithReservedProperties"; + managementApi.createPrincipal(principal); + + PrincipalRole badPrincipalRole = + new PrincipalRole( + client.newEntityName("myprincipalrole"), Map.of("polaris.reserved", "foo"), 0L, 0L, 1); + try (Response response = + managementApi + .request("v1/principal-roles") + .post(Entity.json(new CreatePrincipalRoleRequest(badPrincipalRole)))) { + assertThat(response) + .returns(Response.Status.BAD_REQUEST.getStatusCode(), Response::getStatus); + } + + PrincipalRole goodPrincipalRole = + new PrincipalRole( + client.newEntityName("myprincipalrole"), Map.of("not.reserved", "foo"), 0L, 0L, 1); + try (Response response = + managementApi + .request("v1/principal-roles") + .post(Entity.json(new CreatePrincipalRoleRequest(goodPrincipalRole)))) { + assertThat(response).returns(Response.Status.CREATED.getStatusCode(), Response::getStatus); + } + + UpdatePrincipalRoleRequest badUpdate = + new UpdatePrincipalRoleRequest( + goodPrincipalRole.getEntityVersion(), ImmutableMap.of("polaris.reserved", "true")); + try (Response response = + managementApi + .request("v1/principal-roles/{pr}", Map.of("pr", goodPrincipalRole.getName())) + .put(Entity.json(badUpdate))) { + assertThat(response) + .returns(Response.Status.BAD_REQUEST.getStatusCode(), Response::getStatus); + } + + managementApi.deletePrincipalRole(goodPrincipalRole); + managementApi.deletePrincipal(principal); + } + + @Test + public void testCreateAndUpdatePrincipalWithReservedProperties() { + String principal = "testCreateAndUpdatePrincipalWithReservedProperties"; + + Principal badPrincipal = + new Principal( + principal, "clientId", ImmutableMap.of("polaris.reserved", "true"), 0L, 0L, 1); + try (Response response = + managementApi + .request("v1/principals") + .post(Entity.json(new CreatePrincipalRequest(badPrincipal, false)))) { + assertThat(response) + .returns(Response.Status.BAD_REQUEST.getStatusCode(), Response::getStatus); + } + + Principal goodPrincipal = + new Principal(principal, "clientId", ImmutableMap.of("not.reserved", "true"), 0L, 0L, 1); + try (Response response = + managementApi + .request("v1/principals") + .post(Entity.json(new CreatePrincipalRequest(goodPrincipal, false)))) { + assertThat(response).returns(Response.Status.CREATED.getStatusCode(), Response::getStatus); + } + + UpdatePrincipalRequest badUpdate = + new UpdatePrincipalRequest( + goodPrincipal.getEntityVersion(), ImmutableMap.of("polaris.reserved", "true")); + try (Response response = + managementApi + .request("v1/principals/{p}", Map.of("p", goodPrincipal.getName())) + .put(Entity.json(badUpdate))) { + assertThat(response) + .returns(Response.Status.BAD_REQUEST.getStatusCode(), Response::getStatus); + } + + managementApi.deletePrincipal(principal); + } + public static JWTCreator.Builder defaultJwt() { Instant now = Instant.now(); return JWT.create() diff --git a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java index e55ddf1c5..ae42c5ae5 100644 --- a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java +++ b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationTest.java @@ -19,13 +19,13 @@ package org.apache.polaris.service.it.test; import static jakarta.ws.rs.core.Response.Status.NOT_FOUND; -import static javax.ws.rs.core.Response.Status.BAD_REQUEST; import static org.apache.polaris.service.it.env.PolarisClient.polarisClient; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; import jakarta.ws.rs.ProcessingException; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.Invocation; @@ -97,6 +97,7 @@ import org.apache.polaris.service.it.env.ManagementApi; import org.apache.polaris.service.it.env.PolarisApiEndpoints; import org.apache.polaris.service.it.env.PolarisClient; import org.apache.polaris.service.it.ext.PolarisIntegrationTestExtension; +import org.apache.polaris.service.types.CreateGenericTableRequest; import org.apache.polaris.service.types.GenericTable; import org.assertj.core.api.Assertions; import org.assertj.core.api.Assumptions; @@ -1409,7 +1410,7 @@ public class PolarisRestCatalogIntegrationTest extends CatalogTests<RESTCatalog> assertThatCode(() -> catalogApi.loadTable(currentCatalogName, tableIdentifier, "not-real")) .isInstanceOf(RESTException.class) .hasMessageContaining("Unrecognized snapshots") - .hasMessageContaining("code=" + BAD_REQUEST.getStatusCode()); + .hasMessageContaining("code=" + Response.Status.BAD_REQUEST.getStatusCode()); } finally { genericTableApi.purge(currentCatalogName, namespace); } @@ -1450,4 +1451,109 @@ public class PolarisRestCatalogIntegrationTest extends CatalogTests<RESTCatalog> genericTableApi.purge(currentCatalogName, namespace); } } + + @Test + public void testCreateGenericTableWithReservedProperty() { + Namespace namespace = Namespace.of("ns1"); + restCatalog.createNamespace(namespace); + TableIdentifier tableIdentifier = TableIdentifier.of(namespace, "tbl1"); + + String ns = RESTUtil.encodeNamespace(tableIdentifier.namespace()); + try (Response res = + genericTableApi + .request( + "polaris/v1/{cat}/namespaces/{ns}/generic-tables/", + Map.of("cat", currentCatalogName, "ns", ns)) + .post( + Entity.json( + new CreateGenericTableRequest( + tableIdentifier.name(), + "format", + "doc", + Map.of("polaris.reserved", "true"))))) { + Assertions.assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode()); + Assertions.assertThat(res.readEntity(String.class)).contains("reserved prefix"); + } + + genericTableApi.purge(currentCatalogName, namespace); + } + + @Test + public void testCreateNamespaceWithReservedProperty() { + Namespace namespace = Namespace.of("ns1"); + assertThatCode( + () -> { + restCatalog.createNamespace(namespace, ImmutableMap.of("polaris.reserved", "true")); + }) + .isInstanceOf(org.apache.iceberg.exceptions.BadRequestException.class) + .hasMessageContaining("reserved prefix"); + } + + @Test + public void testUpdateNamespaceWithReservedProperty() { + Namespace namespace = Namespace.of("ns1"); + restCatalog.createNamespace(namespace, ImmutableMap.of("a", "b")); + restCatalog.setProperties(namespace, ImmutableMap.of("c", "d")); + Assertions.assertThatCode( + () -> { + restCatalog.setProperties(namespace, ImmutableMap.of("polaris.reserved", "true")); + }) + .isInstanceOf(org.apache.iceberg.exceptions.BadRequestException.class) + .hasMessageContaining("reserved prefix"); + genericTableApi.purge(currentCatalogName, namespace); + } + + @Test + public void testRemoveReservedPropertyFromNamespace() { + Namespace namespace = Namespace.of("ns1"); + restCatalog.createNamespace(namespace, ImmutableMap.of("a", "b")); + restCatalog.removeProperties(namespace, Sets.newHashSet("a")); + Assertions.assertThatCode( + () -> { + restCatalog.removeProperties(namespace, Sets.newHashSet("polaris.reserved")); + }) + .isInstanceOf(org.apache.iceberg.exceptions.BadRequestException.class) + .hasMessageContaining("reserved prefix"); + genericTableApi.purge(currentCatalogName, namespace); + } + + @Test + public void testCreateTableWithReservedProperty() { + Namespace namespace = Namespace.of("ns1"); + restCatalog.createNamespace(namespace); + TableIdentifier identifier = TableIdentifier.of(namespace, "t1"); + Assertions.assertThatCode( + () -> { + restCatalog.createTable( + identifier, + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of("polaris.reserved", "")); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reserved prefix"); + genericTableApi.purge(currentCatalogName, namespace); + } + + @Test + public void testUpdateTableWithReservedProperty() { + Namespace namespace = Namespace.of("ns1"); + restCatalog.createNamespace(namespace); + TableIdentifier identifier = TableIdentifier.of(namespace, "t1"); + restCatalog.createTable(identifier, SCHEMA); + Assertions.assertThatCode( + () -> { + var txn = + restCatalog.newReplaceTableTransaction( + identifier, + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of("polaris.reserved", ""), + false); + txn.commitTransaction(); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reserved prefix"); + genericTableApi.purge(currentCatalogName, namespace); + } } diff --git a/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/QuarkusReservedProperties.java b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/QuarkusReservedProperties.java new file mode 100644 index 000000000..4d45a456e --- /dev/null +++ b/quarkus/service/src/main/java/org/apache/polaris/service/quarkus/config/QuarkusReservedProperties.java @@ -0,0 +1,31 @@ +/* + * 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.polaris.service.quarkus.config; + +import io.smallrye.config.ConfigMapping; +import java.util.List; +import org.apache.polaris.service.config.ReservedProperties; + +@ConfigMapping(prefix = "polaris.reserved-properties") +public interface QuarkusReservedProperties extends ReservedProperties { + @Override + default List<String> prefixes() { + return List.of("polaris."); + } +} diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAdminServiceAuthzTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAdminServiceAuthzTest.java index 55b2d9f34..f5a6460b1 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAdminServiceAuthzTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAdminServiceAuthzTest.java @@ -55,7 +55,8 @@ public class PolarisAdminServiceAuthzTest extends PolarisAuthzTestBase { metaStoreManager, userSecretsManager, securityContext(authenticatedPrincipal, activatedPrincipalRoles), - polarisAuthorizer); + polarisAuthorizer, + reservedProperties); } private void doTestSufficientPrivileges( diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java index 5761812f4..bb32d9915 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/admin/PolarisAuthzTestBase.java @@ -86,6 +86,7 @@ import org.apache.polaris.service.catalog.io.FileIOFactory; import org.apache.polaris.service.catalog.policy.PolicyCatalog; import org.apache.polaris.service.config.DefaultConfigurationStore; import org.apache.polaris.service.config.RealmEntityManagerFactory; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.context.CallContextCatalogFactory; import org.apache.polaris.service.context.PolarisCallContextCatalogFactory; import org.apache.polaris.service.events.PolarisEventListener; @@ -181,6 +182,7 @@ public abstract class PolarisAuthzTestBase { Map.of( FeatureConfiguration.ENFORCE_PRINCIPAL_CREDENTIAL_ROTATION_REQUIRED_CHECKING.key, true))); + protected final ReservedProperties reservedProperties = ReservedProperties.NONE; @Inject protected MetaStoreManagerFactory managerFactory; @Inject protected RealmEntityManagerFactory realmEntityManagerFactory; @@ -264,7 +266,8 @@ public abstract class PolarisAuthzTestBase { metaStoreManager, userSecretsManager, securityContext(authenticatedRoot, Set.of()), - polarisAuthorizer); + polarisAuthorizer, + reservedProperties); String storageLocation = "file:///tmp/authz"; FileStorageConfigInfo storageConfigModel = diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/GenericTableCatalogTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/GenericTableCatalogTest.java index 0b6af6e8f..a296feca6 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/GenericTableCatalogTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/GenericTableCatalogTest.java @@ -81,6 +81,7 @@ import org.apache.polaris.service.catalog.iceberg.IcebergCatalog; import org.apache.polaris.service.catalog.io.DefaultFileIOFactory; import org.apache.polaris.service.catalog.io.FileIOFactory; import org.apache.polaris.service.config.RealmEntityManagerFactory; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.events.NoOpPolarisEventListener; import org.apache.polaris.service.storage.PolarisStorageIntegrationProviderImpl; import org.apache.polaris.service.task.TaskExecutor; @@ -141,6 +142,7 @@ public class GenericTableCatalogTest { private AuthenticatedPolarisPrincipal authenticatedRoot; private PolarisEntity catalogEntity; private SecurityContext securityContext; + private ReservedProperties reservedProperties; protected static final Schema SCHEMA = new Schema( @@ -195,6 +197,9 @@ public class GenericTableCatalogTest { securityContext = Mockito.mock(SecurityContext.class); when(securityContext.getUserPrincipal()).thenReturn(authenticatedRoot); when(securityContext.isUserInRole(isA(String.class))).thenReturn(true); + + reservedProperties = ReservedProperties.NONE; + adminService = new PolarisAdminService( callContext, @@ -202,7 +207,8 @@ public class GenericTableCatalogTest { metaStoreManager, userSecretsManager, securityContext, - new PolarisAuthorizerImpl(new PolarisConfigurationStore() {})); + new PolarisAuthorizerImpl(new PolarisConfigurationStore() {}), + reservedProperties); String storageLocation = "s3://my-bucket/path/to/data"; storageConfigModel = diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerAuthzTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerAuthzTest.java index e74b7d641..6a95f7cdb 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerAuthzTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogHandlerAuthzTest.java @@ -115,7 +115,8 @@ public class IcebergCatalogHandlerAuthzTest extends PolarisAuthzTestBase { securityContext(authenticatedPrincipal, activatedPrincipalRoles), factory, catalogName, - polarisAuthorizer); + polarisAuthorizer, + reservedProperties); } /** @@ -254,7 +255,8 @@ public class IcebergCatalogHandlerAuthzTest extends PolarisAuthzTestBase { securityContext(authenticatedPrincipal, Set.of(PRINCIPAL_ROLE1, PRINCIPAL_ROLE2)), callContextCatalogFactory, CATALOG_NAME, - polarisAuthorizer); + polarisAuthorizer, + reservedProperties); // a variety of actions are all disallowed because the principal's credentials must be rotated doTestInsufficientPrivileges( @@ -287,7 +289,8 @@ public class IcebergCatalogHandlerAuthzTest extends PolarisAuthzTestBase { securityContext(authenticatedPrincipal1, Set.of(PRINCIPAL_ROLE1, PRINCIPAL_ROLE2)), callContextCatalogFactory, CATALOG_NAME, - polarisAuthorizer); + polarisAuthorizer, + reservedProperties); doTestSufficientPrivilegeSets( List.of(Set.of(PolarisPrivilege.NAMESPACE_LIST)), diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogTest.java index 5837f8822..a2d50d9a4 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogTest.java @@ -75,6 +75,7 @@ import org.apache.polaris.core.PolarisDiagnostics; import org.apache.polaris.core.admin.model.AwsStorageConfigInfo; import org.apache.polaris.core.admin.model.CreateCatalogRequest; import org.apache.polaris.core.admin.model.StorageConfigInfo; +import org.apache.polaris.core.admin.model.UpdateCatalogRequest; import org.apache.polaris.core.auth.AuthenticatedPolarisPrincipal; import org.apache.polaris.core.auth.PolarisAuthorizerImpl; import org.apache.polaris.core.config.FeatureConfiguration; @@ -116,6 +117,7 @@ import org.apache.polaris.service.catalog.io.ExceptionMappingFileIO; import org.apache.polaris.service.catalog.io.FileIOFactory; import org.apache.polaris.service.catalog.io.MeasuredFileIOFactory; import org.apache.polaris.service.config.RealmEntityManagerFactory; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.events.AfterTableCommitedEvent; import org.apache.polaris.service.events.AfterTableRefreshedEvent; import org.apache.polaris.service.events.BeforeTableCommitedEvent; @@ -124,6 +126,7 @@ import org.apache.polaris.service.events.PolarisEventListener; import org.apache.polaris.service.events.TestPolarisEventListener; import org.apache.polaris.service.exception.FakeAzureHttpResponse; import org.apache.polaris.service.exception.IcebergExceptionMapper; +import org.apache.polaris.service.quarkus.config.QuarkusReservedProperties; import org.apache.polaris.service.quarkus.test.TestData; import org.apache.polaris.service.storage.PolarisStorageIntegrationProviderImpl; import org.apache.polaris.service.task.TableCleanupTaskHandler; @@ -218,6 +221,7 @@ public abstract class IcebergCatalogTest extends CatalogTests<IcebergCatalog> { private PolarisEntity catalogEntity; private SecurityContext securityContext; private TestPolarisEventListener testPolarisEventListener; + private ReservedProperties reservedProperties; @BeforeAll public static void setUpMocks() { @@ -270,6 +274,9 @@ public abstract class IcebergCatalogTest extends CatalogTests<IcebergCatalog> { securityContext = Mockito.mock(SecurityContext.class); when(securityContext.getUserPrincipal()).thenReturn(authenticatedRoot); when(securityContext.isUserInRole(isA(String.class))).thenReturn(true); + + reservedProperties = new QuarkusReservedProperties() {}; + adminService = new PolarisAdminService( callContext, @@ -277,7 +284,8 @@ public abstract class IcebergCatalogTest extends CatalogTests<IcebergCatalog> { metaStoreManager, userSecretsManager, securityContext, - new PolarisAuthorizerImpl(new PolarisConfigurationStore() {})); + new PolarisAuthorizerImpl(new PolarisConfigurationStore() {}), + reservedProperties); String storageLocation = "s3://my-bucket/path/to/data"; AwsStorageConfigInfo storageConfigModel = @@ -1896,6 +1904,47 @@ public abstract class IcebergCatalogTest extends CatalogTests<IcebergCatalog> { .hasMessageContaining("conflict_table"); } + @Test + public void createCatalogWithReservedProperty() { + Assertions.assertThatCode( + () -> { + adminService.createCatalog( + new CreateCatalogRequest( + new CatalogEntity.Builder() + .setDefaultBaseLocation("file://") + .setName("createCatalogWithReservedProperty") + .setProperties(ImmutableMap.of("polaris.reserved", "true")) + .build() + .asCatalog())); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reserved prefix"); + } + + @Test + public void updateCatalogWithReservedProperty() { + adminService.createCatalog( + new CreateCatalogRequest( + new CatalogEntity.Builder() + .setDefaultBaseLocation("file://") + .setName("updateCatalogWithReservedProperty") + .setProperties(ImmutableMap.of("a", "b")) + .build() + .asCatalog())); + Assertions.assertThatCode( + () -> { + adminService.updateCatalog( + "updateCatalogWithReservedProperty", + UpdateCatalogRequest.builder() + .setCurrentEntityVersion(1) + .setProperties(ImmutableMap.of("polaris.reserved", "true")) + .build()); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reserved prefix"); + adminService.deleteCatalog("updateCatalogWithReservedProperty"); + } + @ParameterizedTest @ValueSource(booleans = {false, true}) public void testTableOperationsDoesNotRefreshAfterCommit(boolean updateMetadataOnCommit) { diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogViewTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogViewTest.java index c66e88b37..a7bdec4f1 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogViewTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/IcebergCatalogViewTest.java @@ -68,6 +68,7 @@ import org.apache.polaris.service.catalog.iceberg.IcebergCatalog; import org.apache.polaris.service.catalog.io.DefaultFileIOFactory; import org.apache.polaris.service.catalog.io.FileIOFactory; import org.apache.polaris.service.config.RealmEntityManagerFactory; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.events.AfterViewCommitedEvent; import org.apache.polaris.service.events.AfterViewRefreshedEvent; import org.apache.polaris.service.events.BeforeViewCommitedEvent; @@ -197,6 +198,9 @@ public class IcebergCatalogViewTest extends ViewCatalogTests<IcebergCatalog> { SecurityContext securityContext = Mockito.mock(SecurityContext.class); when(securityContext.getUserPrincipal()).thenReturn(authenticatedRoot); when(securityContext.isUserInRole(Mockito.anyString())).thenReturn(true); + + ReservedProperties reservedProperties = ReservedProperties.NONE; + PolarisAdminService adminService = new PolarisAdminService( callContext, @@ -204,7 +208,8 @@ public class IcebergCatalogViewTest extends ViewCatalogTests<IcebergCatalog> { metaStoreManager, userSecretsManager, securityContext, - new PolarisAuthorizerImpl(new PolarisConfigurationStore() {})); + new PolarisAuthorizerImpl(new PolarisConfigurationStore() {}), + reservedProperties); adminService.createCatalog( new CreateCatalogRequest( new CatalogEntity.Builder() diff --git a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java index 68e2c35e0..0ea91bb19 100644 --- a/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java +++ b/quarkus/service/src/test/java/org/apache/polaris/service/quarkus/catalog/PolicyCatalogTest.java @@ -94,6 +94,7 @@ import org.apache.polaris.service.catalog.io.DefaultFileIOFactory; import org.apache.polaris.service.catalog.io.FileIOFactory; import org.apache.polaris.service.catalog.policy.PolicyCatalog; import org.apache.polaris.service.config.RealmEntityManagerFactory; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.events.NoOpPolarisEventListener; import org.apache.polaris.service.storage.PolarisStorageIntegrationProviderImpl; import org.apache.polaris.service.task.TaskExecutor; @@ -170,6 +171,7 @@ public class PolicyCatalogTest { private AuthenticatedPolarisPrincipal authenticatedRoot; private PolarisEntity catalogEntity; private SecurityContext securityContext; + private ReservedProperties reservedProperties; @BeforeAll public static void setUpMocks() { @@ -219,6 +221,9 @@ public class PolicyCatalogTest { securityContext = Mockito.mock(SecurityContext.class); when(securityContext.getUserPrincipal()).thenReturn(authenticatedRoot); when(securityContext.isUserInRole(isA(String.class))).thenReturn(true); + + reservedProperties = ReservedProperties.NONE; + adminService = new PolarisAdminService( callContext, @@ -226,7 +231,8 @@ public class PolicyCatalogTest { metaStoreManager, userSecretsManager, securityContext, - new PolarisAuthorizerImpl(new PolarisConfigurationStore() {})); + new PolarisAuthorizerImpl(new PolarisConfigurationStore() {}), + reservedProperties); String storageLocation = "s3://my-bucket/path/to/data"; storageConfigModel = diff --git a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java index ef6a754e9..c51c9a8eb 100644 --- a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java +++ b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisAdminService.java @@ -106,6 +106,7 @@ import org.apache.polaris.core.storage.StorageLocation; import org.apache.polaris.core.storage.aws.AwsStorageConfigurationInfo; import org.apache.polaris.core.storage.azure.AzureStorageConfigurationInfo; import org.apache.polaris.service.catalog.common.CatalogHandler; +import org.apache.polaris.service.config.ReservedProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -129,6 +130,7 @@ public class PolarisAdminService { private final PolarisAuthorizer authorizer; private final PolarisMetaStoreManager metaStoreManager; private final UserSecretsManager userSecretsManager; + private final ReservedProperties reservedProperties; // Initialized in the authorize methods. private PolarisResolutionManifest resolutionManifest = null; @@ -139,7 +141,8 @@ public class PolarisAdminService { @NotNull PolarisMetaStoreManager metaStoreManager, @NotNull UserSecretsManager userSecretsManager, @NotNull SecurityContext securityContext, - @NotNull PolarisAuthorizer authorizer) { + @NotNull PolarisAuthorizer authorizer, + @NotNull ReservedProperties reservedProperties) { this.callContext = callContext; this.entityManager = entityManager; this.metaStoreManager = metaStoreManager; @@ -156,6 +159,7 @@ public class PolarisAdminService { (AuthenticatedPolarisPrincipal) securityContext.getUserPrincipal(); this.authorizer = authorizer; this.userSecretsManager = userSecretsManager; + this.reservedProperties = reservedProperties; } private PolarisCallContext getCurrentPolarisContext() { @@ -682,6 +686,7 @@ public class PolarisAdminService { new CatalogEntity.Builder(entity) .setId(metaStoreManager.generateNewEntityId(getCurrentPolarisContext()).getId()) .setCreateTimestamp(System.currentTimeMillis()) + .setProperties(reservedProperties.removeReservedProperties(entity.getPropertiesAsMap())) .build(); if (requiresSecretReferenceExtraction(catalogRequest)) { @@ -823,7 +828,10 @@ public class PolarisAdminService { CatalogEntity.Builder updateBuilder = new CatalogEntity.Builder(currentCatalogEntity); String defaultBaseLocation = currentCatalogEntity.getDefaultBaseLocation(); if (updateRequest.getProperties() != null) { - updateBuilder.setProperties(updateRequest.getProperties()); + Map<String, String> updateProperties = + reservedProperties.removeReservedPropertiesFromUpdate( + currentCatalogEntity.getPropertiesAsMap(), updateRequest.getProperties()); + updateBuilder.setProperties(updateProperties); String newDefaultBaseLocation = updateRequest.getProperties().get(CatalogEntity.DEFAULT_BASE_LOCATION_KEY); // Since defaultBaseLocation is a required field during construction of a catalog, and the @@ -972,7 +980,10 @@ public class PolarisAdminService { PrincipalEntity.Builder updateBuilder = new PrincipalEntity.Builder(currentPrincipalEntity); if (updateRequest.getProperties() != null) { - updateBuilder.setProperties(updateRequest.getProperties()); + Map<String, String> updateProperties = + reservedProperties.removeReservedPropertiesFromUpdate( + currentPrincipalEntity.getPropertiesAsMap(), updateRequest.getProperties()); + updateBuilder.setProperties(updateProperties); } PrincipalEntity updatedEntity = updateBuilder.build(); PrincipalEntity returnedEntity = @@ -1138,7 +1149,10 @@ public class PolarisAdminService { PrincipalRoleEntity.Builder updateBuilder = new PrincipalRoleEntity.Builder(currentPrincipalRoleEntity); if (updateRequest.getProperties() != null) { - updateBuilder.setProperties(updateRequest.getProperties()); + Map<String, String> updateProperties = + reservedProperties.removeReservedPropertiesFromUpdate( + currentPrincipalRoleEntity.getPropertiesAsMap(), updateRequest.getProperties()); + updateBuilder.setProperties(updateProperties); } PrincipalRoleEntity updatedEntity = updateBuilder.build(); PrincipalRoleEntity returnedEntity = @@ -1262,7 +1276,10 @@ public class PolarisAdminService { CatalogRoleEntity.Builder updateBuilder = new CatalogRoleEntity.Builder(currentCatalogRoleEntity); if (updateRequest.getProperties() != null) { - updateBuilder.setProperties(updateRequest.getProperties()); + Map<String, String> updateProperties = + reservedProperties.removeReservedPropertiesFromUpdate( + currentCatalogRoleEntity.getPropertiesAsMap(), updateRequest.getProperties()); + updateBuilder.setProperties(updateProperties); } CatalogRoleEntity updatedEntity = updateBuilder.build(); CatalogRoleEntity returnedEntity = diff --git a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java index c2c53c17b..c0eba7b0a 100644 --- a/service/common/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java +++ b/service/common/src/main/java/org/apache/polaris/service/admin/PolarisServiceImpl.java @@ -74,6 +74,7 @@ import org.apache.polaris.service.admin.api.PolarisCatalogsApiService; import org.apache.polaris.service.admin.api.PolarisPrincipalRolesApiService; import org.apache.polaris.service.admin.api.PolarisPrincipalsApiService; import org.apache.polaris.service.config.RealmEntityManagerFactory; +import org.apache.polaris.service.config.ReservedProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,6 +90,7 @@ public class PolarisServiceImpl private final MetaStoreManagerFactory metaStoreManagerFactory; private final UserSecretsManagerFactory userSecretsManagerFactory; private final CallContext callContext; + private final ReservedProperties reservedProperties; @Inject public PolarisServiceImpl( @@ -96,12 +98,14 @@ public class PolarisServiceImpl MetaStoreManagerFactory metaStoreManagerFactory, UserSecretsManagerFactory userSecretsManagerFactory, PolarisAuthorizer polarisAuthorizer, - CallContext callContext) { + CallContext callContext, + ReservedProperties reservedProperties) { this.entityManagerFactory = entityManagerFactory; this.metaStoreManagerFactory = metaStoreManagerFactory; this.userSecretsManagerFactory = userSecretsManagerFactory; this.polarisAuthorizer = polarisAuthorizer; this.callContext = callContext; + this.reservedProperties = reservedProperties; // FIXME: This is a hack to set the current context for downstream calls. CallContext.setCurrentContext(callContext); } @@ -126,7 +130,8 @@ public class PolarisServiceImpl metaStoreManager, userSecretsManager, securityContext, - polarisAuthorizer); + polarisAuthorizer, + reservedProperties); } /** From PolarisCatalogsApiService */ @@ -208,7 +213,13 @@ public class PolarisServiceImpl public Response createPrincipal( CreatePrincipalRequest request, RealmContext realmContext, SecurityContext securityContext) { PolarisAdminService adminService = newAdminService(realmContext, securityContext); - PrincipalEntity principal = PrincipalEntity.fromPrincipal(request.getPrincipal()); + PrincipalEntity principal = + new PrincipalEntity.Builder() + .setName(request.getPrincipal().getName()) + .setClientId(request.getPrincipal().getClientId()) + .setProperties( + reservedProperties.removeReservedProperties(request.getPrincipal().getProperties())) + .build(); if (Boolean.TRUE.equals(request.getCredentialRotationRequired())) { principal = new PrincipalEntity.Builder(principal).setCredentialRotationRequiredState().build(); @@ -276,11 +287,15 @@ public class PolarisServiceImpl RealmContext realmContext, SecurityContext securityContext) { PolarisAdminService adminService = newAdminService(realmContext, securityContext); + PrincipalRoleEntity entity = + new PrincipalRoleEntity.Builder() + .setName(request.getPrincipalRole().getName()) + .setProperties( + reservedProperties.removeReservedProperties( + request.getPrincipalRole().getProperties())) + .build(); PrincipalRole newPrincipalRole = - new PrincipalRoleEntity( - adminService.createPrincipalRole( - PrincipalRoleEntity.fromPrincipalRole(request.getPrincipalRole()))) - .asPrincipalRole(); + new PrincipalRoleEntity(adminService.createPrincipalRole(entity)).asPrincipalRole(); LOGGER.info("Created new principalRole {}", newPrincipalRole); return Response.status(Response.Status.CREATED).build(); } @@ -337,11 +352,15 @@ public class PolarisServiceImpl RealmContext realmContext, SecurityContext securityContext) { PolarisAdminService adminService = newAdminService(realmContext, securityContext); + CatalogRoleEntity entity = + new CatalogRoleEntity.Builder() + .setName(request.getCatalogRole().getName()) + .setProperties( + reservedProperties.removeReservedProperties( + request.getCatalogRole().getProperties())) + .build(); CatalogRole newCatalogRole = - new CatalogRoleEntity( - adminService.createCatalogRole( - catalogName, CatalogRoleEntity.fromCatalogRole(request.getCatalogRole()))) - .asCatalogRole(); + new CatalogRoleEntity(adminService.createCatalogRole(catalogName, entity)).asCatalogRole(); LOGGER.info("Created new catalogRole {}", newCatalogRole); return Response.status(Response.Status.CREATED).build(); } diff --git a/service/common/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java b/service/common/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java index 8d17989ef..0c40960f5 100644 --- a/service/common/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java +++ b/service/common/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java @@ -32,6 +32,7 @@ import org.apache.polaris.core.persistence.PolarisMetaStoreManager; import org.apache.polaris.service.catalog.CatalogPrefixParser; import org.apache.polaris.service.catalog.api.PolarisCatalogGenericTableApiService; import org.apache.polaris.service.catalog.common.CatalogAdapter; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.types.CreateGenericTableRequest; import org.apache.polaris.service.types.ListGenericTablesResponse; import org.apache.polaris.service.types.LoadGenericTableResponse; @@ -49,6 +50,7 @@ public class GenericTableCatalogAdapter private final PolarisEntityManager entityManager; private final PolarisMetaStoreManager metaStoreManager; private final PolarisAuthorizer polarisAuthorizer; + private final ReservedProperties reservedProperties; private final CatalogPrefixParser prefixParser; @Inject @@ -58,13 +60,15 @@ public class GenericTableCatalogAdapter PolarisEntityManager entityManager, PolarisMetaStoreManager metaStoreManager, PolarisAuthorizer polarisAuthorizer, - CatalogPrefixParser prefixParser) { + CatalogPrefixParser prefixParser, + ReservedProperties reservedProperties) { this.realmContext = realmContext; this.callContext = callContext; this.entityManager = entityManager; this.metaStoreManager = metaStoreManager; this.polarisAuthorizer = polarisAuthorizer; this.prefixParser = prefixParser; + this.reservedProperties = reservedProperties; // FIXME: This is a hack to set the current context for downstream calls. CallContext.setCurrentContext(callContext); @@ -98,7 +102,7 @@ public class GenericTableCatalogAdapter TableIdentifier.of(decodeNamespace(namespace), createGenericTableRequest.getName()), createGenericTableRequest.getFormat(), createGenericTableRequest.getDoc(), - createGenericTableRequest.getProperties()); + reservedProperties.removeReservedProperties(createGenericTableRequest.getProperties())); return Response.ok(response).build(); } diff --git a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java index 38026c0fa..8dec97168 100644 --- a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java +++ b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java @@ -48,10 +48,12 @@ import org.apache.iceberg.rest.requests.CommitTransactionRequest; import org.apache.iceberg.rest.requests.CreateNamespaceRequest; import org.apache.iceberg.rest.requests.CreateTableRequest; import org.apache.iceberg.rest.requests.CreateViewRequest; +import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; +import org.apache.iceberg.rest.requests.UpdateTableRequest; import org.apache.iceberg.rest.responses.ConfigResponse; import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse; import org.apache.iceberg.rest.responses.LoadTableResponse; @@ -72,6 +74,7 @@ import org.apache.polaris.service.catalog.CatalogPrefixParser; import org.apache.polaris.service.catalog.api.IcebergRestCatalogApiService; import org.apache.polaris.service.catalog.api.IcebergRestConfigurationApiService; import org.apache.polaris.service.catalog.common.CatalogAdapter; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.context.CallContextCatalogFactory; import org.apache.polaris.service.http.IcebergHttpUtil; import org.apache.polaris.service.http.IfNoneMatch; @@ -136,6 +139,7 @@ public class IcebergCatalogAdapter private final UserSecretsManager userSecretsManager; private final PolarisAuthorizer polarisAuthorizer; private final CatalogPrefixParser prefixParser; + private final ReservedProperties reservedProperties; @Inject public IcebergCatalogAdapter( @@ -146,7 +150,8 @@ public class IcebergCatalogAdapter PolarisMetaStoreManager metaStoreManager, UserSecretsManager userSecretsManager, PolarisAuthorizer polarisAuthorizer, - CatalogPrefixParser prefixParser) { + CatalogPrefixParser prefixParser, + ReservedProperties reservedProperties) { this.realmContext = realmContext; this.callContext = callContext; this.catalogFactory = catalogFactory; @@ -155,6 +160,7 @@ public class IcebergCatalogAdapter this.userSecretsManager = userSecretsManager; this.polarisAuthorizer = polarisAuthorizer; this.prefixParser = prefixParser; + this.reservedProperties = reservedProperties; // FIXME: This is a hack to set the current context for downstream calls. CallContext.setCurrentContext(callContext); @@ -192,7 +198,8 @@ public class IcebergCatalogAdapter securityContext, catalogFactory, catalogName, - polarisAuthorizer); + polarisAuthorizer, + reservedProperties); } @Override @@ -293,12 +300,19 @@ public class IcebergCatalogAdapter RealmContext realmContext, SecurityContext securityContext) { Namespace ns = decodeNamespace(namespace); + UpdateNamespacePropertiesRequest revisedRequest = + UpdateNamespacePropertiesRequest.builder() + .removeAll( + reservedProperties.removeReservedProperties( + updateNamespacePropertiesRequest.removals())) + .updateAll( + reservedProperties.removeReservedProperties( + updateNamespacePropertiesRequest.updates())) + .build(); return withCatalog( securityContext, prefix, - catalog -> - Response.ok(catalog.updateNamespaceProperties(ns, updateNamespacePropertiesRequest)) - .build()); + catalog -> Response.ok(catalog.updateNamespaceProperties(ns, revisedRequest)).build()); } private EnumSet<AccessDelegationMode> parseAccessDelegationModes(String accessDelegationMode) { @@ -490,18 +504,24 @@ public class IcebergCatalogAdapter CommitTableRequest commitTableRequest, RealmContext realmContext, SecurityContext securityContext) { + UpdateTableRequest revisedRequest = + UpdateTableRequest.create( + commitTableRequest.identifier(), + commitTableRequest.requirements(), + commitTableRequest.updates().stream() + .map(reservedProperties::removeReservedProperties) + .toList()); Namespace ns = decodeNamespace(namespace); TableIdentifier tableIdentifier = TableIdentifier.of(ns, RESTUtil.decodeString(table)); return withCatalog( securityContext, prefix, catalog -> { - if (IcebergCatalogHandler.isCreate(commitTableRequest)) { - return Response.ok( - catalog.updateTableForStagedCreate(tableIdentifier, commitTableRequest)) + if (IcebergCatalogHandler.isCreate(revisedRequest)) { + return Response.ok(catalog.updateTableForStagedCreate(tableIdentifier, revisedRequest)) .build(); } else { - return Response.ok(catalog.updateTable(tableIdentifier, commitTableRequest)).build(); + return Response.ok(catalog.updateTable(tableIdentifier, revisedRequest)).build(); } }); } @@ -513,11 +533,15 @@ public class IcebergCatalogAdapter CreateViewRequest createViewRequest, RealmContext realmContext, SecurityContext securityContext) { + CreateViewRequest revisedRequest = + ImmutableCreateViewRequest.copyOf(createViewRequest) + .withProperties( + reservedProperties.removeReservedProperties(createViewRequest.properties())); Namespace ns = decodeNamespace(namespace); return withCatalog( securityContext, prefix, - catalog -> Response.ok(catalog.createView(ns, createViewRequest)).build()); + catalog -> Response.ok(catalog.createView(ns, revisedRequest)).build()); } @Override @@ -630,12 +654,19 @@ public class IcebergCatalogAdapter CommitViewRequest commitViewRequest, RealmContext realmContext, SecurityContext securityContext) { + UpdateTableRequest revisedRequest = + UpdateTableRequest.create( + commitViewRequest.identifier(), + commitViewRequest.requirements(), + commitViewRequest.updates().stream() + .map(reservedProperties::removeReservedProperties) + .toList()); Namespace ns = decodeNamespace(namespace); TableIdentifier tableIdentifier = TableIdentifier.of(ns, RESTUtil.decodeString(view)); return withCatalog( securityContext, prefix, - catalog -> Response.ok(catalog.replaceView(tableIdentifier, commitViewRequest)).build()); + catalog -> Response.ok(catalog.replaceView(tableIdentifier, revisedRequest)).build()); } @Override @@ -644,11 +675,24 @@ public class IcebergCatalogAdapter CommitTransactionRequest commitTransactionRequest, RealmContext realmContext, SecurityContext securityContext) { + CommitTransactionRequest revisedRequest = + new CommitTransactionRequest( + commitTransactionRequest.tableChanges().stream() + .map( + r -> { + return UpdateTableRequest.create( + r.identifier(), + r.requirements(), + r.updates().stream() + .map(reservedProperties::removeReservedProperties) + .toList()); + }) + .toList()); return withCatalog( securityContext, prefix, catalog -> { - catalog.commitTransaction(commitTransactionRequest); + catalog.commitTransaction(revisedRequest); return Response.status(Response.Status.NO_CONTENT).build(); }); } diff --git a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java index 158732c32..e8912548d 100644 --- a/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java +++ b/service/common/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java @@ -96,6 +96,7 @@ import org.apache.polaris.core.storage.AccessConfig; import org.apache.polaris.core.storage.PolarisStorageActions; import org.apache.polaris.service.catalog.SupportsNotifications; import org.apache.polaris.service.catalog.common.CatalogHandler; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.context.CallContextCatalogFactory; import org.apache.polaris.service.http.IcebergHttpUtil; import org.apache.polaris.service.http.IfNoneMatch; @@ -124,6 +125,7 @@ public class IcebergCatalogHandler extends CatalogHandler implements AutoCloseab private final PolarisMetaStoreManager metaStoreManager; private final UserSecretsManager userSecretsManager; private final CallContextCatalogFactory catalogFactory; + private final ReservedProperties reservedProperties; // Catalog instance will be initialized after authorizing resolver successfully resolves // the catalog entity. @@ -142,11 +144,13 @@ public class IcebergCatalogHandler extends CatalogHandler implements AutoCloseab SecurityContext securityContext, CallContextCatalogFactory catalogFactory, String catalogName, - PolarisAuthorizer authorizer) { + PolarisAuthorizer authorizer, + ReservedProperties reservedProperties) { super(callContext, entityManager, securityContext, catalogName, authorizer); this.metaStoreManager = metaStoreManager; this.userSecretsManager = userSecretsManager; this.catalogFactory = catalogFactory; + this.reservedProperties = reservedProperties; } /** @@ -265,14 +269,17 @@ public class IcebergCatalogHandler extends CatalogHandler implements AutoCloseab // For CreateNamespace, we consider this a special case in that the creator is able to // retrieve the latest namespace metadata for the duration of the CreateNamespace // operation, even if the entityVersion and/or grantsVersion update in the interim. - namespaceCatalog.createNamespace(namespace, request.properties()); - return CreateNamespaceResponse.builder() - .withNamespace(namespace) - .setProperties( + namespaceCatalog.createNamespace( + namespace, reservedProperties.removeReservedProperties(request.properties())); + Map<String, String> filteredProperties = + reservedProperties.removeReservedProperties( resolutionManifest .getPassthroughResolvedPath(namespace) .getRawLeafEntity() - .getPropertiesAsMap()) + .getPropertiesAsMap()); + return CreateNamespaceResponse.builder() + .withNamespace(namespace) + .setProperties(filteredProperties) .build(); } else { return CatalogHandlers.createNamespace(namespaceCatalog, request); @@ -366,7 +373,16 @@ public class IcebergCatalogHandler extends CatalogHandler implements AutoCloseab if (isStaticFacade(catalog)) { throw new BadRequestException("Cannot create table on static-facade external catalogs."); } - return CatalogHandlers.createTable(baseCatalog, namespace, request); + CreateTableRequest requestWithoutReservedProperties = + CreateTableRequest.builder() + .withName(request.name()) + .withLocation(request.location()) + .withPartitionSpec(request.spec()) + .withSchema(request.schema()) + .withWriteOrder(request.writeOrder()) + .setProperties(reservedProperties.removeReservedProperties(request.properties())) + .build(); + return CatalogHandlers.createTable(baseCatalog, namespace, requestWithoutReservedProperties); } /** @@ -401,7 +417,7 @@ public class IcebergCatalogHandler extends CatalogHandler implements AutoCloseab Map<String, String> properties = Maps.newHashMap(); properties.put("created-at", OffsetDateTime.now(ZoneOffset.UTC).toString()); - properties.putAll(request.properties()); + properties.putAll(reservedProperties.removeReservedProperties(request.properties())); Table table = baseCatalog @@ -441,7 +457,7 @@ public class IcebergCatalogHandler extends CatalogHandler implements AutoCloseab Map<String, String> properties = Maps.newHashMap(); properties.put("created-at", OffsetDateTime.now(ZoneOffset.UTC).toString()); - properties.putAll(request.properties()); + properties.putAll(reservedProperties.removeReservedProperties(request.properties())); String location; if (request.location() != null) { diff --git a/service/common/src/main/java/org/apache/polaris/service/config/ReservedProperties.java b/service/common/src/main/java/org/apache/polaris/service/config/ReservedProperties.java new file mode 100644 index 000000000..3c4b108d0 --- /dev/null +++ b/service/common/src/main/java/org/apache/polaris/service/config/ReservedProperties.java @@ -0,0 +1,137 @@ +/* + * 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.polaris.service.config; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.iceberg.MetadataUpdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Used to track entity properties reserved for use by the catalog. These properties may not be + * overridden by the end user. + */ +public interface ReservedProperties { + Logger LOGGER = LoggerFactory.getLogger(ReservedProperties.class); + + /** + * Provides a {@link ReservedProperties} implementation that reserves nothing. Used for testing. + */ + ReservedProperties NONE = + new ReservedProperties() { + @Override + public List<String> prefixes() { + return List.of(); + } + }; + + /** + * A list of prefixes that are considered reserved. Any property starting with one of these + * prefixes is a reserved property. + */ + List<String> prefixes(); + + /** If true, attempts to modify a reserved property should throw an exception. */ + default boolean shouldThrow() { + return true; + } + + /** + * Removes reserved properties from a planned change to an entity. If `shouldThrow` returns true, + * this will throw an IllegalArgumentException. + * + * @param existingProperties The properties currently present for an entity + * @param updateProperties The properties present in an update to an entity + * @return The properties in the update, with changes to reserved properties removed + */ + default Map<String, String> removeReservedPropertiesFromUpdate( + Map<String, String> existingProperties, Map<String, String> updateProperties) + throws IllegalArgumentException { + Map<String, String> updatePropertiesWithoutReservedProperties = + removeReservedProperties(updateProperties); + for (var entry : updateProperties.entrySet()) { + // If a key was removed from the update, we substitute back the existing value + if (!updatePropertiesWithoutReservedProperties.containsKey(entry.getKey())) { + if (existingProperties.containsKey(entry.getKey())) { + updatePropertiesWithoutReservedProperties.put( + entry.getKey(), existingProperties.get(entry.getKey())); + } + } + } + return updatePropertiesWithoutReservedProperties; + } + + /** + * Removes reserved properties from a list of input property keys. If `shouldThrow`returns true, + * this will throw an IllegalArgumentException. + * + * @param properties A map of properties to remove reserved properties from + * @return The keys from the input list which are not reserved properties + */ + default Map<String, String> removeReservedProperties(Map<String, String> properties) + throws IllegalArgumentException { + Map<String, String> results = new HashMap<>(); + List<String> prefixes = prefixes(); + for (var entry : properties.entrySet()) { + boolean isReserved = false; + for (String prefix : prefixes) { + if (entry.getKey().startsWith(prefix)) { + isReserved = true; + String message = + String.format("Property '%s' matches reserved prefix '%s'", entry.getKey(), prefix); + if (shouldThrow()) { + throw new IllegalArgumentException(message); + } else { + LOGGER.debug(message); + } + } + } + if (!isReserved) { + results.put(entry.getKey(), entry.getValue()); + } + } + return results; + } + + /** See {@link #removeReservedProperties(Map)} */ + default List<String> removeReservedProperties(List<String> properties) + throws IllegalArgumentException { + Map<String, String> propertyMap = + properties.stream().collect(Collectors.toMap(k -> k, k -> "")); + Map<String, String> filteredMap = removeReservedProperties(propertyMap); + return filteredMap.keySet().stream().toList(); + } + + default MetadataUpdate removeReservedProperties(MetadataUpdate update) { + return switch (update) { + case MetadataUpdate.SetProperties p -> { + yield new MetadataUpdate.SetProperties(removeReservedProperties(p.updated())); + } + case MetadataUpdate.RemoveProperties p -> { + List<String> filteredProperties = removeReservedProperties(p.removed().stream().toList()); + yield new MetadataUpdate.RemoveProperties(new HashSet<>(filteredProperties)); + } + default -> update; + }; + } +} diff --git a/service/common/src/testFixtures/java/org/apache/polaris/service/TestServices.java b/service/common/src/testFixtures/java/org/apache/polaris/service/TestServices.java index cb48ad0a1..15a254a08 100644 --- a/service/common/src/testFixtures/java/org/apache/polaris/service/TestServices.java +++ b/service/common/src/testFixtures/java/org/apache/polaris/service/TestServices.java @@ -54,6 +54,7 @@ import org.apache.polaris.service.catalog.io.FileIOFactory; import org.apache.polaris.service.catalog.io.MeasuredFileIOFactory; import org.apache.polaris.service.config.DefaultConfigurationStore; import org.apache.polaris.service.config.RealmEntityManagerFactory; +import org.apache.polaris.service.config.ReservedProperties; import org.apache.polaris.service.context.CallContextCatalogFactory; import org.apache.polaris.service.context.PolarisCallContextCatalogFactory; import org.apache.polaris.service.events.PolarisEventListener; @@ -188,6 +189,8 @@ public record TestServices( fileIOFactory, polarisEventListener); + ReservedProperties reservedProperties = ReservedProperties.NONE; + IcebergCatalogAdapter service = new IcebergCatalogAdapter( realmContext, @@ -197,7 +200,8 @@ public record TestServices( metaStoreManager, userSecretsManager, authorizer, - new DefaultCatalogPrefixParser()); + new DefaultCatalogPrefixParser(), + reservedProperties); IcebergRestCatalogApi restApi = new IcebergRestCatalogApi(service); IcebergRestConfigurationApi restConfigurationApi = new IcebergRestConfigurationApi(service); @@ -244,7 +248,8 @@ public record TestServices( metaStoreManagerFactory, userSecretsManagerFactory, authorizer, - callContext)); + callContext, + reservedProperties)); return new TestServices( catalogsApi,