|
20 | 20 | package org.apache.polaris.service.catalog.iceberg; |
21 | 21 |
|
22 | 22 | import static org.apache.polaris.core.config.FeatureConfiguration.OPTIMIZED_SIBLING_CHECK; |
| 23 | +import static org.apache.polaris.core.entity.table.IcebergTableLikeEntity.USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY; |
23 | 24 | import static org.apache.polaris.service.admin.PolarisAuthzTestBase.SCHEMA; |
24 | 25 | import static org.assertj.core.api.Assertions.assertThat; |
| 26 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; |
25 | 27 | import static org.junit.jupiter.api.Assertions.assertEquals; |
26 | 28 | import static org.junit.jupiter.api.Assertions.assertNotEquals; |
27 | 29 | import static org.junit.jupiter.api.Assertions.assertThrows; |
28 | 30 |
|
29 | 31 | import jakarta.ws.rs.core.Response; |
30 | 32 | import java.nio.file.Path; |
| 33 | +import java.nio.file.Paths; |
31 | 34 | import java.util.HashMap; |
32 | 35 | import java.util.List; |
33 | 36 | import java.util.Map; |
34 | 37 | import java.util.UUID; |
| 38 | +import org.apache.iceberg.MetadataUpdate; |
35 | 39 | import org.apache.iceberg.catalog.Namespace; |
| 40 | +import org.apache.iceberg.catalog.TableIdentifier; |
36 | 41 | import org.apache.iceberg.exceptions.ForbiddenException; |
37 | 42 | import org.apache.iceberg.rest.requests.CreateNamespaceRequest; |
38 | 43 | import org.apache.iceberg.rest.requests.CreateTableRequest; |
| 44 | +import org.apache.iceberg.rest.requests.CreateViewRequest; |
| 45 | +import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest; |
| 46 | +import org.apache.iceberg.rest.requests.UpdateTableRequest; |
| 47 | +import org.apache.iceberg.view.ImmutableSQLViewRepresentation; |
| 48 | +import org.apache.iceberg.view.ImmutableViewVersion; |
39 | 49 | import org.apache.polaris.core.admin.model.Catalog; |
40 | 50 | import org.apache.polaris.core.admin.model.CatalogProperties; |
41 | 51 | import org.apache.polaris.core.admin.model.CreateCatalogRequest; |
42 | 52 | import org.apache.polaris.core.admin.model.FileStorageConfigInfo; |
43 | 53 | import org.apache.polaris.core.admin.model.StorageConfigInfo; |
44 | 54 | import org.apache.polaris.service.TestServices; |
| 55 | +import org.jetbrains.annotations.NotNull; |
45 | 56 | import org.junit.jupiter.api.Test; |
46 | 57 | import org.junit.jupiter.api.io.TempDir; |
47 | 58 |
|
48 | 59 | public class IcebergAllowedLocationTest { |
49 | 60 | private static final String namespace = "ns"; |
50 | 61 | private static final String catalog = "test-catalog"; |
51 | 62 |
|
| 63 | + private static final String VIEW_QUERY = "select * from ns.tbl"; |
| 64 | + public static final ImmutableViewVersion VIEW_VERSION = |
| 65 | + ImmutableViewVersion.builder() |
| 66 | + .versionId(1) |
| 67 | + .timestampMillis(System.currentTimeMillis()) |
| 68 | + .schemaId(1) |
| 69 | + .defaultNamespace(Namespace.of(namespace)) |
| 70 | + .addRepresentations( |
| 71 | + ImmutableSQLViewRepresentation.builder().sql(VIEW_QUERY).dialect("spark").build()) |
| 72 | + .build(); |
| 73 | + |
52 | 74 | private String getTableName() { |
53 | 75 | return "table_" + UUID.randomUUID(); |
54 | 76 | } |
@@ -131,6 +153,155 @@ private static TestServices getTestServices() { |
131 | 153 | return services; |
132 | 154 | } |
133 | 155 |
|
| 156 | + @Test |
| 157 | + void testViewWithAllowedLocations(@TempDir Path tmpDir) { |
| 158 | + var viewId = TableIdentifier.of(namespace, "view"); |
| 159 | + var services = getTestServices(); |
| 160 | + var catalogLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString(); |
| 161 | + createCatalog(services, Map.of(), catalogLocation, List.of(catalogLocation)); |
| 162 | + var namespaceLocation = catalogLocation + "/" + namespace; |
| 163 | + createNamespace(services, namespaceLocation); |
| 164 | + |
| 165 | + // create a view with allowed locations |
| 166 | + String customAllowedLocation1 = Paths.get(namespaceLocation, "custom-location1").toString(); |
| 167 | + String customAllowedLocation2 = Paths.get(namespaceLocation, "custom-location2").toString(); |
| 168 | + |
| 169 | + CreateViewRequest createViewRequest = |
| 170 | + getCreateViewRequest(customAllowedLocation2, viewId.name(), customAllowedLocation1); |
| 171 | + var response = |
| 172 | + services |
| 173 | + .restApi() |
| 174 | + .createView( |
| 175 | + catalog, |
| 176 | + namespace, |
| 177 | + createViewRequest, |
| 178 | + services.realmContext(), |
| 179 | + services.securityContext()); |
| 180 | + |
| 181 | + assertEquals(response.getStatus(), Response.Status.OK.getStatusCode()); |
| 182 | + |
| 183 | + // update the view with allowed locations |
| 184 | + String customAllowedLocation3 = Paths.get(namespaceLocation, "custom-location3").toString(); |
| 185 | + |
| 186 | + Map<String, String> updatedProperties = new HashMap<>(); |
| 187 | + updatedProperties.put(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY, customAllowedLocation3); |
| 188 | + |
| 189 | + UpdateTableRequest updateRequest = |
| 190 | + UpdateTableRequest.create( |
| 191 | + viewId, List.of(), List.of(new MetadataUpdate.SetProperties(updatedProperties))); |
| 192 | + |
| 193 | + var updateResponse = |
| 194 | + services |
| 195 | + .catalogAdapter() |
| 196 | + .newHandlerWrapper(services.securityContext(), catalog) |
| 197 | + .replaceView(viewId, updateRequest); |
| 198 | + assertEquals( |
| 199 | + updateResponse.metadata().properties().get(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY), |
| 200 | + customAllowedLocation3); |
| 201 | + } |
| 202 | + |
| 203 | + @Test |
| 204 | + void testViewOutsideAllowedLocations(@TempDir Path tmpDir) { |
| 205 | + var viewId = TableIdentifier.of(namespace, "view"); |
| 206 | + var services = getTestServices(); |
| 207 | + |
| 208 | + var catalogBaseLocation = tmpDir.resolve(catalog).toAbsolutePath().toUri().toString(); |
| 209 | + var namespaceLocation = catalogBaseLocation + "/" + namespace; |
| 210 | + |
| 211 | + createCatalog(services, Map.of(), catalogBaseLocation, List.of(catalogBaseLocation)); |
| 212 | + createNamespace(services, namespaceLocation); |
| 213 | + |
| 214 | + var locationNotAllowed = |
| 215 | + tmpDir.resolve("location-not-allowed").toAbsolutePath().toUri().toString(); |
| 216 | + var locationAllowed = Paths.get(namespaceLocation, "custom-location").toString(); |
| 217 | + |
| 218 | + // Test 1: Create a view with allowed location, and update it with a location not allowed |
| 219 | + var properties = new HashMap<String, String>(); |
| 220 | + |
| 221 | + CreateViewRequest createViewRequest = |
| 222 | + ImmutableCreateViewRequest.builder() |
| 223 | + .name(viewId.name()) |
| 224 | + .schema(SCHEMA) |
| 225 | + .viewVersion(VIEW_VERSION) |
| 226 | + .location(locationAllowed) |
| 227 | + .properties(properties) |
| 228 | + .build(); |
| 229 | + |
| 230 | + var response = |
| 231 | + services |
| 232 | + .restApi() |
| 233 | + .createView( |
| 234 | + catalog, |
| 235 | + namespace, |
| 236 | + createViewRequest, |
| 237 | + services.realmContext(), |
| 238 | + services.securityContext()); |
| 239 | + assertEquals(response.getStatus(), Response.Status.OK.getStatusCode()); |
| 240 | + |
| 241 | + Map<String, String> updatedProperties = new HashMap<>(); |
| 242 | + updatedProperties.put(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY, locationNotAllowed); |
| 243 | + |
| 244 | + var updateRequest = |
| 245 | + UpdateTableRequest.create( |
| 246 | + viewId, |
| 247 | + List.of(), // requirements |
| 248 | + List.of(new MetadataUpdate.SetProperties(updatedProperties))); |
| 249 | + |
| 250 | + assertThatThrownBy( |
| 251 | + () -> |
| 252 | + services |
| 253 | + .catalogAdapter() |
| 254 | + .newHandlerWrapper(services.securityContext(), catalog) |
| 255 | + .replaceView(viewId, updateRequest)); |
| 256 | + |
| 257 | + // Test 2: Try to create a view with location not allowed |
| 258 | + var createViewRequestNotAllowed = |
| 259 | + getCreateViewRequest(locationNotAllowed, "view2", locationNotAllowed); |
| 260 | + |
| 261 | + assertThatThrownBy( |
| 262 | + () -> |
| 263 | + services |
| 264 | + .restApi() |
| 265 | + .createView( |
| 266 | + catalog, |
| 267 | + namespace, |
| 268 | + createViewRequestNotAllowed, |
| 269 | + services.realmContext(), |
| 270 | + services.securityContext())) |
| 271 | + .isInstanceOf(ForbiddenException.class) |
| 272 | + .hasMessageContaining("Invalid locations"); |
| 273 | + |
| 274 | + // Test 3: Try to create a view with metadata location not allowed |
| 275 | + var createViewRequestMetadataNotAllowed = |
| 276 | + getCreateViewRequest(locationNotAllowed, "view3", locationAllowed); |
| 277 | + |
| 278 | + assertThatThrownBy( |
| 279 | + () -> |
| 280 | + services |
| 281 | + .restApi() |
| 282 | + .createView( |
| 283 | + catalog, |
| 284 | + namespace, |
| 285 | + createViewRequestMetadataNotAllowed, |
| 286 | + services.realmContext(), |
| 287 | + services.securityContext())) |
| 288 | + .isInstanceOf(ForbiddenException.class) |
| 289 | + .hasMessageContaining("Invalid locations"); |
| 290 | + } |
| 291 | + |
| 292 | + private static @NotNull CreateViewRequest getCreateViewRequest( |
| 293 | + String writeMetadataPath, String viewName, String location) { |
| 294 | + var properties = new HashMap<String, String>(); |
| 295 | + properties.put(USER_SPECIFIED_WRITE_METADATA_LOCATION_KEY, writeMetadataPath); |
| 296 | + return ImmutableCreateViewRequest.builder() |
| 297 | + .name(viewName) |
| 298 | + .schema(SCHEMA) |
| 299 | + .viewVersion(VIEW_VERSION) |
| 300 | + .location(location) |
| 301 | + .properties(properties) |
| 302 | + .build(); |
| 303 | + } |
| 304 | + |
134 | 305 | private void createCatalog( |
135 | 306 | TestServices services, |
136 | 307 | Map<String, String> catalogConfig, |
|
0 commit comments