You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by ad...@apache.org on 2026/04/08 17:14:59 UTC
(polaris) branch main updated: Improve Access Delegation Mode Selection Algorithm (#3750)
This is an automated email from the ASF dual-hosted git repository.
adutra 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 70960cd402 Improve Access Delegation Mode Selection Algorithm (#3750)
70960cd402 is described below
commit 70960cd402e373a84f085279f2117df57046c837
Author: iting0321 <13...@users.noreply.github.com>
AuthorDate: Thu Apr 9 01:14:54 2026 +0800
Improve Access Delegation Mode Selection Algorithm (#3750)
Implement AccessDelegationModeResolver to optimize delegation mode selection based on catalog capabilities.
---------
Co-authored-by: iting0321 <it...@MacBook-11111111.local>
Co-authored-by: Alexandre Dutra <ad...@apache.org>
Co-authored-by: iting0321 <it...@MacBook-11111156.local>
---
.../it/test/PolarisRestCatalogIntegrationBase.java | 6 +-
.../service/catalog/AccessDelegationMode.java | 22 +-
.../catalog/AccessDelegationModeResolver.java | 49 +++
.../DefaultAccessDelegationModeResolver.java | 240 ++++++++++++++
.../catalog/iceberg/IcebergCatalogAdapter.java | 12 +-
.../catalog/iceberg/IcebergCatalogHandler.java | 95 +++---
.../iceberg/IcebergCatalogHandlerFactory.java | 3 +
.../catalog/AccessDelegationModeResolverTest.java | 361 +++++++++++++++++++++
.../service/catalog/AccessDelegationModeTest.java | 11 +-
.../org/apache/polaris/service/TestServices.java | 3 +
10 files changed, 731 insertions(+), 71 deletions(-)
diff --git a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
index b73ef544a8..6079261204 100644
--- a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
+++ b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
@@ -709,7 +709,7 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
/**
* Create an EXTERNAL catalog. The test configuration, by default, disables access delegation for
* EXTERNAL catalogs, so register a table and try to load it with the REST client configured to
- * try to fetch vended credentials. Expect a ForbiddenException.
+ * try to fetch vended credentials. Expect an IllegalArgumentException (HTTP 400 Bad Request).
*/
@CatalogConfig(Catalog.TypeEnum.EXTERNAL)
@RestCatalogConfig({"header.X-Iceberg-Access-Delegation", "vended-credentials"})
@@ -732,8 +732,8 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
try {
Assertions.assertThatThrownBy(
() -> restCatalog.loadTable(TableIdentifier.of(ns1, "my_table")))
- .isInstanceOf(ForbiddenException.class)
- .hasMessageContaining("Access Delegation is not enabled for this catalog")
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("is not enabled for this external catalog")
.hasMessageContaining(
FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING.catalogConfig());
} finally {
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/AccessDelegationMode.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/AccessDelegationMode.java
index 46c67622bd..bb1315dbc1 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/AccessDelegationMode.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/AccessDelegationMode.java
@@ -19,18 +19,21 @@
package org.apache.polaris.service.catalog;
import com.google.common.base.Functions;
+import jakarta.annotation.Nullable;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Represents access mechanisms defined in the Iceberg REST API specification (values for the {@code
* X-Iceberg-Access-Delegation} header).
*/
public enum AccessDelegationMode {
- UNKNOWN("unknown"),
VENDED_CREDENTIALS("vended-credentials"),
REMOTE_SIGNING("remote-signing"),
;
@@ -61,14 +64,27 @@ public enum AccessDelegationMode {
EnumSet<AccessDelegationMode> set = EnumSet.noneOf(AccessDelegationMode.class);
Arrays.stream(protocolValues.split(",")) // per Iceberg REST Catalog spec
.map(String::trim)
- .map(n -> Mapper.byProtocolValue.getOrDefault(n, UNKNOWN))
+ .map(Mapper::map)
+ .filter(Objects::nonNull)
.forEach(set::add);
return set;
}
private static class Mapper {
- private static final Map<String, AccessDelegationMode> byProtocolValue =
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(Mapper.class);
+
+ private static final Map<String, AccessDelegationMode> BY_PROTOCOL_VALUE =
Arrays.stream(AccessDelegationMode.values())
.collect(Collectors.toMap(AccessDelegationMode::protocolValue, Functions.identity()));
+
+ @Nullable
+ public static AccessDelegationMode map(String key) {
+ AccessDelegationMode mode = Mapper.BY_PROTOCOL_VALUE.get(key);
+ if (mode == null) {
+ LOGGER.warn("Unknown access delegation mode requested: {}", key);
+ }
+ return mode;
+ }
}
}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/AccessDelegationModeResolver.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/AccessDelegationModeResolver.java
new file mode 100644
index 0000000000..49b4eea1cf
--- /dev/null
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/AccessDelegationModeResolver.java
@@ -0,0 +1,49 @@
+/*
+ * 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.catalog;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import java.util.EnumSet;
+import java.util.Optional;
+import org.apache.polaris.core.entity.CatalogEntity;
+
+/**
+ * Resolves the optimal {@link AccessDelegationMode} to use based on the client's requested modes
+ * and the catalog's capabilities.
+ */
+public interface AccessDelegationModeResolver {
+
+ /**
+ * Resolves the optimal access delegation mode based on the requested modes and catalog
+ * capabilities.
+ *
+ * @param requestedModes The set of delegation modes requested by the client
+ * @param catalogEntity The catalog entity, used to determine storage configuration and
+ * capabilities
+ * @return The resolved access delegation mode, or empty if no suitable mode is available.
+ * @throws IllegalArgumentException if the client requests a mode that is explicitly disallowed
+ * for the catalog and no viable fallback mode exists (e.g., {@link
+ * AccessDelegationMode#VENDED_CREDENTIALS} on an external catalog whose credential vending
+ * feature is disabled).
+ */
+ @Nonnull
+ Optional<AccessDelegationMode> resolve(
+ @Nonnull EnumSet<AccessDelegationMode> requestedModes, @Nullable CatalogEntity catalogEntity);
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/DefaultAccessDelegationModeResolver.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/DefaultAccessDelegationModeResolver.java
new file mode 100644
index 0000000000..1b56f09781
--- /dev/null
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/DefaultAccessDelegationModeResolver.java
@@ -0,0 +1,240 @@
+/*
+ * 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.catalog;
+
+import static org.apache.polaris.service.catalog.AccessDelegationMode.REMOTE_SIGNING;
+import static org.apache.polaris.service.catalog.AccessDelegationMode.VENDED_CREDENTIALS;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.util.EnumSet;
+import java.util.Optional;
+import org.apache.polaris.core.config.FeatureConfiguration;
+import org.apache.polaris.core.config.RealmConfig;
+import org.apache.polaris.core.entity.CatalogEntity;
+import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo;
+import org.apache.polaris.core.storage.aws.AwsStorageConfigurationInfo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Default implementation of {@link AccessDelegationModeResolver} that resolves the optimal access
+ * delegation mode based on catalog capabilities and configuration.
+ *
+ * <p>The resolution logic:
+ *
+ * <ol>
+ * <li>If no delegation mode is requested, returns empty.
+ * <li>If exactly one delegation mode is requested:
+ * <ul>
+ * <li>If {@link AccessDelegationMode#VENDED_CREDENTIALS} is requested on an external
+ * catalog where credential vending is disabled (via {@link
+ * FeatureConfiguration#ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING} or {@link
+ * FeatureConfiguration#ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING}), throws {@link
+ * IllegalArgumentException}.
+ * <li>Otherwise returns that mode as-is.
+ * </ul>
+ * <li>If both {@link AccessDelegationMode#VENDED_CREDENTIALS} and {@link
+ * AccessDelegationMode#REMOTE_SIGNING} are requested, picks the best viable mode:
+ * <ul>
+ * <li>If the catalog is external and {@link
+ * FeatureConfiguration#ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING} is disabled, returns
+ * {@link AccessDelegationMode#REMOTE_SIGNING} (graceful degradation).
+ * <li>If the catalog is external/federated and {@link
+ * FeatureConfiguration#ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING} is disabled,
+ * returns {@link AccessDelegationMode#REMOTE_SIGNING}.
+ * <li>If credential subscoping is skipped, returns {@link
+ * AccessDelegationMode#REMOTE_SIGNING}.
+ * <li>If STS is unavailable for the catalog's AWS storage, returns {@link
+ * AccessDelegationMode#REMOTE_SIGNING}.
+ * <li>Otherwise returns {@link AccessDelegationMode#VENDED_CREDENTIALS}.
+ * </ul>
+ * </ol>
+ */
+@ApplicationScoped
+public class DefaultAccessDelegationModeResolver implements AccessDelegationModeResolver {
+
+ private static final Logger LOGGER =
+ LoggerFactory.getLogger(DefaultAccessDelegationModeResolver.class);
+
+ private final RealmConfig realmConfig;
+
+ @Inject
+ public DefaultAccessDelegationModeResolver(RealmConfig realmConfig) {
+ this.realmConfig = realmConfig;
+ }
+
+ @Override
+ public @Nonnull Optional<AccessDelegationMode> resolve(
+ @Nonnull EnumSet<AccessDelegationMode> requestedModes,
+ @Nullable CatalogEntity catalogEntity) {
+
+ // Case 1: No valid delegation mode found, or none requested
+ if (requestedModes.isEmpty()) {
+ return Optional.empty();
+ }
+
+ // Case 2: Exactly one delegation mode requested
+ if (requestedModes.size() == 1) {
+ AccessDelegationMode mode = requestedModes.iterator().next();
+ // REMOTE_SIGNING does not involve Polaris vending credentials to the client, so the
+ // credential-vending feature flags below do not apply to it.
+ if (mode == VENDED_CREDENTIALS) {
+ if (isExternalCatalogCredentialVendingDisabled(catalogEntity)) {
+ throw new IllegalArgumentException(
+ "Credential vending is not enabled for this external catalog. Please consult "
+ + "applicable documentation for the catalog config property '"
+ + FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING.catalogConfig()
+ + "' to enable this feature");
+ }
+ if (catalogEntity != null
+ && catalogEntity.isExternal()
+ && !realmConfig.getConfig(
+ FeatureConfiguration.ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING, catalogEntity)) {
+ throw new IllegalArgumentException(
+ "Credential vending is not enabled for this federated catalog. Please consult "
+ + "applicable documentation for the catalog config property '"
+ + FeatureConfiguration.ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING.catalogConfig()
+ + "' to enable this feature");
+ }
+ }
+ LOGGER.debug("Single delegation mode requested: {}", mode);
+ return Optional.of(mode);
+ }
+
+ // Case 3: Both VENDED_CREDENTIALS and REMOTE_SIGNING requested
+
+ // If no catalog entity available, default to VENDED_CREDENTIALS for backward compatibility
+ if (catalogEntity == null) {
+ LOGGER.debug(
+ "No catalog entity available for mode resolution, defaulting to VENDED_CREDENTIALS");
+ return Optional.of(VENDED_CREDENTIALS);
+ }
+
+ // Check ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING first — this is the broader gate that
+ // controls whether external catalogs may vend credentials at all. When both modes are
+ // requested and this gate is closed, gracefully fall back to REMOTE_SIGNING instead of
+ // throwing, because REMOTE_SIGNING is a viable alternative that does not require credential
+ // vending.
+ if (isExternalCatalogCredentialVendingDisabled(catalogEntity)) {
+ LOGGER.debug(
+ "Credential vending not allowed for external catalog {}, selecting REMOTE_SIGNING",
+ catalogEntity.getName());
+ return Optional.of(REMOTE_SIGNING);
+ }
+
+ // Check if credential vending is enabled for this catalog.
+ // For internal catalogs, credential vending is always enabled.
+ // For external/federated catalogs, check if ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING is
+ // enabled. Note: reaching here means ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING is true.
+ boolean credentialVendingEnabled =
+ !catalogEntity.isExternal()
+ || realmConfig.getConfig(
+ FeatureConfiguration.ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING, catalogEntity);
+
+ if (!credentialVendingEnabled) {
+ LOGGER.debug(
+ "Credential vending is not enabled for external catalog {}, selecting REMOTE_SIGNING",
+ catalogEntity.getName());
+ return Optional.of(REMOTE_SIGNING);
+ }
+
+ // Check if credential subscoping is skipped - if so, VENDED_CREDENTIALS won't work properly
+ // Note: This config is realm-level only (not overridable at catalog level) and has a default
+ // value
+ boolean skipCredentialSubscoping =
+ realmConfig.getConfig(FeatureConfiguration.SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION);
+
+ if (skipCredentialSubscoping) {
+ LOGGER.debug("Credential subscoping is skipped for this realm, selecting REMOTE_SIGNING");
+ return Optional.of(REMOTE_SIGNING);
+ }
+
+ // Check credential vending availability from storage configuration
+ boolean credentialVendingAvailable = isCredentialVendingAvailable(catalogEntity);
+
+ if (credentialVendingAvailable) {
+ LOGGER.debug(
+ "Credential vending is available for catalog {}, selecting VENDED_CREDENTIALS",
+ catalogEntity.getName());
+ return Optional.of(VENDED_CREDENTIALS);
+ } else {
+ LOGGER.debug(
+ "Credential vending is not available for catalog {}, selecting REMOTE_SIGNING",
+ catalogEntity.getName());
+ return Optional.of(REMOTE_SIGNING);
+ }
+ }
+
+ /**
+ * Returns true if the catalog is an external catalog and credential vending is disabled for it
+ * via {@link FeatureConfiguration#ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING}.
+ *
+ * <p>Internal catalogs always allow credential vending, so this returns false for them.
+ */
+ private boolean isExternalCatalogCredentialVendingDisabled(
+ @Nullable CatalogEntity catalogEntity) {
+ if (catalogEntity == null || !catalogEntity.isExternal()) {
+ return false;
+ }
+ return !realmConfig.getConfig(
+ FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING, catalogEntity);
+ }
+
+ /**
+ * Determines if STS is available for the catalog's storage configuration.
+ *
+ * <p>For AWS storage, this checks the {@link AwsStorageConfigurationInfo#getStsUnavailable()}
+ * flag. For other storage types, STS is considered available by default (since they may have
+ * their own credential vending mechanisms).
+ *
+ * @param catalogEntity The catalog entity to check
+ * @return true if STS is available or the storage type doesn't require STS
+ */
+ private boolean isCredentialVendingAvailable(@Nonnull CatalogEntity catalogEntity) {
+ PolarisStorageConfigurationInfo storageConfig = catalogEntity.getStorageConfigurationInfo();
+
+ if (storageConfig == null) {
+ LOGGER.debug(
+ "No storage configuration found for catalog {}, assuming STS is available",
+ catalogEntity.getName());
+ return true;
+ }
+
+ if (storageConfig instanceof AwsStorageConfigurationInfo awsConfig) {
+ // STS is available unless explicitly marked as unavailable
+ boolean stsUnavailable = Boolean.TRUE.equals(awsConfig.getStsUnavailable());
+ LOGGER.debug(
+ "AWS storage configuration for catalog {}: stsUnavailable={}",
+ catalogEntity.getName(),
+ stsUnavailable);
+ return !stsUnavailable;
+ }
+
+ // For non-AWS storage types, assume STS-like functionality is available
+ // (e.g., Azure uses different mechanisms, GCP uses service accounts)
+ LOGGER.debug(
+ "Non-AWS storage type {} for catalog {}, assuming credential vending is available",
+ storageConfig.getStorageType(),
+ catalogEntity.getName());
+ return true;
+ }
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
index 5c4db21bb6..382515be6b 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
@@ -18,12 +18,10 @@
*/
package org.apache.polaris.service.catalog.iceberg;
-import static org.apache.polaris.service.catalog.AccessDelegationMode.VENDED_CREDENTIALS;
import static org.apache.polaris.service.catalog.common.CatalogUtils.decodeNamespace;
import static org.apache.polaris.service.catalog.validation.IcebergPropertiesValidation.validateIcebergProperties;
import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Preconditions;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.HttpHeaders;
@@ -245,13 +243,9 @@ public class IcebergCatalogAdapter
}
private EnumSet<AccessDelegationMode> parseAccessDelegationModes(String accessDelegationMode) {
- EnumSet<AccessDelegationMode> delegationModes =
- AccessDelegationMode.fromProtocolValuesList(accessDelegationMode);
- Preconditions.checkArgument(
- delegationModes.isEmpty() || delegationModes.contains(VENDED_CREDENTIALS),
- "Unsupported access delegation mode: %s",
- accessDelegationMode);
- return delegationModes;
+ // Parse the access delegation modes - validation will happen after mode resolution
+ // in IcebergCatalogHandler.resolveAccessDelegationModes()
+ return AccessDelegationMode.fromProtocolValuesList(accessDelegationMode);
}
@Override
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
index 9d4f057c2a..d69e843b04 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
@@ -118,6 +118,7 @@ import org.apache.polaris.core.storage.StorageAccessConfig;
import org.apache.polaris.core.storage.StorageUtil;
import org.apache.polaris.immutables.PolarisImmutable;
import org.apache.polaris.service.catalog.AccessDelegationMode;
+import org.apache.polaris.service.catalog.AccessDelegationModeResolver;
import org.apache.polaris.service.catalog.CatalogPrefixParser;
import org.apache.polaris.service.catalog.SupportsNotifications;
import org.apache.polaris.service.catalog.common.CatalogHandler;
@@ -208,6 +209,8 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
protected abstract Clock clock();
+ protected abstract AccessDelegationModeResolver accessDelegationModeResolver();
+
// Catalog instance will be initialized after authorizing resolver successfully resolves
// the catalog entity.
@SuppressWarnings("immutables:incompat")
@@ -436,24 +439,21 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
}
public void authorizeCreateTableDirect(
- Namespace namespace,
- CreateTableRequest request,
- EnumSet<AccessDelegationMode> delegationModes) {
- if (delegationModes.isEmpty()) {
- TableIdentifier identifier = TableIdentifier.of(namespace, request.name());
- authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
- PolarisAuthorizableOperation.CREATE_TABLE_DIRECT, identifier);
- } else {
+ Namespace namespace, CreateTableRequest request, boolean delegationRequested) {
+ if (delegationRequested) {
authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
PolarisAuthorizableOperation.CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION,
TableIdentifier.of(namespace, request.name()));
+ } else {
+ TableIdentifier identifier = TableIdentifier.of(namespace, request.name());
+ authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
+ PolarisAuthorizableOperation.CREATE_TABLE_DIRECT, identifier);
}
CatalogEntity catalog = getResolvedCatalogEntity();
if (catalog.isStaticFacade()) {
throw new BadRequestException("Cannot create table on static-facade external catalogs.");
}
- checkAllowExternalCatalogCredentialVending(delegationModes);
}
public LoadTableResponse createTableDirect(
@@ -462,7 +462,8 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
EnumSet<AccessDelegationMode> delegationModes,
Optional<String> refreshCredentialsEndpoint) {
- authorizeCreateTableDirect(namespace, request, delegationModes);
+ authorizeCreateTableDirect(namespace, request, !delegationModes.isEmpty());
+ Optional<AccessDelegationMode> resolvedMode = resolveAccessDelegationModes(delegationModes);
request.validate();
@@ -490,7 +491,7 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
return buildLoadTableResponseWithDelegationCredentials(
tableIdentifier,
tableMetadata,
- delegationModes,
+ resolvedMode,
Set.of(
PolarisStorageActions.READ,
PolarisStorageActions.WRITE,
@@ -564,16 +565,14 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
}
private void authorizeCreateTableStaged(
- Namespace namespace,
- CreateTableRequest request,
- EnumSet<AccessDelegationMode> delegationModes) {
- if (delegationModes.isEmpty()) {
+ Namespace namespace, CreateTableRequest request, boolean delegationRequested) {
+ if (delegationRequested) {
authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
- PolarisAuthorizableOperation.CREATE_TABLE_STAGED,
+ PolarisAuthorizableOperation.CREATE_TABLE_STAGED_WITH_WRITE_DELEGATION,
TableIdentifier.of(namespace, request.name()));
} else {
authorizeCreateTableLikeUnderNamespaceOperationOrThrow(
- PolarisAuthorizableOperation.CREATE_TABLE_STAGED_WITH_WRITE_DELEGATION,
+ PolarisAuthorizableOperation.CREATE_TABLE_STAGED,
TableIdentifier.of(namespace, request.name()));
}
@@ -581,7 +580,6 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
if (catalog.isStaticFacade()) {
throw new BadRequestException("Cannot create table on static-facade external catalogs.");
}
- checkAllowExternalCatalogCredentialVending(delegationModes);
}
public LoadTableResponse createTableStaged(
@@ -590,7 +588,8 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
EnumSet<AccessDelegationMode> delegationModes,
Optional<String> refreshCredentialsEndpoint) {
- authorizeCreateTableStaged(namespace, request, delegationModes);
+ authorizeCreateTableStaged(namespace, request, !delegationModes.isEmpty());
+ Optional<AccessDelegationMode> resolvedMode = resolveAccessDelegationModes(delegationModes);
TableIdentifier ident = TableIdentifier.of(namespace, request.name());
TableMetadata metadata = stageTableCreateHelper(namespace, request);
@@ -598,7 +597,7 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
return buildLoadTableResponseWithDelegationCredentials(
ident,
metadata,
- delegationModes,
+ resolvedMode,
Set.of(PolarisStorageActions.ALL),
refreshCredentialsEndpoint)
.build();
@@ -819,8 +818,8 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
}
private Set<PolarisStorageActions> authorizeLoadTable(
- TableIdentifier tableIdentifier, EnumSet<AccessDelegationMode> delegationModes) {
- if (delegationModes.isEmpty()) {
+ TableIdentifier tableIdentifier, boolean delegationRequested) {
+ if (!delegationRequested) {
authorizeBasicTableLikeOperationOrThrow(
PolarisAuthorizableOperation.LOAD_TABLE,
PolarisEntitySubType.ICEBERG_TABLE,
@@ -851,8 +850,6 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
read, PolarisEntitySubType.ICEBERG_TABLE, tableIdentifier);
}
- checkAllowExternalCatalogCredentialVending(delegationModes);
-
return actionsRequested;
}
@@ -864,7 +861,8 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
Optional<String> refreshCredentialsEndpoint) {
Set<PolarisStorageActions> actionsRequested =
- authorizeLoadTable(tableIdentifier, delegationModes);
+ authorizeLoadTable(tableIdentifier, !delegationModes.isEmpty());
+ Optional<AccessDelegationMode> resolvedMode = resolveAccessDelegationModes(delegationModes);
if (ifNoneMatch != null) {
// Perform freshness-aware table loading if caller specified ifNoneMatch.
@@ -896,7 +894,7 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
buildLoadTableResponseWithDelegationCredentials(
tableIdentifier,
tableMetadata,
- delegationModes,
+ resolvedMode,
actionsRequested,
refreshCredentialsEndpoint)
.build();
@@ -913,7 +911,7 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
private LoadTableResponse.Builder buildLoadTableResponseWithDelegationCredentials(
TableIdentifier tableIdentifier,
TableMetadata tableMetadata,
- EnumSet<AccessDelegationMode> delegationModes,
+ Optional<AccessDelegationMode> delegationMode,
Set<PolarisStorageActions> actions,
Optional<String> refreshCredentialsEndpoint) {
LoadTableResponse.Builder responseBuilder =
@@ -947,7 +945,7 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
refreshCredentialsEndpoint,
resolvedStoragePath);
Map<String, String> credentialConfig = storageAccessConfig.credentials();
- if (delegationModes.contains(VENDED_CREDENTIALS)) {
+ if (VENDED_CREDENTIALS.equals(delegationMode.orElse(null))) {
if (!credentialConfig.isEmpty()) {
responseBuilder.addAllConfig(credentialConfig);
responseBuilder.addCredential(
@@ -1397,31 +1395,28 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
}
}
- private void checkAllowExternalCatalogCredentialVending(
- EnumSet<AccessDelegationMode> delegationModes) {
+ /**
+ * Resolves the access delegation mode by delegating to the configured {@link
+ * AccessDelegationModeResolver}.
+ *
+ * @param requestedModes The non-empty set of delegation modes requested by the client
+ * @return The resolved access delegation mode, or empty if no delegation mode was resolved
+ */
+ protected Optional<AccessDelegationMode> resolveAccessDelegationModes(
+ EnumSet<AccessDelegationMode> requestedModes) {
- if (delegationModes.isEmpty()) {
- return;
- }
CatalogEntity catalogEntity = getResolvedCatalogEntity();
+ Optional<AccessDelegationMode> resolvedMode =
+ accessDelegationModeResolver().resolve(requestedModes, catalogEntity);
- LOGGER.info("Catalog type: {}", catalogEntity.getCatalogType());
- LOGGER.info(
- "allow external catalog credential vending: {}",
- realmConfig()
- .getConfig(
- FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING, catalogEntity));
- if (catalogEntity
- .getCatalogType()
- .equals(org.apache.polaris.core.admin.model.Catalog.TypeEnum.EXTERNAL)
- && !realmConfig()
- .getConfig(
- FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING, catalogEntity)) {
- throw new ForbiddenException(
- "Access Delegation is not enabled for this catalog. Please consult applicable "
- + "documentation for the catalog config property '%s' to enable this feature",
- FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING.catalogConfig());
- }
+ // TODO remove when remote signing is implemented
+ // Reject if the resolved mode is REMOTE_SIGNING since it's not yet supported
+ Preconditions.checkArgument(
+ resolvedMode.orElse(null) != AccessDelegationMode.REMOTE_SIGNING,
+ "Unsupported access delegation mode: %s",
+ AccessDelegationMode.REMOTE_SIGNING);
+
+ return resolvedMode;
}
@Override
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerFactory.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerFactory.java
index 58e8c6ac1b..31ba19550a 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerFactory.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandlerFactory.java
@@ -33,6 +33,7 @@ import org.apache.polaris.core.credentials.PolarisCredentialManager;
import org.apache.polaris.core.persistence.PolarisMetaStoreManager;
import org.apache.polaris.core.persistence.resolver.ResolutionManifestFactory;
import org.apache.polaris.core.persistence.resolver.ResolverFactory;
+import org.apache.polaris.service.catalog.AccessDelegationModeResolver;
import org.apache.polaris.service.catalog.CatalogPrefixParser;
import org.apache.polaris.service.catalog.io.StorageAccessConfigProvider;
import org.apache.polaris.service.config.ReservedProperties;
@@ -58,6 +59,7 @@ public class IcebergCatalogHandlerFactory {
@Inject EventAttributeMap eventAttributeMap;
@Inject PolarisMetricsReporter metricsReporter;
@Inject Clock clock;
+ @Inject AccessDelegationModeResolver accessDelegationModeResolver;
public IcebergCatalogHandler createHandler(String catalogName, PolarisPrincipal principal) {
return ImmutableIcebergCatalogHandler.builder()
@@ -79,6 +81,7 @@ public class IcebergCatalogHandlerFactory {
.eventAttributeMap(eventAttributeMap)
.metricsReporter(metricsReporter)
.clock(clock)
+ .accessDelegationModeResolver(accessDelegationModeResolver)
.build();
}
}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/AccessDelegationModeResolverTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/AccessDelegationModeResolverTest.java
new file mode 100644
index 0000000000..e1c3a00508
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/AccessDelegationModeResolverTest.java
@@ -0,0 +1,361 @@
+/*
+ * 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.catalog;
+
+import static org.apache.polaris.service.catalog.AccessDelegationMode.REMOTE_SIGNING;
+import static org.apache.polaris.service.catalog.AccessDelegationMode.VENDED_CREDENTIALS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.polaris.core.admin.model.Catalog;
+import org.apache.polaris.core.config.FeatureConfiguration;
+import org.apache.polaris.core.config.RealmConfig;
+import org.apache.polaris.core.entity.CatalogEntity;
+import org.apache.polaris.core.entity.PolarisEntityConstants;
+import org.apache.polaris.core.storage.aws.AwsStorageConfigurationInfo;
+import org.apache.polaris.core.storage.azure.AzureStorageConfigurationInfo;
+import org.apache.polaris.core.storage.gcp.GcpStorageConfigurationInfo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@SuppressWarnings("unchecked")
+class AccessDelegationModeResolverTest {
+
+ @Mock private RealmConfig realmConfig;
+
+ private AccessDelegationModeResolver resolver;
+
+ @BeforeEach
+ void setUp() {
+ resolver = new DefaultAccessDelegationModeResolver(realmConfig);
+ }
+
+ /** Helper to set up config mock for tests that need it */
+ private void mockSkipCredentialSubscopingConfig(boolean skipCredentialSubscoping) {
+ when(realmConfig.getConfig(eq(FeatureConfiguration.SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION)))
+ .thenReturn(skipCredentialSubscoping);
+ }
+
+ /** Helper to set up config mock for ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING */
+ private void mockAllowExternalCatalogCredentialVending(
+ CatalogEntity catalogEntity, boolean allow) {
+ when(realmConfig.getConfig(
+ eq(FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING), eq(catalogEntity)))
+ .thenReturn(allow);
+ }
+
+ /** Helper to set up config mock for ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING */
+ private void mockAllowFederatedCatalogsCredentialVending(
+ CatalogEntity catalogEntity, boolean allowCredentialVending) {
+ when(realmConfig.getConfig(
+ eq(FeatureConfiguration.ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING),
+ eq(catalogEntity)))
+ .thenReturn(allowCredentialVending);
+ }
+
+ @Test
+ void resolveEmptyModes_returnsEmpty() {
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.noneOf(AccessDelegationMode.class);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, null);
+
+ assertThat(result).isEmpty();
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = AccessDelegationMode.class,
+ names = {"VENDED_CREDENTIALS", "REMOTE_SIGNING"})
+ void resolveSingleMode_returnsThatMode(AccessDelegationMode mode) {
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(mode);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, null);
+
+ assertThat(result).hasValue(mode);
+ }
+
+ @Test
+ void resolveBothModes_withStsAvailable_returnsVendedCredentials() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createCatalogWithAwsConfig(false); // STS available
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(VENDED_CREDENTIALS);
+ }
+
+ @Test
+ void resolveBothModes_withStsUnavailable_returnsRemoteSigning() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createCatalogWithAwsConfig(true); // STS unavailable
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(REMOTE_SIGNING);
+ }
+
+ @Test
+ void resolveBothModes_withCredentialSubscopingSkipped_returnsRemoteSigning() {
+ mockSkipCredentialSubscopingConfig(true); // Skip credential subscoping
+ CatalogEntity catalogEntity = createCatalogWithAwsConfig(false); // STS available
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(REMOTE_SIGNING);
+ }
+
+ @Test
+ void resolveBothModes_withNullCatalogEntity_returnsVendedCredentials() {
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, null);
+
+ assertThat(result).hasValue(VENDED_CREDENTIALS);
+ }
+
+ @Test
+ void resolveBothModes_withNoStorageConfig_returnsVendedCredentials() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createCatalogWithoutStorageConfig();
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(VENDED_CREDENTIALS);
+ }
+
+ @Test
+ void resolveBothModes_withAzureStorageConfig_returnsVendedCredentials() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createCatalogWithAzureConfig();
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ // Azure uses different mechanisms, assumes credential vending is available
+ assertThat(result).hasValue(VENDED_CREDENTIALS);
+ }
+
+ @Test
+ void resolveBothModes_withGcpStorageConfig_returnsVendedCredentials() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createCatalogWithGcpConfig();
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ // GCP uses service accounts, assumes credential vending is available
+ assertThat(result).hasValue(VENDED_CREDENTIALS);
+ }
+
+ @Test
+ void resolve_withBothModes_returnsSingleResolvedMode() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createCatalogWithAwsConfig(true); // STS unavailable
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(REMOTE_SIGNING);
+ }
+
+ @Test
+ void resolveBothModes_externalCatalog_withStsAvailable_returnsVendedCredentials() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createExternalCatalogWithAwsConfig(false);
+ mockAllowExternalCatalogCredentialVending(catalogEntity, true);
+ mockAllowFederatedCatalogsCredentialVending(catalogEntity, true);
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(VENDED_CREDENTIALS);
+ }
+
+ @Test
+ void resolveBothModes_externalCatalog_withStsUnavailable_returnsRemoteSigning() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createExternalCatalogWithAwsConfig(true);
+ mockAllowExternalCatalogCredentialVending(catalogEntity, true);
+ mockAllowFederatedCatalogsCredentialVending(catalogEntity, true);
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(REMOTE_SIGNING);
+ }
+
+ @Test
+ void resolveBothModes_externalCatalog_federatedVendingDisabled_returnsRemoteSigning() {
+ mockSkipCredentialSubscopingConfig(false);
+ CatalogEntity catalogEntity = createExternalCatalogWithAwsConfig(false);
+ mockAllowExternalCatalogCredentialVending(catalogEntity, true);
+ mockAllowFederatedCatalogsCredentialVending(catalogEntity, false);
+
+ EnumSet<AccessDelegationMode> requestedModes = EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING);
+
+ Optional<AccessDelegationMode> result = resolver.resolve(requestedModes, catalogEntity);
+
+ assertThat(result).hasValue(REMOTE_SIGNING);
+ }
+
+ @Test
+ void
+ resolveSingleVendedCredentials_externalCatalog_federatedVendingDisabled_throwsIllegalArgument() {
+ CatalogEntity catalogEntity = createExternalCatalogWithAwsConfig(false);
+ mockAllowExternalCatalogCredentialVending(catalogEntity, true);
+ mockAllowFederatedCatalogsCredentialVending(catalogEntity, false);
+
+ assertThatThrownBy(() -> resolver.resolve(EnumSet.of(VENDED_CREDENTIALS), catalogEntity))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("is not enabled for this federated catalog")
+ .hasMessageContaining(
+ FeatureConfiguration.ALLOW_FEDERATED_CATALOGS_CREDENTIAL_VENDING.catalogConfig());
+ }
+
+ @Test
+ void
+ resolveSingleVendedCredentials_externalCatalog_credentialVendingDisabled_throwsIllegalArgument() {
+ CatalogEntity catalogEntity = createExternalCatalogWithAwsConfig(false);
+ mockAllowExternalCatalogCredentialVending(catalogEntity, false);
+
+ assertThatThrownBy(() -> resolver.resolve(EnumSet.of(VENDED_CREDENTIALS), catalogEntity))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("is not enabled for this external catalog")
+ .hasMessageContaining(
+ FeatureConfiguration.ALLOW_EXTERNAL_CATALOG_CREDENTIAL_VENDING.catalogConfig());
+ }
+
+ @Test
+ void resolveSingleRemoteSigning_externalCatalog_credentialVendingDisabled_returnsRemoteSigning() {
+ CatalogEntity catalogEntity = createExternalCatalogWithAwsConfig(false);
+ mockAllowExternalCatalogCredentialVending(catalogEntity, false);
+
+ Optional<AccessDelegationMode> result =
+ resolver.resolve(EnumSet.of(REMOTE_SIGNING), catalogEntity);
+
+ assertThat(result).hasValue(REMOTE_SIGNING);
+ }
+
+ @Test
+ void resolveBothModes_externalCatalog_externalVendingDisabled_gracefullyReturnsRemoteSigning() {
+ CatalogEntity catalogEntity = createExternalCatalogWithAwsConfig(false);
+ mockAllowExternalCatalogCredentialVending(catalogEntity, false);
+
+ Optional<AccessDelegationMode> result =
+ resolver.resolve(EnumSet.of(VENDED_CREDENTIALS, REMOTE_SIGNING), catalogEntity);
+
+ assertThat(result).hasValue(REMOTE_SIGNING);
+ }
+
+ private CatalogEntity createExternalCatalogWithAwsConfig(boolean stsUnavailable) {
+ AwsStorageConfigurationInfo awsConfig =
+ AwsStorageConfigurationInfo.builder()
+ .roleARN("arn:aws:iam::123456789012:role/test-role")
+ .addAllowedLocation("s3://test-bucket/")
+ .stsUnavailable(stsUnavailable)
+ .build();
+
+ // Note: setCatalogType must be called AFTER setInternalProperties because
+ // setInternalProperties replaces the entire internal properties map
+ return new CatalogEntity.Builder()
+ .setName("external-catalog")
+ .setInternalProperties(
+ Map.of(
+ PolarisEntityConstants.getStorageConfigInfoPropertyName(), awsConfig.serialize()))
+ .setCatalogType(Catalog.TypeEnum.EXTERNAL.name())
+ .build();
+ }
+
+ private CatalogEntity createCatalogWithAwsConfig(boolean stsUnavailable) {
+ AwsStorageConfigurationInfo awsConfig =
+ AwsStorageConfigurationInfo.builder()
+ .roleARN("arn:aws:iam::123456789012:role/test-role")
+ .addAllowedLocation("s3://test-bucket/")
+ .stsUnavailable(stsUnavailable)
+ .build();
+
+ return new CatalogEntity.Builder()
+ .setName("test-catalog")
+ .setInternalProperties(
+ Map.of(
+ PolarisEntityConstants.getStorageConfigInfoPropertyName(), awsConfig.serialize()))
+ .build();
+ }
+
+ private CatalogEntity createCatalogWithoutStorageConfig() {
+ return new CatalogEntity.Builder().setName("test-catalog").build();
+ }
+
+ private CatalogEntity createCatalogWithAzureConfig() {
+ AzureStorageConfigurationInfo azureConfig =
+ AzureStorageConfigurationInfo.builder()
+ .tenantId("test-tenant-id")
+ .addAllowedLocation("abfss://container@account.dfs.core.windows.net/")
+ .build();
+
+ return new CatalogEntity.Builder()
+ .setName("test-catalog")
+ .setInternalProperties(
+ Map.of(
+ PolarisEntityConstants.getStorageConfigInfoPropertyName(), azureConfig.serialize()))
+ .build();
+ }
+
+ private CatalogEntity createCatalogWithGcpConfig() {
+ GcpStorageConfigurationInfo gcpConfig =
+ GcpStorageConfigurationInfo.builder()
+ .gcpServiceAccount("test@project.iam.gserviceaccount.com")
+ .addAllowedLocation("gs://test-bucket/")
+ .build();
+
+ return new CatalogEntity.Builder()
+ .setName("test-catalog")
+ .setInternalProperties(
+ Map.of(
+ PolarisEntityConstants.getStorageConfigInfoPropertyName(), gcpConfig.serialize()))
+ .build();
+ }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/AccessDelegationModeTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/AccessDelegationModeTest.java
index 9c36e13106..23f54bf6e1 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/AccessDelegationModeTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/AccessDelegationModeTest.java
@@ -19,7 +19,6 @@
package org.apache.polaris.service.catalog;
import static org.apache.polaris.service.catalog.AccessDelegationMode.REMOTE_SIGNING;
-import static org.apache.polaris.service.catalog.AccessDelegationMode.UNKNOWN;
import static org.apache.polaris.service.catalog.AccessDelegationMode.VENDED_CREDENTIALS;
import static org.apache.polaris.service.catalog.AccessDelegationMode.fromProtocolValuesList;
import static org.assertj.core.api.Assertions.assertThat;
@@ -51,16 +50,16 @@ class AccessDelegationModeTest {
@Test
void testUnknown() {
- assertThat(fromProtocolValuesList("abc")).isEqualTo(EnumSet.of(UNKNOWN));
- assertThat(fromProtocolValuesList("abc,def")).isEqualTo(EnumSet.of(UNKNOWN));
- assertThat(fromProtocolValuesList("abc,remote-signing"))
- .isEqualTo(EnumSet.of(REMOTE_SIGNING, UNKNOWN));
+ assertThat(fromProtocolValuesList("abc")).isEqualTo(EnumSet.noneOf(AccessDelegationMode.class));
+ assertThat(fromProtocolValuesList("abc,def"))
+ .isEqualTo(EnumSet.noneOf(AccessDelegationMode.class));
+ assertThat(fromProtocolValuesList("abc,remote-signing")).isEqualTo(EnumSet.of(REMOTE_SIGNING));
}
@Test
void testLegacy() {
assertThat(fromProtocolValuesList("true")).isEqualTo(EnumSet.of(VENDED_CREDENTIALS));
assertThat(fromProtocolValuesList("true, vended-credentials"))
- .isEqualTo(EnumSet.of(UNKNOWN, VENDED_CREDENTIALS));
+ .isEqualTo(EnumSet.of(VENDED_CREDENTIALS));
}
}
diff --git a/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java b/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
index c71cd79cd7..1bd6997a21 100644
--- a/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
+++ b/runtime/service/src/testFixtures/java/org/apache/polaris/service/TestServices.java
@@ -65,6 +65,7 @@ import org.apache.polaris.core.storage.cache.StorageCredentialCacheConfig;
import org.apache.polaris.service.admin.PolarisAdminService;
import org.apache.polaris.service.admin.PolarisServiceImpl;
import org.apache.polaris.service.admin.api.PolarisCatalogsApi;
+import org.apache.polaris.service.catalog.DefaultAccessDelegationModeResolver;
import org.apache.polaris.service.catalog.DefaultCatalogPrefixParser;
import org.apache.polaris.service.catalog.api.IcebergRestCatalogApi;
import org.apache.polaris.service.catalog.api.IcebergRestCatalogApiService;
@@ -347,6 +348,8 @@ public record TestServices(
.eventAttributeMap(eventAttributeMap)
.metricsReporter(new DefaultMetricsReporter())
.clock(clock)
+ .accessDelegationModeResolver(
+ new DefaultAccessDelegationModeResolver(realmConfig))
.build();
}
};