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();
             }
           };