Skip to content

Commit 453e9fb

Browse files
authored
Disable custom namespace locations (#2422)
When we create a namespace or alter its location, we must confirm that this location is within the parent location. This PR introduces introduces a check similar to the one we have for tables, where custom locations are prohibited by default. This functionality is gated behind a new behavior change flag `ALLOW_NAMESPACE_CUSTOM_LOCATION`. In addition to allowing us to revert to the old behavior, this flag allows some tests relying on arbitrarily-located namespaces to pass (such as those from upstream Iceberg). Fixes: #2417
1 parent d7ec8f2 commit 453e9fb

File tree

14 files changed

+216
-17
lines changed

14 files changed

+216
-17
lines changed

integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisApplicationIntegrationTest.java

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020

2121
import static org.apache.polaris.service.it.env.PolarisClient.polarisClient;
2222
import static org.assertj.core.api.Assertions.assertThat;
23+
import static org.assertj.core.api.Assertions.assertThatCode;
2324
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2425
import static org.awaitility.Awaitility.await;
2526

27+
import com.google.common.collect.ImmutableMap;
2628
import jakarta.ws.rs.ProcessingException;
2729
import jakarta.ws.rs.client.Entity;
2830
import jakarta.ws.rs.client.Invocation;
@@ -67,6 +69,7 @@
6769
import org.apache.polaris.core.admin.model.PolarisCatalog;
6870
import org.apache.polaris.core.admin.model.PrincipalRole;
6971
import org.apache.polaris.core.admin.model.StorageConfigInfo;
72+
import org.apache.polaris.core.config.BehaviorChangeConfiguration;
7073
import org.apache.polaris.core.entity.CatalogEntity;
7174
import org.apache.polaris.core.entity.PolarisEntityConstants;
7275
import org.apache.polaris.service.it.env.ClientPrincipal;
@@ -76,6 +79,7 @@
7679
import org.apache.polaris.service.it.env.RestApi;
7780
import org.apache.polaris.service.it.ext.PolarisIntegrationTestExtension;
7881
import org.assertj.core.api.InstanceOfAssertFactories;
82+
import org.assertj.core.api.ThrowableAssert;
7983
import org.junit.jupiter.api.AfterAll;
8084
import org.junit.jupiter.api.AfterEach;
8185
import org.junit.jupiter.api.BeforeAll;
@@ -84,6 +88,8 @@
8488
import org.junit.jupiter.api.TestInfo;
8589
import org.junit.jupiter.api.extension.ExtendWith;
8690
import org.junit.jupiter.api.io.TempDir;
91+
import org.junit.jupiter.params.ParameterizedTest;
92+
import org.junit.jupiter.params.provider.ValueSource;
8793

8894
/**
8995
* @implSpec This test expects the server to be configured with the following features configured:
@@ -186,11 +192,30 @@ private static void createCatalog(
186192
String principalRoleName,
187193
StorageConfigInfo storageConfig,
188194
String defaultBaseLocation) {
189-
CatalogProperties props =
195+
createCatalog(
196+
catalogName,
197+
catalogType,
198+
principalRoleName,
199+
storageConfig,
200+
defaultBaseLocation,
201+
ImmutableMap.of());
202+
}
203+
204+
private static void createCatalog(
205+
String catalogName,
206+
Catalog.TypeEnum catalogType,
207+
String principalRoleName,
208+
StorageConfigInfo storageConfig,
209+
String defaultBaseLocation,
210+
Map<String, String> additionalProperties) {
211+
CatalogProperties.Builder propsBuilder =
190212
CatalogProperties.builder(defaultBaseLocation)
191213
.addProperty(
192-
CatalogEntity.REPLACE_NEW_LOCATION_PREFIX_WITH_CATALOG_DEFAULT_KEY, "file:/")
193-
.build();
214+
CatalogEntity.REPLACE_NEW_LOCATION_PREFIX_WITH_CATALOG_DEFAULT_KEY, "file:/");
215+
for (var entry : additionalProperties.entrySet()) {
216+
propsBuilder.addProperty(entry.getKey(), entry.getValue());
217+
}
218+
CatalogProperties props = propsBuilder.build();
194219
Catalog catalog =
195220
catalogType.equals(Catalog.TypeEnum.INTERNAL)
196221
? PolarisCatalog.builder()
@@ -641,4 +666,53 @@ public void testRequestBodyTooLarge() throws Exception {
641666
});
642667
}
643668
}
669+
670+
@ParameterizedTest
671+
@ValueSource(booleans = {true, false})
672+
public void testNamespaceOutsideCatalog(boolean allowNamespaceLocationEscape) throws IOException {
673+
String catalogName = client.newEntityName("testNamespaceOutsideCatalog_specificLocation");
674+
String catalogLocation = baseLocation.resolve(catalogName + "/catalog").toString();
675+
String badLocation = baseLocation.resolve(catalogName + "/ns").toString();
676+
createCatalog(
677+
catalogName,
678+
Catalog.TypeEnum.INTERNAL,
679+
principalRoleName,
680+
FileStorageConfigInfo.builder(StorageConfigInfo.StorageTypeEnum.FILE)
681+
.setAllowedLocations(List.of(catalogLocation))
682+
.build(),
683+
catalogLocation,
684+
ImmutableMap.of(
685+
BehaviorChangeConfiguration.ALLOW_NAMESPACE_CUSTOM_LOCATION.catalogConfig(),
686+
String.valueOf(allowNamespaceLocationEscape)));
687+
try (RESTSessionCatalog sessionCatalog = newSessionCatalog(catalogName)) {
688+
SessionCatalog.SessionContext sessionContext = SessionCatalog.SessionContext.createEmpty();
689+
sessionCatalog.createNamespace(sessionContext, Namespace.of("good_namespace"));
690+
ThrowableAssert.ThrowingCallable createBadNamespace =
691+
() ->
692+
sessionCatalog.createNamespace(
693+
sessionContext,
694+
Namespace.of("bad_namespace"),
695+
ImmutableMap.of("location", badLocation));
696+
if (!allowNamespaceLocationEscape) {
697+
assertThatThrownBy(createBadNamespace)
698+
.isInstanceOf(BadRequestException.class)
699+
.hasMessageContaining("custom location");
700+
} else {
701+
assertThatCode(createBadNamespace).doesNotThrowAnyException();
702+
}
703+
ThrowableAssert.ThrowingCallable createBadChildGoodParent =
704+
() ->
705+
sessionCatalog.createNamespace(
706+
sessionContext,
707+
Namespace.of("good_namespace", "bad_child"),
708+
ImmutableMap.of("location", badLocation));
709+
if (!allowNamespaceLocationEscape) {
710+
assertThatThrownBy(createBadChildGoodParent)
711+
.isInstanceOf(BadRequestException.class)
712+
.hasMessageContaining("custom location");
713+
} else {
714+
assertThatCode(createBadChildGoodParent).doesNotThrowAnyException();
715+
}
716+
}
717+
}
644718
}

integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
173173
Map.of(
174174
"polaris.config.allow.unstructured.table.location", "true",
175175
"polaris.config.allow.external.table.location", "true",
176-
"polaris.config.list-pagination-enabled", "true");
176+
"polaris.config.list-pagination-enabled", "true",
177+
"polaris.config.namespace-custom-location.enabled", "true");
177178

178179
/**
179180
* Get the storage configuration information for the catalog.

plugins/spark/v3.5/integration/src/intTest/java/org/apache/polaris/spark/quarkus/it/SparkIntegrationBase.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ public void before(
100100
CatalogProperties props = new CatalogProperties("s3://my-bucket/path/to/data");
101101
props.putAll(s3Container.getS3ConfigProperties());
102102
props.put("polaris.config.drop-with-purge.enabled", "true");
103+
props.put("polaris.config.namespace-custom-location.enabled", "true");
103104
Catalog catalog =
104105
PolarisCatalog.builder()
105106
.setType(Catalog.TypeEnum.INTERNAL)

polaris-core/src/main/java/org/apache/polaris/core/config/BehaviorChangeConfiguration.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@
2222

2323
/**
2424
* Internal configuration flags for non-feature behavior changes in Polaris. These flags control
25-
* subtle behavior adjustments and bug fixes, not user-facing catalog settings. They are intended
26-
* for internal use only, are inherently unstable, and may be removed at any time. When introducing
27-
* a new flag, consider the trade-off between maintenance burden and the risk of an unguarded
28-
* behavior change. Flags here are generally short-lived and should either be removed or promoted to
29-
* stable feature flags before the next release.
25+
* subtle behavior adjustments and bug fixes, not user-facing settings. They are intended for
26+
* internal use only, are inherently unstable, and may be removed at any time. When introducing a
27+
* new flag, consider the trade-off between maintenance burden and the risk of an unguarded behavior
28+
* change. Flags here are generally short-lived and should either be removed or promoted to stable
29+
* feature flags before the next release.
3030
*
3131
* @param <T> The type of the configuration
3232
*/
@@ -74,4 +74,14 @@ protected BehaviorChangeConfiguration(
7474
+ " the committed metadata again.")
7575
.defaultValue(true)
7676
.buildBehaviorChangeConfiguration();
77+
78+
public static final BehaviorChangeConfiguration<Boolean> ALLOW_NAMESPACE_CUSTOM_LOCATION =
79+
PolarisConfiguration.<Boolean>builder()
80+
.key("ALLOW_NAMESPACE_CUSTOM_LOCATION")
81+
.catalogConfig("polaris.config.namespace-custom-location.enabled")
82+
.description(
83+
"If set to true, allow namespaces with completely arbitrary locations. This should not affect"
84+
+ " credential vending.")
85+
.defaultValue(false)
86+
.buildBehaviorChangeConfiguration();
7787
}

polaris-core/src/main/java/org/apache/polaris/core/config/PolarisConfiguration.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,6 @@ public FeatureConfiguration<T> buildFeatureConfiguration() {
209209

210210
public BehaviorChangeConfiguration<T> buildBehaviorChangeConfiguration() {
211211
validateOrThrow();
212-
if (catalogConfig.isPresent() || catalogConfigUnsafe.isPresent()) {
213-
throw new IllegalArgumentException(
214-
"catalog configs are not valid for behavior change configs");
215-
}
216212
BehaviorChangeConfiguration<T> config =
217213
new BehaviorChangeConfiguration<>(
218214
key, description, defaultValue, catalogConfig, catalogConfigUnsafe);

polaris-core/src/main/java/org/apache/polaris/core/storage/StorageLocation.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ protected StorageLocation(@Nonnull String location) {
6565
}
6666

6767
/** If a path doesn't end in `/`, this will add one */
68-
protected static String ensureTrailingSlash(String location) {
68+
public static String ensureTrailingSlash(String location) {
6969
if (location == null || location.endsWith("/")) {
7070
return location;
7171
} else {

polaris-core/src/main/java/org/apache/polaris/core/storage/aws/S3Location.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,19 @@ public String getScheme() {
6767
public String withoutScheme() {
6868
return locationWithoutScheme;
6969
}
70+
71+
@Override
72+
public int hashCode() {
73+
return withoutScheme().hashCode();
74+
}
75+
76+
/** Checks if two S3Location instances represent the same physical location. */
77+
@Override
78+
public boolean equals(Object obj) {
79+
if (obj instanceof S3Location) {
80+
return withoutScheme().equals(((StorageLocation) obj).withoutScheme());
81+
} else {
82+
return false;
83+
}
84+
}
7085
}

polaris-core/src/main/java/org/apache/polaris/core/storage/azure/AzureLocation.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,19 @@ public static boolean isAzureLocation(String location) {
126126
Matcher matcher = URI_PATTERN.matcher(location);
127127
return matcher.matches();
128128
}
129+
130+
@Override
131+
public int hashCode() {
132+
return withoutScheme().hashCode();
133+
}
134+
135+
/** Checks if two AzureLocation instances represent the same physical location. */
136+
@Override
137+
public boolean equals(Object obj) {
138+
if (obj instanceof AzureLocation) {
139+
return withoutScheme().equals(((StorageLocation) obj).withoutScheme());
140+
} else {
141+
return false;
142+
}
143+
}
129144
}

polaris-core/src/test/java/org/apache/polaris/core/storage/aws/S3LocationTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public void testPrefixValidationIgnoresScheme(String parentScheme, String childS
4747
Assertions.assertThat(loc1.isChildOf(loc2)).isTrue();
4848

4949
StorageLocation loc3 = StorageLocation.of(childScheme + "://bucket/schema1");
50-
Assertions.assertThat(loc2.equals(loc3)).isFalse();
50+
Assertions.assertThat(loc2.toString().equals(loc3.toString())).isFalse();
51+
Assertions.assertThat(loc2.equals(loc3)).isTrue();
5152
}
5253
}

regtests/t_cli/src/test_cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ def test_quickstart_flow():
110110
ROLE_ARN,
111111
'--default-base-location',
112112
f's3://fake-location-{SALT}',
113+
'--property',
114+
'polaris.config.namespace-custom-location.enabled=true',
113115
f'test_cli_catalog_{SALT}'), checker=lambda s: s == '')
114116
check_output(root_cli('catalogs', 'list'),
115117
checker=lambda s: f'test_cli_catalog_{SALT}' in s)
@@ -170,7 +172,7 @@ def test_quickstart_flow():
170172
'--property',
171173
'foo=bar',
172174
'--location',
173-
's3://custom-namespace-location'
175+
f's3://fake-location-{SALT}/custom-namespace-location/'
174176
), checker=lambda s: s == '')
175177
check_output(cli(user_token)('namespaces', 'list', '--catalog', f'test_cli_catalog_{SALT}'),
176178
checker=lambda s: f'test_cli_namespace_{SALT}' in s)
@@ -180,7 +182,7 @@ def test_quickstart_flow():
180182
'--catalog',
181183
f'test_cli_catalog_{SALT}',
182184
f'test_cli_namespace_{SALT}'
183-
), checker=lambda s: 's3://custom-namespace-location' in s and '"foo": "bar"' in s)
185+
), checker=lambda s: f's3://fake-location-{SALT}/custom-namespace-location/' in s and '"foo": "bar"' in s)
184186
check_output(cli(user_token)(
185187
'namespaces',
186188
'delete',

0 commit comments

Comments
 (0)