Skip to content

Commit e912210

Browse files
committed
Support S3 storage that does not have STS
This change is backward compatible with old catalogs that have storage configuration for S3 systems with STS. * Add new property to S3 storage config: `stsUnavailable` (defaults to "available"). * Do not call STS when unavailable in `AwsCredentialsStorageIntegration`, but still put other properties (e.g. s3.endpoint) into `AccessConfig` * Fail create/load table requests if credential vending is requested but STS is not available.
1 parent 7409939 commit e912210

File tree

8 files changed

+173
-54
lines changed

8 files changed

+173
-54
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
4040

4141
- Added a Management API endpoint to reset principal credentials, controlled by the `ENABLE_CREDENTIAL_RESET` (default: true) feature flag.
4242

43+
- Added support for S3-compatible storage that does not have STS (use `stsUavailable: true` in catalog storage configuration)
44+
4345
### Changes
4446

4547
* The following APIs will now return the newly-created objects as part of the successful 201 response: createCatalog, createPrincipalRole, createCatalogRole.

polaris-core/src/main/java/org/apache/polaris/core/entity/CatalogEntity.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ private StorageConfigInfo getStorageInfo(Map<String, String> internalProperties)
155155
.setEndpoint(awsConfig.getEndpoint())
156156
.setStsEndpoint(awsConfig.getStsEndpoint())
157157
.setPathStyleAccess(awsConfig.getPathStyleAccess())
158+
.setStsUnavailable(awsConfig.getStsUnavailable())
158159
.setEndpointInternal(awsConfig.getEndpointInternal())
159160
.build();
160161
}
@@ -299,6 +300,7 @@ public Builder setStorageConfigurationInfo(
299300
.endpoint(awsConfigModel.getEndpoint())
300301
.stsEndpoint(awsConfigModel.getStsEndpoint())
301302
.pathStyleAccess(awsConfigModel.getPathStyleAccess())
303+
.stsUnavailable(awsConfigModel.getStsUnavailable())
302304
.endpointInternal(awsConfigModel.getEndpointInternal())
303305
.build();
304306
config = awsConfig;

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

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -79,43 +79,46 @@ public AccessConfig getSubscopedCreds(
7979
int storageCredentialDurationSeconds =
8080
realmConfig.getConfig(STORAGE_CREDENTIAL_DURATION_SECONDS);
8181
AwsStorageConfigurationInfo storageConfig = config();
82-
AssumeRoleRequest.Builder request =
83-
AssumeRoleRequest.builder()
84-
.externalId(storageConfig.getExternalId())
85-
.roleArn(storageConfig.getRoleARN())
86-
.roleSessionName("PolarisAwsCredentialsStorageIntegration")
87-
.policy(
88-
policyString(
89-
storageConfig.getAwsPartition(),
90-
allowListOperation,
91-
allowedReadLocations,
92-
allowedWriteLocations)
93-
.toJson())
94-
.durationSeconds(storageCredentialDurationSeconds);
95-
credentialsProvider.ifPresent(
96-
cp -> request.overrideConfiguration(b -> b.credentialsProvider(cp)));
97-
9882
String region = storageConfig.getRegion();
99-
@SuppressWarnings("resource")
100-
// Note: stsClientProvider returns "thin" clients that do not need closing
101-
StsClient stsClient =
102-
stsClientProvider.stsClient(StsDestination.of(storageConfig.getStsEndpointUri(), region));
103-
104-
AssumeRoleResponse response = stsClient.assumeRole(request.build());
10583
AccessConfig.Builder accessConfig = AccessConfig.builder();
106-
accessConfig.put(StorageAccessProperty.AWS_KEY_ID, response.credentials().accessKeyId());
107-
accessConfig.put(
108-
StorageAccessProperty.AWS_SECRET_KEY, response.credentials().secretAccessKey());
109-
accessConfig.put(StorageAccessProperty.AWS_TOKEN, response.credentials().sessionToken());
110-
Optional.ofNullable(response.credentials().expiration())
111-
.ifPresent(
112-
i -> {
113-
accessConfig.put(
114-
StorageAccessProperty.EXPIRATION_TIME, String.valueOf(i.toEpochMilli()));
115-
accessConfig.put(
116-
StorageAccessProperty.AWS_SESSION_TOKEN_EXPIRES_AT_MS,
117-
String.valueOf(i.toEpochMilli()));
118-
});
84+
85+
if (storageConfig.shouldUseSts()) {
86+
AssumeRoleRequest.Builder request =
87+
AssumeRoleRequest.builder()
88+
.externalId(storageConfig.getExternalId())
89+
.roleArn(storageConfig.getRoleARN())
90+
.roleSessionName("PolarisAwsCredentialsStorageIntegration")
91+
.policy(
92+
policyString(
93+
storageConfig.getAwsPartition(),
94+
allowListOperation,
95+
allowedReadLocations,
96+
allowedWriteLocations)
97+
.toJson())
98+
.durationSeconds(storageCredentialDurationSeconds);
99+
credentialsProvider.ifPresent(
100+
cp -> request.overrideConfiguration(b -> b.credentialsProvider(cp)));
101+
102+
@SuppressWarnings("resource")
103+
// Note: stsClientProvider returns "thin" clients that do not need closing
104+
StsClient stsClient =
105+
stsClientProvider.stsClient(StsDestination.of(storageConfig.getStsEndpointUri(), region));
106+
107+
AssumeRoleResponse response = stsClient.assumeRole(request.build());
108+
accessConfig.put(StorageAccessProperty.AWS_KEY_ID, response.credentials().accessKeyId());
109+
accessConfig.put(
110+
StorageAccessProperty.AWS_SECRET_KEY, response.credentials().secretAccessKey());
111+
accessConfig.put(StorageAccessProperty.AWS_TOKEN, response.credentials().sessionToken());
112+
Optional.ofNullable(response.credentials().expiration())
113+
.ifPresent(
114+
i -> {
115+
accessConfig.put(
116+
StorageAccessProperty.EXPIRATION_TIME, String.valueOf(i.toEpochMilli()));
117+
accessConfig.put(
118+
StorageAccessProperty.AWS_SESSION_TOKEN_EXPIRES_AT_MS,
119+
String.valueOf(i.toEpochMilli()));
120+
});
121+
}
119122

120123
if (region != null) {
121124
accessConfig.put(StorageAccessProperty.CLIENT_REGION, region);

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ public URI getInternalEndpointUri() {
9898
/** Flag indicating whether path-style bucket access should be forced in S3 clients. */
9999
public abstract @Nullable Boolean getPathStyleAccess();
100100

101+
/**
102+
* Flag indicating whether STS is available or not. It is modeled in the negative to simplify
103+
* support for unset values ({@code null} being interpreted as {@code false}).
104+
*/
105+
public abstract @Nullable Boolean getStsUnavailable();
106+
107+
/** Convenience getter for {@link #getStsUnavailable()} that handles defaults. */
108+
@JsonIgnore
109+
public boolean shouldUseSts() {
110+
return !Boolean.TRUE.equals(getStsUnavailable());
111+
}
112+
101113
/** Endpoint URI for STS API calls */
102114
@Nullable
103115
public abstract String getStsEndpoint();

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ public void testPathStyleAccess() {
118118
assertThat(newBuilder().pathStyleAccess(true).build().getPathStyleAccess()).isTrue();
119119
}
120120

121+
@Test
122+
public void testStsUnavailable() {
123+
assertThat(newBuilder().build().shouldUseSts()).isTrue();
124+
assertThat(newBuilder().stsUnavailable(null).build().shouldUseSts()).isTrue();
125+
assertThat(newBuilder().stsUnavailable(false).build().shouldUseSts()).isTrue();
126+
assertThat(newBuilder().stsUnavailable(true).build().shouldUseSts()).isFalse();
127+
}
128+
121129
@Test
122130
public void testRoleArnParsing() {
123131
AwsStorageConfigurationInfo awsConfig =

runtime/service/src/intTest/java/org/apache/polaris/service/it/RestCatalogMinIOSpecialIT.java

Lines changed: 98 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,19 @@
1919
package org.apache.polaris.service.it;
2020

2121
import static java.nio.charset.StandardCharsets.UTF_8;
22+
import static org.apache.iceberg.CatalogProperties.TABLE_DEFAULT_PREFIX;
2223
import static org.apache.iceberg.aws.AwsClientProperties.REFRESH_CREDENTIALS_ENDPOINT;
2324
import static org.apache.iceberg.aws.s3.S3FileIOProperties.ACCESS_KEY_ID;
2425
import static org.apache.iceberg.aws.s3.S3FileIOProperties.ENDPOINT;
2526
import static org.apache.iceberg.aws.s3.S3FileIOProperties.SECRET_ACCESS_KEY;
2627
import static org.apache.iceberg.types.Types.NestedField.optional;
2728
import static org.apache.iceberg.types.Types.NestedField.required;
29+
import static org.apache.polaris.core.storage.StorageAccessProperty.AWS_KEY_ID;
30+
import static org.apache.polaris.core.storage.StorageAccessProperty.AWS_SECRET_KEY;
2831
import static org.apache.polaris.service.catalog.AccessDelegationMode.VENDED_CREDENTIALS;
2932
import static org.apache.polaris.service.it.env.PolarisClient.polarisClient;
3033
import static org.assertj.core.api.Assertions.assertThat;
34+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3135

3236
import com.google.common.collect.ImmutableMap;
3337
import io.quarkus.test.junit.QuarkusIntegrationTest;
@@ -165,20 +169,24 @@ private RESTCatalog createCatalog(
165169
Optional<String> endpoint,
166170
Optional<String> stsEndpoint,
167171
boolean pathStyleAccess,
168-
Optional<AccessDelegationMode> delegationMode) {
169-
return createCatalog(endpoint, stsEndpoint, pathStyleAccess, Optional.empty(), delegationMode);
172+
Optional<AccessDelegationMode> delegationMode,
173+
boolean stsEnabled) {
174+
return createCatalog(
175+
endpoint, stsEndpoint, pathStyleAccess, Optional.empty(), delegationMode, stsEnabled);
170176
}
171177

172178
private RESTCatalog createCatalog(
173179
Optional<String> endpoint,
174180
Optional<String> stsEndpoint,
175181
boolean pathStyleAccess,
176182
Optional<String> endpointInternal,
177-
Optional<AccessDelegationMode> delegationMode) {
183+
Optional<AccessDelegationMode> delegationMode,
184+
boolean stsEnabled) {
178185
AwsStorageConfigInfo.Builder storageConfig =
179186
AwsStorageConfigInfo.builder()
180187
.setStorageType(StorageConfigInfo.StorageTypeEnum.S3)
181188
.setPathStyleAccess(pathStyleAccess)
189+
.setStsUnavailable(!stsEnabled)
182190
.setAllowedLocations(List.of(storageBase.toString()));
183191

184192
endpoint.ifPresent(storageConfig::setEndpoint);
@@ -187,6 +195,12 @@ private RESTCatalog createCatalog(
187195

188196
CatalogProperties.Builder catalogProps =
189197
CatalogProperties.builder(storageBase.toASCIIString() + "/" + catalogName);
198+
if (!stsEnabled) {
199+
catalogProps.addProperty(
200+
TABLE_DEFAULT_PREFIX + AWS_KEY_ID.getPropertyName(), MINIO_ACCESS_KEY);
201+
catalogProps.addProperty(
202+
TABLE_DEFAULT_PREFIX + AWS_SECRET_KEY.getPropertyName(), MINIO_SECRET_KEY);
203+
}
190204
Catalog catalog =
191205
PolarisCatalog.builder()
192206
.setType(Catalog.TypeEnum.INTERNAL)
@@ -227,9 +241,12 @@ public void cleanUp() {
227241
}
228242

229243
@ParameterizedTest
230-
@ValueSource(booleans = {true, false})
231-
public void testCreateTable(boolean pathStyle) throws IOException {
232-
LoadTableResponse response = doTestCreateTable(pathStyle, Optional.empty());
244+
@CsvSource("true, true,")
245+
@CsvSource("false, true,")
246+
@CsvSource("true, false,")
247+
@CsvSource("false, false,")
248+
public void testCreateTable(boolean pathStyle, boolean stsEnabled) throws IOException {
249+
LoadTableResponse response = doTestCreateTable(pathStyle, Optional.empty(), stsEnabled);
233250
assertThat(response.config()).doesNotContainKey(SECRET_ACCESS_KEY);
234251
assertThat(response.config()).doesNotContainKey(ACCESS_KEY_ID);
235252
assertThat(response.config()).doesNotContainKey(REFRESH_CREDENTIALS_ENDPOINT);
@@ -239,18 +256,19 @@ public void testCreateTable(boolean pathStyle) throws IOException {
239256
@ParameterizedTest
240257
@ValueSource(booleans = {true, false})
241258
public void testCreateTableVendedCredentials(boolean pathStyle) throws IOException {
242-
LoadTableResponse response = doTestCreateTable(pathStyle, Optional.of(VENDED_CREDENTIALS));
259+
LoadTableResponse response =
260+
doTestCreateTable(pathStyle, Optional.of(VENDED_CREDENTIALS), true);
243261
assertThat(response.config())
244262
.containsEntry(
245263
REFRESH_CREDENTIALS_ENDPOINT,
246264
"v1/" + catalogName + "/namespaces/test-ns/tables/t1/credentials");
247265
assertThat(response.credentials()).hasSize(1);
248266
}
249267

250-
private LoadTableResponse doTestCreateTable(boolean pathStyle, Optional<AccessDelegationMode> dm)
251-
throws IOException {
268+
private LoadTableResponse doTestCreateTable(
269+
boolean pathStyle, Optional<AccessDelegationMode> dm, boolean stsEnabled) throws IOException {
252270
try (RESTCatalog restCatalog =
253-
createCatalog(Optional.of(endpoint), Optional.empty(), pathStyle, dm)) {
271+
createCatalog(Optional.of(endpoint), Optional.empty(), pathStyle, dm, stsEnabled)) {
254272
LoadTableResponse loadTableResponse = doTestCreateTable(restCatalog, dm);
255273
if (pathStyle) {
256274
assertThat(loadTableResponse.config())
@@ -268,7 +286,8 @@ public void testInternalEndpoints() throws IOException {
268286
Optional.of(endpoint),
269287
false,
270288
Optional.of(endpoint),
271-
Optional.empty())) {
289+
Optional.empty(),
290+
true)) {
272291
StorageConfigInfo storageConfig =
273292
managementApi.getCatalog(catalogName).getStorageConfigInfo();
274293
assertThat((AwsStorageConfigInfo) storageConfig)
@@ -283,6 +302,62 @@ public void testInternalEndpoints() throws IOException {
283302
}
284303
}
285304

305+
@Test
306+
public void testCreateTableFailureWithCredentialVendingWithoutSts() throws IOException {
307+
try (RESTCatalog restCatalog =
308+
createCatalog(
309+
Optional.of(endpoint),
310+
Optional.of("http://sts.example.com"), // not called
311+
false,
312+
Optional.of(VENDED_CREDENTIALS),
313+
false)) {
314+
StorageConfigInfo storageConfig =
315+
managementApi.getCatalog(catalogName).getStorageConfigInfo();
316+
assertThat((AwsStorageConfigInfo) storageConfig)
317+
.extracting(
318+
AwsStorageConfigInfo::getEndpoint,
319+
AwsStorageConfigInfo::getStsEndpoint,
320+
AwsStorageConfigInfo::getEndpointInternal,
321+
AwsStorageConfigInfo::getPathStyleAccess,
322+
AwsStorageConfigInfo::getStsUnavailable)
323+
.containsExactly(endpoint, "http://sts.example.com", null, false, true);
324+
325+
catalogApi.createNamespace(catalogName, "test-ns");
326+
TableIdentifier id = TableIdentifier.of("test-ns", "t2");
327+
// Credential vending is not supported without STS
328+
assertThatThrownBy(() -> restCatalog.createTable(id, SCHEMA))
329+
.hasMessageContaining("but no credentials are available")
330+
.hasMessageContaining(id.toString());
331+
}
332+
}
333+
334+
@Test
335+
public void testLoadTableFailureWithCredentialVendingWithoutSts() throws IOException {
336+
try (RESTCatalog restCatalog =
337+
createCatalog(
338+
Optional.of(endpoint),
339+
Optional.of("http://sts.example.com"), // not called
340+
false,
341+
Optional.empty(),
342+
false)) {
343+
344+
catalogApi.createNamespace(catalogName, "test-ns");
345+
TableIdentifier id = TableIdentifier.of("test-ns", "t3");
346+
restCatalog.createTable(id, SCHEMA);
347+
348+
// Credential vending is not supported without STS
349+
assertThatThrownBy(
350+
() ->
351+
catalogApi.loadTable(
352+
catalogName,
353+
id,
354+
"ALL",
355+
Map.of("X-Iceberg-Access-Delegation", VENDED_CREDENTIALS.protocolValue())))
356+
.hasMessageContaining("but no credentials are available")
357+
.hasMessageContaining(id.toString());
358+
}
359+
}
360+
286361
public LoadTableResponse doTestCreateTable(
287362
RESTCatalog restCatalog, Optional<AccessDelegationMode> dm) {
288363
catalogApi.createNamespace(catalogName, "test-ns");
@@ -319,18 +394,22 @@ public LoadTableResponse doTestCreateTable(
319394
}
320395

321396
@ParameterizedTest
322-
@CsvSource("true,")
323-
@CsvSource("false,")
324-
@CsvSource("true,VENDED_CREDENTIALS")
325-
@CsvSource("false,VENDED_CREDENTIALS")
326-
public void testAppendFiles(boolean pathStyle, AccessDelegationMode delegationMode)
397+
@CsvSource("true, true,")
398+
@CsvSource("false, true,")
399+
@CsvSource("true, false,")
400+
@CsvSource("false, false,")
401+
@CsvSource("true, true, VENDED_CREDENTIALS")
402+
@CsvSource("false, true, VENDED_CREDENTIALS")
403+
public void testAppendFiles(
404+
boolean pathStyle, boolean stsEnabled, AccessDelegationMode delegationMode)
327405
throws IOException {
328406
try (RESTCatalog restCatalog =
329407
createCatalog(
330408
Optional.of(endpoint),
331409
Optional.of(endpoint),
332410
pathStyle,
333-
Optional.ofNullable(delegationMode))) {
411+
Optional.ofNullable(delegationMode),
412+
stsEnabled)) {
334413
catalogApi.createNamespace(catalogName, "test-ns");
335414
TableIdentifier id = TableIdentifier.of("test-ns", "t1");
336415
Table table = restCatalog.createTable(id, SCHEMA);
@@ -344,7 +423,8 @@ public void testAppendFiles(boolean pathStyle, AccessDelegationMode delegationMo
344423
table
345424
.locationProvider()
346425
.newDataLocation(
347-
String.format("test-file-%s-%s.txt", pathStyle, delegationMode)));
426+
String.format(
427+
"test-file-%s-%s-%s.txt", pathStyle, delegationMode, stsEnabled)));
348428
OutputFile f1 = io.newOutputFile(loc.toString());
349429
try (PositionOutputStream os = f1.create()) {
350430
os.write("Hello World".getBytes(UTF_8));

runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,12 @@ private LoadTableResponse.Builder buildLoadTableResponseWithDelegationCredential
804804
credentialDelegation.getAccessConfig(
805805
tableIdentifier, tableMetadata, actions, refreshCredentialsEndpoint);
806806
Map<String, String> credentialConfig = accessConfig.credentials();
807-
if (!credentialConfig.isEmpty() && delegationModes.contains(VENDED_CREDENTIALS)) {
807+
if (delegationModes.contains(VENDED_CREDENTIALS)) {
808+
Preconditions.checkArgument(
809+
!credentialConfig.isEmpty(),
810+
"Credential vending was requested for table %s, but no credentials are available",
811+
tableIdentifier);
812+
808813
responseBuilder.addAllConfig(credentialConfig);
809814
responseBuilder.addCredential(
810815
ImmutableCredential.builder()

spec/polaris-management-service.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,13 @@ components:
11191119
endpoint for STS requests made by the Polaris Server (optional). If not set, defaults to
11201120
'endpointInternal' (which in turn defaults to `endpoint`).
11211121
example: "https://sts.example.com:1234"
1122+
stsUnavailable:
1123+
type: boolean
1124+
description: >-
1125+
if set to `true`, instructs Polaris Servers to avoid using the STS endpoints when obtaining credentials
1126+
for accessing data and metadata files within the related catalog. Setting this property to `true`
1127+
effectively disables vending storage credentials to clients. This setting is intended for configuring
1128+
catalogs with S3-compatible storage implementations that do not support STS.
11221129
endpointInternal:
11231130
type: string
11241131
description: >-

0 commit comments

Comments
 (0)