This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 197e1adba9 [#13369] fix(catalog-glue): Report rejected AWS credentials
clearly (#13370)
197e1adba9 is described below
commit 197e1adba9b073c7df890f8526e2102f232ac005
Author: Yuhui <[email protected]>
AuthorDate: Mon Sep 21 17:42:09 2026 +0800
[#13369] fix(catalog-glue): Report rejected AWS credentials clearly (#13370)
### What changes were proposed in this pull request?
- Recognize AWS Glue authentication error codes separately from
authorization and general connection failures.
- Convert credential-provider resolution failures and AWS-rejected
credentials into actionable connection errors that identify the relevant
Gravitino catalog properties.
- Apply the same actionable authentication diagnostics to Glue schema
and table operations.
- Keep catalog creation offline-capable; static credentials are not
authenticated during catalog creation.
### Why are the changes needed?
Static AWS credentials can resolve locally even when AWS will reject
them. Glue connection checks and metadata operations currently expose
raw or generic AWS SDK errors, which do not tell users which catalog
properties need correction.
Fix: #13369
### Does this PR introduce _any_ user-facing change?
Yes. Glue connection tests and metadata operations now report clearer
authentication failures and identify `aws-access-key-id` and
`aws-secret-access-key`. Catalog creation behavior is unchanged.
### How was this patch tested?
Added unit tests for:
- Missing credentials in the default provider chain.
- AWS-rejected static credentials during connection tests.
- AWS-rejected credentials during schema operations.
- Authentication error-code recognition and conversion.
- Credential-provider resolution failures.
Ran:
`./gradlew :catalogs:catalog-glue:spotlessApply
:catalogs:catalog-glue:test -PskipITs`
---
.../catalog/glue/GlueCatalogOperations.java | 3 +-
.../gravitino/catalog/glue/GlueClientProvider.java | 30 +++-----
.../catalog/glue/GlueExceptionConverter.java | 87 ++++++++++++++++++++++
.../glue/TestGlueCatalogSchemaOperations.java | 60 +++++++++++++++
.../catalog/glue/TestGlueClientProvider.java | 13 +++-
.../catalog/glue/TestGlueExceptionConverter.java | 41 ++++++++++
6 files changed, 208 insertions(+), 26 deletions(-)
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
index 8b36af4129..898e6964a2 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueCatalogOperations.java
@@ -46,7 +46,6 @@ import org.apache.gravitino.connector.CatalogInfo;
import org.apache.gravitino.connector.CatalogOperations;
import org.apache.gravitino.connector.HasPropertyMetadata;
import org.apache.gravitino.connector.SupportsSchemas;
-import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.exceptions.NoSuchSchemaException;
import org.apache.gravitino.exceptions.NoSuchTableException;
@@ -166,7 +165,7 @@ public class GlueCatalogOperations implements
CatalogOperations, SupportsSchemas
applyCatalogId(catalogId, req::catalogId);
glueClient.getDatabases(req.build());
} catch (SdkException e) {
- throw new ConnectionFailedException(e, "Failed to connect to AWS Glue:
%s", e.getMessage());
+ throw GlueExceptionConverter.toConnectionException(e);
}
}
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
index 0cc5bb9e90..be0dbe1925 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueClientProvider.java
@@ -23,6 +23,7 @@ import com.google.common.base.Preconditions;
import java.net.URI;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
@@ -55,8 +56,9 @@ public final class GlueClientProvider {
* @param config Catalog configuration properties.
* @return A configured and ready-to-use {@link GlueClient}.
* @throws IllegalArgumentException if {@code aws-region} is missing or
blank, if only one of the
- * credential keys is provided, if {@code aws-glue-endpoint} is not a
valid URI, or if no
- * usable AWS credential source can be resolved.
+ * credential keys is provided, or if {@code aws-glue-endpoint} is not a
valid URI
+ * @throws ConnectionFailedException if the configured credential provider
cannot resolve
+ * credentials
*/
public static GlueClient buildClient(Map<String, String> config) {
String region = config.get(GlueConstants.AWS_REGION);
@@ -97,31 +99,19 @@ public final class GlueClientProvider {
}
/**
- * Eagerly resolves {@code credentialsProvider} to confirm a usable
credential source exists,
- * instead of leaving resolution to the first real Glue API call. Without
this check, a catalog
- * created with no static credentials and no usable default-chain source
(env vars, instance
- * profile, etc.) is stored successfully and then fails on every operation
with a raw AWS SDK
- * error that never mentions this connector's own credential properties.
+ * Eagerly resolves {@code credentialsProvider} when Glue operations are
initialized, instead of
+ * leaving resolution to the first real Glue API call. This makes an
explicit connection test or
+ * the first operation fail with an actionable connection error when no
credential source is
+ * available. It does not authenticate static credentials; only an AWS API
request can do that.
*
- * @throws IllegalArgumentException if no credentials can be resolved
+ * @throws ConnectionFailedException if no credentials can be resolved
*/
@VisibleForTesting
static void validateCredentials(AwsCredentialsProvider credentialsProvider) {
try {
credentialsProvider.resolveCredentials();
} catch (SdkClientException e) {
- if (!GlueExceptionConverter.isCredentialFailure(e)) {
- throw new IllegalArgumentException(
- "Failed to resolve AWS credentials for the Glue catalog: " +
e.getMessage(), e);
- }
- throw new IllegalArgumentException(
- String.format(
- "No usable AWS credentials found for the Glue catalog. Set both
'%s' and '%s' "
- + "catalog properties for static authentication, or ensure
the default AWS "
- + "credential chain (environment variables, instance
profile, web identity "
- + "token, etc.) can resolve credentials.",
- GlueConstants.AWS_ACCESS_KEY_ID,
GlueConstants.AWS_SECRET_ACCESS_KEY),
- e);
+ throw GlueExceptionConverter.toConnectionException(e);
}
}
diff --git
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
index 4ab3ed9c9b..5725c75fb1 100644
---
a/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
+++
b/catalogs/catalog-glue/src/main/java/org/apache/gravitino/catalog/glue/GlueExceptionConverter.java
@@ -18,7 +18,9 @@
*/
package org.apache.gravitino.catalog.glue;
+import java.util.Set;
import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.NoSuchSchemaException;
import org.apache.gravitino.exceptions.NoSuchTableException;
@@ -27,6 +29,7 @@ import
org.apache.gravitino.exceptions.TableAlreadyExistsException;
import org.apache.gravitino.utils.ExceptionMessages;
import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.services.glue.model.AccessDeniedException;
import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
import software.amazon.awssdk.services.glue.model.EntityNotFoundException;
@@ -39,6 +42,20 @@ final class GlueExceptionConverter {
private static final String NO_CREDENTIALS_MARKER =
"Unable to load credentials from any of the providers";
+ private static final Set<String> AUTHENTICATION_ERROR_CODES =
+ Set.of(
+ "AuthFailure",
+ "ExpiredToken",
+ "ExpiredTokenException",
+ "IncompleteSignature",
+ "InvalidAccessKeyId",
+ "InvalidClientTokenId",
+ "InvalidSignatureException",
+ "RequestExpired",
+ "SignatureDoesNotMatch",
+ "TokenRefreshRequired",
+ "UnrecognizedClientException");
+
private GlueExceptionConverter() {}
/**
@@ -72,6 +89,57 @@ final class GlueExceptionConverter {
e);
}
+ /**
+ * Whether AWS Glue rejected credentials that were successfully resolved by
the configured
+ * provider. Static credential providers can return any nonblank access-key
pair locally, so only
+ * an AWS service response can establish whether that pair is authentic.
+ *
+ * @param e the service exception raised by AWS Glue
+ * @return true if AWS classified the failure as an authentication error
+ */
+ static boolean isAuthenticationFailure(GlueException e) {
+ AwsErrorDetails details = e.awsErrorDetails();
+ return details != null
+ && StringUtils.isNotBlank(details.errorCode())
+ && AUTHENTICATION_ERROR_CODES.contains(details.errorCode());
+ }
+
+ /**
+ * Converts an AWS Glue SDK failure raised by a connection probe into a
connection error. Known
+ * credential failures name the connector properties that an operator can
correct; authorization
+ * and transport failures retain the AWS or SDK detail without claiming the
credentials are
+ * invalid.
+ *
+ * @param e the SDK failure raised by the connection probe
+ * @return an actionable connection failure
+ */
+ static ConnectionFailedException toConnectionException(SdkException e) {
+ if (e instanceof SdkClientException &&
isCredentialFailure((SdkClientException) e)) {
+ return new ConnectionFailedException(
+ e,
+ "Failed to authenticate with AWS Glue. No usable AWS credentials
were found. Set both "
+ + "'%s' and '%s' catalog properties, or ensure the default AWS
credential chain can "
+ + "resolve credentials.",
+ GlueConstants.AWS_ACCESS_KEY_ID,
+ GlueConstants.AWS_SECRET_ACCESS_KEY);
+ }
+ if (e instanceof GlueException && isAuthenticationFailure((GlueException)
e)) {
+ return new ConnectionFailedException(
+ e,
+ "AWS Glue rejected the configured credentials. Verify the '%s' and
'%s' catalog "
+ + "properties, or the configured default AWS credential source.
AWS error: %s",
+ GlueConstants.AWS_ACCESS_KEY_ID,
+ GlueConstants.AWS_SECRET_ACCESS_KEY,
+ awsErrorDetail((GlueException) e));
+ }
+
+ String detail =
+ e instanceof GlueException
+ ? awsErrorDetail((GlueException) e)
+ : StringUtils.defaultIfBlank(e.getMessage(),
e.getClass().getSimpleName());
+ return new ConnectionFailedException(e, "Failed to connect to AWS Glue:
%s", detail);
+ }
+
/**
* Converts a {@link GlueException} to the appropriate Gravitino schema
exception.
*
@@ -80,6 +148,9 @@ final class GlueExceptionConverter {
* @return a Gravitino or standard Java runtime exception
*/
static RuntimeException toSchemaException(GlueException e, String context) {
+ if (isAuthenticationFailure(e)) {
+ return toAuthenticationException(e, context);
+ }
if (e instanceof EntityNotFoundException) {
return new NoSuchSchemaException(e, "%s does not exist", context);
}
@@ -103,6 +174,9 @@ final class GlueExceptionConverter {
* @return a Gravitino or standard Java runtime exception
*/
static RuntimeException toTableException(GlueException e, String context) {
+ if (isAuthenticationFailure(e)) {
+ return toAuthenticationException(e, context);
+ }
if (e instanceof EntityNotFoundException) {
return new NoSuchTableException(e, "%s does not exist", context);
}
@@ -118,6 +192,19 @@ final class GlueExceptionConverter {
return new RuntimeException("Glue error: " + context + ": " +
awsErrorDetail(e), e);
}
+ private static RuntimeException toAuthenticationException(GlueException e,
String context) {
+ return new RuntimeException(
+ String.format(
+ "Failed to authenticate with AWS Glue for %s. AWS rejected the
configured "
+ + "credentials. Verify the '%s' and '%s' catalog properties,
or the configured "
+ + "default AWS credential source. AWS error: %s",
+ context,
+ GlueConstants.AWS_ACCESS_KEY_ID,
+ GlueConstants.AWS_SECRET_ACCESS_KEY,
+ awsErrorDetail(e)),
+ e);
+ }
+
/**
* Renders the AWS-side detail of a Glue exception. AWS names the failing
action and the resource
* there, which is what the caller needs to act on; the error code is
prefixed so the failure can
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
index 7c03aaeddb..6bebc1baac 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueCatalogSchemaOperations.java
@@ -42,6 +42,7 @@ import
org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
+import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.services.glue.GlueClient;
import software.amazon.awssdk.services.glue.model.AlreadyExistsException;
@@ -55,6 +56,7 @@ import
software.amazon.awssdk.services.glue.model.GetDatabasesRequest;
import software.amazon.awssdk.services.glue.model.GetDatabasesResponse;
import software.amazon.awssdk.services.glue.model.GetTablesRequest;
import software.amazon.awssdk.services.glue.model.GetTablesResponse;
+import software.amazon.awssdk.services.glue.model.GlueException;
import software.amazon.awssdk.services.glue.model.Table;
import software.amazon.awssdk.services.glue.model.UpdateDatabaseRequest;
import software.amazon.awssdk.services.glue.model.UpdateDatabaseResponse;
@@ -86,6 +88,38 @@ class TestGlueCatalogSchemaOperations {
assertTrue(exception.getMessage().contains("connection refused"));
}
+ @Test
+ void testConnectionMapsMissingCredentialsToActionableMessage() {
+ SdkClientException cause =
+ SdkClientException.create("Unable to load credentials from any of the
providers");
+
when(mockClient.getDatabases(any(GetDatabasesRequest.class))).thenThrow(cause);
+
+ ConnectionFailedException exception =
+ assertThrows(
+ ConnectionFailedException.class,
+ () -> ops.testConnection(NameIdentifier.of("metalake",
"catalog")));
+
+ assertEquals(cause, exception.getCause());
+
assertTrue(exception.getMessage().contains(GlueConstants.AWS_ACCESS_KEY_ID));
+
assertTrue(exception.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY));
+ }
+
+ @Test
+ void testConnectionMapsRejectedCredentialsToActionableMessage() {
+ GlueException cause = invalidCredentialsException();
+
when(mockClient.getDatabases(any(GetDatabasesRequest.class))).thenThrow(cause);
+
+ ConnectionFailedException exception =
+ assertThrows(
+ ConnectionFailedException.class,
+ () -> ops.testConnection(NameIdentifier.of("metalake",
"catalog")));
+
+ assertEquals(cause, exception.getCause());
+
assertTrue(exception.getMessage().contains(GlueConstants.AWS_ACCESS_KEY_ID));
+
assertTrue(exception.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY));
+ assertTrue(exception.getMessage().contains("UnrecognizedClientException"));
+ }
+
// -------------------------------------------------------------------------
// listSchemas
// -------------------------------------------------------------------------
@@ -139,6 +173,20 @@ class TestGlueCatalogSchemaOperations {
assertTrue(ex.getMessage().contains("aws-secret-access-key"));
}
+ @Test
+ void testListSchemasMapsRejectedCredentialsToActionableMessage() {
+ Namespace ns = Namespace.of("metalake", "catalog");
+ GlueException cause = invalidCredentialsException();
+
when(mockClient.getDatabases(any(GetDatabasesRequest.class))).thenThrow(cause);
+
+ RuntimeException ex = assertThrows(RuntimeException.class, () ->
ops.listSchemas(ns));
+
+ assertEquals(cause, ex.getCause());
+ assertTrue(ex.getMessage().contains(GlueConstants.AWS_ACCESS_KEY_ID));
+ assertTrue(ex.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY));
+ assertTrue(ex.getMessage().contains("UnrecognizedClientException"));
+ }
+
@Test
void testListSchemasRethrowsNonCredentialSdkClientException() {
Namespace ns = Namespace.of("metalake", "catalog");
@@ -352,4 +400,16 @@ class TestGlueCatalogSchemaOperations {
verify(mockClient).deleteDatabase(captor.capture());
assertEquals("123456789012", captor.getValue().catalogId());
}
+
+ private static GlueException invalidCredentialsException() {
+ return (GlueException)
+ GlueException.builder()
+ .message("The security token included in the request is invalid")
+ .awsErrorDetails(
+ AwsErrorDetails.builder()
+ .errorCode("UnrecognizedClientException")
+ .errorMessage("The security token included in the request
is invalid")
+ .build())
+ .build();
+ }
}
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
index e3b1a2cb1c..665e76d72b 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueClientProvider.java
@@ -32,6 +32,7 @@ import static org.mockito.Mockito.mock;
import java.util.HashMap;
import java.util.Map;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
@@ -74,9 +75,10 @@ class TestGlueClientProvider {
.when(provider)
.resolveCredentials();
- IllegalArgumentException ex =
+ ConnectionFailedException ex =
assertThrows(
- IllegalArgumentException.class, () ->
GlueClientProvider.validateCredentials(provider));
+ ConnectionFailedException.class,
+ () -> GlueClientProvider.validateCredentials(provider));
assertTrue(ex.getMessage().contains(AWS_ACCESS_KEY_ID));
assertTrue(ex.getMessage().contains(AWS_SECRET_ACCESS_KEY));
}
@@ -89,9 +91,10 @@ class TestGlueClientProvider {
SdkClientException cause = SdkClientException.create("connection refused");
doThrow(cause).when(provider).resolveCredentials();
- IllegalArgumentException ex =
+ ConnectionFailedException ex =
assertThrows(
- IllegalArgumentException.class, () ->
GlueClientProvider.validateCredentials(provider));
+ ConnectionFailedException.class,
+ () -> GlueClientProvider.validateCredentials(provider));
assertEquals(cause, ex.getCause());
assertTrue(ex.getMessage().contains("connection refused"));
assertFalse(ex.getMessage().contains("No usable AWS credentials"));
@@ -155,6 +158,8 @@ class TestGlueClientProvider {
void testBuildClientInvalidEndpointThrows() {
Map<String, String> config = new HashMap<>();
config.put(AWS_REGION, "us-east-1");
+ config.put(AWS_ACCESS_KEY_ID, "test");
+ config.put(AWS_SECRET_ACCESS_KEY, "test");
config.put(AWS_GLUE_ENDPOINT, "not a valid uri ://");
assertThrows(IllegalArgumentException.class, () ->
GlueClientProvider.buildClient(config));
diff --git
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
index 23a32114a7..bb6ff91378 100644
---
a/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
+++
b/catalogs/catalog-glue/src/test/java/org/apache/gravitino/catalog/glue/TestGlueExceptionConverter.java
@@ -71,6 +71,23 @@ public class TestGlueExceptionConverter {
assertFalse(GlueExceptionConverter.isCredentialFailure(e));
}
+ @Test
+ public void testIsAuthenticationFailureMatchesAwsErrorCode() {
+ GlueException e = invalidCredentialsException();
+
+ assertTrue(GlueExceptionConverter.isAuthenticationFailure(e));
+ }
+
+ @Test
+ public void testIsAuthenticationFailureRejectsAuthorizationError() {
+ AccessDeniedException e =
+ AccessDeniedException.builder()
+
.awsErrorDetails(AwsErrorDetails.builder().errorCode("AccessDeniedException").build())
+ .build();
+
+ assertFalse(GlueExceptionConverter.isAuthenticationFailure(e));
+ }
+
@Test
public void testToCredentialExceptionIncludesContextAndPropertyNames() {
SdkClientException cause =
@@ -84,6 +101,18 @@ public class TestGlueExceptionConverter {
assertTrue(ex.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY));
}
+ @Test
+ public void testRejectedCredentialsIncludePropertyNamesAndAwsError() {
+ GlueException cause = invalidCredentialsException();
+
+ RuntimeException converted =
GlueExceptionConverter.toSchemaException(cause, "listing schemas");
+
+ assertSame(cause, converted.getCause());
+
assertTrue(converted.getMessage().contains(GlueConstants.AWS_ACCESS_KEY_ID));
+
assertTrue(converted.getMessage().contains(GlueConstants.AWS_SECRET_ACCESS_KEY));
+ assertTrue(converted.getMessage().contains("UnrecognizedClientException"));
+ }
+
@Test
public void testSchemaAccessDeniedKeepsAwsMessage() {
AccessDeniedException e =
@@ -218,4 +247,16 @@ public class TestGlueExceptionConverter {
IllegalArgumentException.class,
GlueExceptionConverter.toTableException(invalid, "table ctas_test"));
}
+
+ private static GlueException invalidCredentialsException() {
+ return (GlueException)
+ GlueException.builder()
+ .message("The security token included in the request is invalid")
+ .awsErrorDetails(
+ AwsErrorDetails.builder()
+ .errorCode("UnrecognizedClientException")
+ .errorMessage("The security token included in the request
is invalid")
+ .build())
+ .build();
+ }
}