diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergAllowedLocationTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergAllowedLocationTest.java index f24d3dd376..789122dd86 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergAllowedLocationTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/IcebergAllowedLocationTest.java @@ -20,28 +20,39 @@ package org.apache.polaris.service.catalog.iceberg; import static org.apache.polaris.core.config.FeatureConfiguration.OPTIMIZED_SIBLING_CHECK; +import static org.apache.polaris.core.entity.table.IcebergTableLikeEntity.USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY; import static org.apache.polaris.service.admin.PolarisAuthzTestBase.SCHEMA; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import jakarta.ws.rs.core.Response; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import org.apache.iceberg.MetadataUpdate; import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ForbiddenException; 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.UpdateTableRequest; +import org.apache.iceberg.view.ImmutableSQLViewRepresentation; +import org.apache.iceberg.view.ImmutableViewVersion; import org.apache.polaris.core.admin.model.Catalog; import org.apache.polaris.core.admin.model.CatalogProperties; import org.apache.polaris.core.admin.model.CreateCatalogRequest; import org.apache.polaris.core.admin.model.FileStorageConfigInfo; import org.apache.polaris.core.admin.model.StorageConfigInfo; import org.apache.polaris.service.TestServices; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -49,6 +60,17 @@ public class IcebergAllowedLocationTest { private static final String namespace = "ns"; private static final String catalog = "test-catalog"; + private static final String VIEW_QUERY = "select * from ns.tbl"; + public static final ImmutableViewVersion VIEW_VERSION = + ImmutableViewVersion.builder() + .versionId(1) + .timestampMillis(System.currentTimeMillis()) + .schemaId(1) + .defaultNamespace(Namespace.of(namespace)) + .addRepresentations( + ImmutableSQLViewRepresentation.builder().sql(VIEW_QUERY).dialect("spark").build()) + .build(); + private String getTableName() { return "table_" + UUID.randomUUID(); } @@ -131,6 +153,155 @@ private static TestServices getTestServices() { return services; } + @Test + void testViewWithAllowedLocations(@TempDir Path tmpDir) { + var viewId = TableIdentifier.of(namespace, "view"); + var services = getTestServices(); + var catalogLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString(); + createCatalog(services, Map.of(), catalogLocation, List.of(catalogLocation)); + var namespaceLocation = catalogLocation + "/" + namespace; + createNamespace(services, namespaceLocation); + + // create a view with allowed locations + String customAllowedLocation1 = Paths.get(namespaceLocation, "custom-location1").toString(); + String customAllowedLocation2 = Paths.get(namespaceLocation, "custom-location2").toString(); + + CreateViewRequest createViewRequest = + getCreateViewRequest(customAllowedLocation2, viewId.name(), customAllowedLocation1); + var response = + services + .restApi() + .createView( + catalog, + namespace, + createViewRequest, + services.realmContext(), + services.securityContext()); + + assertEquals(response.getStatus(), Response.Status.OK.getStatusCode()); + + // update the view with allowed locations + String customAllowedLocation3 = Paths.get(namespaceLocation, "custom-location3").toString(); + + Map updatedProperties = new HashMap<>(); + updatedProperties.put(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY, customAllowedLocation3); + + UpdateTableRequest updateRequest = + UpdateTableRequest.create( + viewId, List.of(), List.of(new MetadataUpdate.SetProperties(updatedProperties))); + + var updateResponse = + services + .catalogAdapter() + .newHandlerWrapper(services.securityContext(), catalog) + .replaceView(viewId, updateRequest); + assertEquals( + updateResponse.metadata().properties().get(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY), + customAllowedLocation3); + } + + @Test + void testViewOutsideAllowedLocations(@TempDir Path tmpDir) { + var viewId = TableIdentifier.of(namespace, "view"); + var services = getTestServices(); + + var catalogBaseLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString(); + var namespaceLocation = catalogBaseLocation + "/" + namespace; + + createCatalog(services, Map.of(), catalogBaseLocation, List.of(catalogBaseLocation)); + createNamespace(services, namespaceLocation); + + var locationNotAllowed = + tmpDir.resolve("location-not-allowed").toAbsolutePath().toUri().toString(); + var locationAllowed = Paths.get(namespaceLocation, "custom-location").toString(); + + // Test 1: Create a view with allowed location, and update it with a location not allowed + var properties = new HashMap(); + + CreateViewRequest createViewRequest = + ImmutableCreateViewRequest.builder() + .name(viewId.name()) + .schema(SCHEMA) + .viewVersion(VIEW_VERSION) + .location(locationAllowed) + .properties(properties) + .build(); + + var response = + services + .restApi() + .createView( + catalog, + namespace, + createViewRequest, + services.realmContext(), + services.securityContext()); + assertEquals(response.getStatus(), Response.Status.OK.getStatusCode()); + + Map updatedProperties = new HashMap<>(); + updatedProperties.put(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY, locationNotAllowed); + + var updateRequest = + UpdateTableRequest.create( + viewId, + List.of(), // requirements + List.of(new MetadataUpdate.SetProperties(updatedProperties))); + + assertThatThrownBy( + () -> + services + .catalogAdapter() + .newHandlerWrapper(services.securityContext(), catalog) + .replaceView(viewId, updateRequest)); + + // Test 2: Try to create a view with location not allowed + var createViewRequestNotAllowed = + getCreateViewRequest(locationNotAllowed, "view2", locationNotAllowed); + + assertThatThrownBy( + () -> + services + .restApi() + .createView( + catalog, + namespace, + createViewRequestNotAllowed, + services.realmContext(), + services.securityContext())) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Invalid locations"); + + // Test 3: Try to create a view with metadata location not allowed + var createViewRequestMetadataNotAllowed = + getCreateViewRequest(locationNotAllowed, "view3", locationAllowed); + + assertThatThrownBy( + () -> + services + .restApi() + .createView( + catalog, + namespace, + createViewRequestMetadataNotAllowed, + services.realmContext(), + services.securityContext())) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Invalid locations"); + } + + private static @NotNull CreateViewRequest getCreateViewRequest( + String writeMetadataPath, String viewName, String location) { + var properties = new HashMap(); + properties.put(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY, writeMetadataPath); + return ImmutableCreateViewRequest.builder() + .name(viewName) + .schema(SCHEMA) + .viewVersion(VIEW_VERSION) + .location(location) + .properties(properties) + .build(); + } + private void createCatalog( TestServices services, Map catalogConfig,