This is an automated email from the ASF dual-hosted git repository.
roryqi 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 8f3211278a [#12131] feat(kms): Define key inspection API (#12132)
8f3211278a is described below
commit 8f3211278a9ff9d81ae1924cac5d1e8261093590
Author: Nevin Zheng <[email protected]>
AuthorDate: Sat Jul 25 22:04:36 2026 -0700
[#12131] feat(kms): Define key inspection API (#12132)
### What changes were proposed in this pull request?
Add provider-neutral Java contracts for server-private KMS key
inspection:
- `api`: `KmsReference` (api / source / keyId) and `KmsApiIdentifiers`
validation helper
- `common`: `KmsClient` / `KmsClientFactory` SPI, `KmsKeyProperties`,
config/auth exceptions, `KmsReferenceDTO`, reusable fake + contract
tests
Key identity rules:
- `api` is a `String` matched exactly (no trim/lowercase normalization)
- identifiers must be lowercase kebab-case with no padding (e.g.
`aws-kms`), enforced by `KmsApiIdentifiers`
- `source` is trimmed; provider-native `keyId` is preserved as-is
- unknown/custom APIs are allowed at the contract layer; dispatch
remains SPI-based
Inspection semantics:
- `KmsClient.getKeyProperties` returns `Optional<KmsKeyProperties>`
- `Optional.empty()` means authoritative key-not-found
- configuration vs authentication failures remain distinct exception
types
### Why are the changes needed?
KMS integrations need a common contract that does not expose credentials
or key material. Using string API identifiers (instead of a closed enum)
keeps the Developer API easier to extend through SPI, consistent with
catalog/credential provider patterns.
This PR defines only the provider-neutral API/SPI. Named-source routing
follows in #12133. Concrete providers, provider ITs, server
configuration, and cryptographic operations are outside this PR.
Part of #12131.
### Does this PR introduce _any_ user-facing change?
Yes. It adds new `@DeveloperApi` Java contracts. It does not add REST
APIs, server configuration, concrete providers, or cryptographic
operations.
### How was this patch tested?
- `./gradlew :api:test :common:test`
- `./gradlew :api:spotlessCheck :common:spotlessCheck`
- `./gradlew :common:testFixturesJar`
- `./gradlew :api:javadoc :common:javadoc`
### Series
- Epic: #12131
- Position: 1 of 2
- Previous: none
- Next: #12133
- Full stack: #12132 → #12133
---------
Co-authored-by: Cursor <[email protected]>
---
.../encryption/kms/KmsApiIdentifiers.java | 57 +++++++++
.../gravitino/encryption/kms/KmsReference.java | 110 ++++++++++++++++
.../encryption/kms/TestKmsApiIdentifiers.java | 62 +++++++++
.../gravitino/encryption/kms/TestKmsReference.java | 87 +++++++++++++
common/build.gradle.kts | 4 +
.../dto/encryption/kms/KmsReferenceDTO.java | 68 ++++++++++
.../encryption/kms/KmsAuthenticationException.java | 50 ++++++++
.../apache/gravitino/encryption/kms/KmsClient.java | 53 ++++++++
.../gravitino/encryption/kms/KmsClientFactory.java | 52 ++++++++
.../encryption/kms/KmsConfigurationException.java | 49 ++++++++
.../gravitino/encryption/kms/KmsKeyProperties.java | 63 ++++++++++
.../dto/encryption/kms/TestKmsReferenceDTO.java | 91 ++++++++++++++
.../encryption/kms/TestFakeKmsClient.java | 104 +++++++++++++++
.../gravitino/encryption/kms/TestKmsClient.java | 95 ++++++++++++++
.../gravitino/encryption/kms/FakeKmsClient.java | 140 +++++++++++++++++++++
.../encryption/kms/TestKmsClientContract.java | 95 ++++++++++++++
.../kms/TestKmsClientFactoryContract.java | 45 +++++++
17 files changed, 1225 insertions(+)
diff --git
a/api/src/main/java/org/apache/gravitino/encryption/kms/KmsApiIdentifiers.java
b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsApiIdentifiers.java
new file mode 100644
index 0000000000..7b2f9b74b6
--- /dev/null
+++
b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsApiIdentifiers.java
@@ -0,0 +1,57 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import com.google.common.base.Preconditions;
+import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/**
+ * Validation helpers for KMS API identifiers.
+ *
+ * <p>Identifiers are matched exactly. They must be lowercase kebab-case with
no surrounding
+ * whitespace (for example {@code aws-kms}). Values are never normalized.
+ */
+@DeveloperApi
+public final class KmsApiIdentifiers {
+
+ private static final Pattern LOWERCASE_KEBAB_CASE =
Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
+
+ private KmsApiIdentifiers() {}
+
+ /**
+ * Validates a KMS API identifier.
+ *
+ * @param api the identifier to validate
+ * @return the same identifier when valid
+ * @throws IllegalArgumentException if {@code api} is null, blank, padded,
or not lowercase
+ * kebab-case
+ */
+ public static String requireValid(String api) {
+ Preconditions.checkArgument(StringUtils.isNotBlank(api), "KMS API cannot
be blank");
+ Preconditions.checkArgument(
+ api.equals(api.trim()), "KMS API cannot have leading or trailing
whitespace");
+ Preconditions.checkArgument(
+ LOWERCASE_KEBAB_CASE.matcher(api).matches(),
+ "KMS API must be lowercase kebab-case: '%s'",
+ api);
+ return api;
+ }
+}
diff --git
a/api/src/main/java/org/apache/gravitino/encryption/kms/KmsReference.java
b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsReference.java
new file mode 100644
index 0000000000..e68c0c7e00
--- /dev/null
+++ b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsReference.java
@@ -0,0 +1,110 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import com.google.common.base.Preconditions;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/**
+ * Identifies a key owned by a configured KMS source.
+ *
+ * <p>Contains no credentials or key material.
+ *
+ * <p>KMS API identifiers use exact string matching. Callers must supply
lowercase kebab-case values
+ * with no leading or trailing whitespace (for example {@code aws-kms}). This
type does not
+ * normalize the identifier.
+ */
+@DeveloperApi
+public final class KmsReference {
+
+ private final String api;
+ private final String source;
+ private final String keyId;
+
+ /**
+ * Creates a structurally valid key reference without contacting the
provider.
+ *
+ * @param api explicitly selected KMS API identifier; must be lowercase
kebab-case with no
+ * surrounding whitespace
+ * @param source configured KMS client-instance name
+ * @param keyId provider-native key identifier
+ * @throws IllegalArgumentException if any argument is null or blank, or if
{@code api} is not a
+ * valid KMS API identifier
+ */
+ public KmsReference(String api, String source, String keyId) {
+ this.api = KmsApiIdentifiers.requireValid(api);
+ Preconditions.checkArgument(StringUtils.isNotBlank(source), "KMS source
cannot be blank");
+ Preconditions.checkArgument(StringUtils.isNotBlank(keyId), "KMS key ID
cannot be blank");
+
+ this.source = source.trim();
+ this.keyId = keyId;
+ }
+
+ /**
+ * Returns the explicitly selected KMS API identifier.
+ *
+ * @return the exact KMS API identifier
+ */
+ public String api() {
+ return api;
+ }
+
+ /**
+ * Returns the configured KMS client-instance name.
+ *
+ * @return the source name
+ */
+ public String source() {
+ return source;
+ }
+
+ /**
+ * Returns the provider-native key identifier.
+ *
+ * @return the key identifier
+ */
+ public String keyId() {
+ return keyId;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof KmsReference)) {
+ return false;
+ }
+ KmsReference that = (KmsReference) other;
+ return api.equals(that.api) && source.equals(that.source) &&
keyId.equals(that.keyId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(api, source, keyId);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("KmsReference{api='%s', source='%s', keyId='%s'}",
api, source, keyId);
+ }
+}
diff --git
a/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsApiIdentifiers.java
b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsApiIdentifiers.java
new file mode 100644
index 0000000000..2743451393
--- /dev/null
+++
b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsApiIdentifiers.java
@@ -0,0 +1,62 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestKmsApiIdentifiers {
+
+ @Test
+ void testAcceptsLowercaseKebabCase() {
+ Assertions.assertEquals("aws-kms",
KmsApiIdentifiers.requireValid("aws-kms"));
+ Assertions.assertEquals("test", KmsApiIdentifiers.requireValid("test"));
+ Assertions.assertEquals("acme-kms-v2",
KmsApiIdentifiers.requireValid("acme-kms-v2"));
+ }
+
+ @Test
+ void testRejectsBlankAndPaddedValues() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid(null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid(""));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> KmsApiIdentifiers.requireValid("
"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> KmsApiIdentifiers.requireValid("
aws-kms"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid("aws-kms "));
+ }
+
+ @Test
+ void testRejectsNonKebabCaseValues() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid("AWS-KMS"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid("aws_kms"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid("aws kms"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid("aws--kms"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid("-aws-kms"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
KmsApiIdentifiers.requireValid("aws-kms-"));
+ }
+}
diff --git
a/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsReference.java
b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsReference.java
new file mode 100644
index 0000000000..9c658b9c9b
--- /dev/null
+++
b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsReference.java
@@ -0,0 +1,87 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestKmsReference {
+
+ @Test
+ void testStoresExactApiAndPreservesProviderKey() {
+ KmsReference reference = new KmsReference("aws-kms", " production ", "
alias/Customer-Key ");
+
+ Assertions.assertEquals("aws-kms", reference.api());
+ Assertions.assertEquals("production", reference.source());
+ Assertions.assertEquals(" alias/Customer-Key ", reference.keyId());
+ }
+
+ @Test
+ void testRejectsMissingFields() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference(null,
"production", "key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("",
"production", "key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference(" ",
"production", "key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws-kms",
null, "key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws-kms",
"production", null));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws-kms", "",
"key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws-kms", " ",
"key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws-kms",
"production", ""));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws-kms",
"production", " "));
+ }
+
+ @Test
+ void testRejectsInvalidApiIdentifiers() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference(" aws-kms",
"production", "key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws-kms ",
"production", "key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("AWS-KMS",
"production", "key"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new KmsReference("aws_kms",
"production", "key"));
+ }
+
+ @Test
+ void testValueSemantics() {
+ KmsReference first = new KmsReference("aws-kms", "production", "key");
+ KmsReference same = new KmsReference("aws-kms", "production", "key");
+ KmsReference differentApi = new KmsReference("google-cloud-kms",
"production", "key");
+ KmsReference differentSource = new KmsReference("aws-kms", "recovery",
"key");
+ KmsReference differentKey = new KmsReference("aws-kms", "production",
"another-key");
+
+ Assertions.assertEquals(first, same);
+ Assertions.assertEquals(first.hashCode(), same.hashCode());
+ Assertions.assertNotEquals(first, differentApi);
+ Assertions.assertNotEquals(first, differentSource);
+ Assertions.assertNotEquals(first, differentKey);
+ Assertions.assertNotEquals(first, null);
+ Assertions.assertNotEquals(first, "key");
+ Assertions.assertEquals(
+ "KmsReference{api='aws-kms', source='production', keyId='key'}",
first.toString());
+ }
+}
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index e267e9a118..3f20efd17a 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -22,6 +22,7 @@ import java.util.Date
plugins {
`maven-publish`
+ `java-test-fixtures`
id("java")
id("idea")
}
@@ -50,6 +51,9 @@ dependencies {
testImplementation(libs.junit.jupiter.params)
testRuntimeOnly(libs.junit.jupiter.engine)
+
+ testFixturesApi(project(":api"))
+ testFixturesApi(libs.junit.jupiter.api)
}
fun getGitCommitId(): String {
diff --git
a/common/src/main/java/org/apache/gravitino/dto/encryption/kms/KmsReferenceDTO.java
b/common/src/main/java/org/apache/gravitino/dto/encryption/kms/KmsReferenceDTO.java
new file mode 100644
index 0000000000..3e9f501a23
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/dto/encryption/kms/KmsReferenceDTO.java
@@ -0,0 +1,68 @@
+/*
+ * 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.gravitino.dto.encryption.kms;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.apache.gravitino.encryption.kms.KmsReference;
+
+/** Data transfer object for a KMS key reference. */
+@Getter
+@EqualsAndHashCode
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor
+@Builder(setterPrefix = "with")
+public class KmsReferenceDTO {
+
+ @JsonProperty("api")
+ private String api;
+
+ @JsonProperty("source")
+ private String source;
+
+ @JsonProperty("keyId")
+ private String keyId;
+
+ /**
+ * Converts this DTO to a {@link KmsReference}.
+ *
+ * <p>{@code api} is validated by {@link KmsReference}: it must already be
lowercase kebab-case
+ * with no surrounding whitespace and is matched exactly.
+ *
+ * @return the KMS key reference
+ */
+ public KmsReference toKmsReference() {
+ return new KmsReference(api, source, keyId);
+ }
+
+ /**
+ * Creates a DTO from a {@link KmsReference}.
+ *
+ * @param reference the KMS key reference
+ * @return the KMS key reference DTO
+ */
+ public static KmsReferenceDTO fromKmsReference(KmsReference reference) {
+ return new KmsReferenceDTO(reference.api(), reference.source(),
reference.keyId());
+ }
+}
diff --git
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsAuthenticationException.java
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsAuthenticationException.java
new file mode 100644
index 0000000000..d199c7cbd6
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsAuthenticationException.java
@@ -0,0 +1,50 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+
+/** Indicates that a KMS backend rejected or could not resolve configured
credentials. */
+public class KmsAuthenticationException extends ConnectionFailedException {
+
+ /**
+ * Creates an authentication exception with the specified cause and detail
message.
+ *
+ * @param cause the cause
+ * @param message the detail message
+ * @param args the arguments to the message
+ */
+ @FormatMethod
+ public KmsAuthenticationException(Throwable cause, @FormatString String
message, Object... args) {
+ super(cause, message, args);
+ }
+
+ /**
+ * Creates an authentication exception with the specified detail message.
+ *
+ * @param message the detail message
+ * @param args the arguments to the message
+ */
+ @FormatMethod
+ public KmsAuthenticationException(@FormatString String message, Object...
args) {
+ super(message, args);
+ }
+}
diff --git
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java
new file mode 100644
index 0000000000..fc4339d9c8
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java
@@ -0,0 +1,53 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import java.util.Optional;
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+
+/**
+ * Inspects keys managed by a configured KMS.
+ *
+ * <p>This is a server-side operation client, not a credential-vending API.
Provider credentials
+ * authenticate calls made by the client and must never be returned to
callers. This client does not
+ * perform cryptographic operations.
+ */
+@DeveloperApi
+public interface KmsClient extends AutoCloseable {
+
+ /**
+ * Reads the provider-reported properties of a key.
+ *
+ * <p>An empty result means the provider authoritatively reported that the
key does not exist.
+ * Authentication, authorization, timeout, availability, and other
indeterminate failures are
+ * reported as exceptions, never as an empty result.
+ *
+ * @param reference non-null key to inspect
+ * @return normalized key properties, or empty when the key authoritatively
does not exist; never
+ * null
+ * @throws IllegalArgumentException if the reference is null or does not
belong to this client
+ * @throws ConnectionFailedException if the provider cannot be queried
+ */
+ Optional<KmsKeyProperties> getKeyProperties(KmsReference reference);
+
+ /** Releases resources owned by this client. */
+ @Override
+ default void close() {}
+}
diff --git
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java
new file mode 100644
index 0000000000..5695d4140c
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java
@@ -0,0 +1,52 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import java.util.Map;
+import org.apache.gravitino.annotation.DeveloperApi;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+
+/** Creates server-side KMS clients for one KMS API. */
+@DeveloperApi
+public interface KmsClientFactory {
+
+ /**
+ * Returns the exact KMS API identifier implemented by this factory.
+ *
+ * <p>Identifiers use lowercase kebab-case with no surrounding whitespace
({@link
+ * KmsApiIdentifiers}) and are matched exactly against {@link
KmsReference#api()}.
+ *
+ * @return the KMS API identifier
+ */
+ String api();
+
+ /**
+ * Creates a client bound to a configured KMS source.
+ *
+ * <p>Provider credentials are private implementation details of the
returned client. They must
+ * not be exposed as Gravitino credentials or key properties.
+ *
+ * @param source logical name of the configured KMS instance
+ * @param properties provider-specific configuration
+ * @return the configured client
+ * @throws IllegalArgumentException if the source or configuration is invalid
+ * @throws ConnectionFailedException if required external initialization
fails
+ */
+ KmsClient create(String source, Map<String, String> properties);
+}
diff --git
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsConfigurationException.java
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsConfigurationException.java
new file mode 100644
index 0000000000..ec1613666a
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsConfigurationException.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.gravitino.encryption.kms;
+
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+
+/** Indicates invalid KMS configuration detected during initialization. */
+public class KmsConfigurationException extends IllegalArgumentException {
+
+ /**
+ * Creates a configuration exception with the specified detail message.
+ *
+ * @param message the detail message
+ * @param args the arguments to the message
+ */
+ @FormatMethod
+ public KmsConfigurationException(@FormatString String message, Object...
args) {
+ super(args.length == 0 ? message : String.format(message, args));
+ }
+
+ /**
+ * Creates a configuration exception with the specified cause and detail
message.
+ *
+ * @param cause the cause
+ * @param message the detail message
+ * @param args the arguments to the message
+ */
+ @FormatMethod
+ public KmsConfigurationException(Throwable cause, @FormatString String
message, Object... args) {
+ super(args.length == 0 ? message : String.format(message, args), cause);
+ }
+}
diff --git
a/common/src/main/java/org/apache/gravitino/encryption/kms/KmsKeyProperties.java
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsKeyProperties.java
new file mode 100644
index 0000000000..67dec82f40
--- /dev/null
+++
b/common/src/main/java/org/apache/gravitino/encryption/kms/KmsKeyProperties.java
@@ -0,0 +1,63 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import org.apache.gravitino.annotation.DeveloperApi;
+
+/**
+ * Common properties reported for a successfully located KMS key.
+ *
+ * <p>Provider-specific lifecycle state may describe the logical key or its
selected or current
+ * version. Capabilities describe key-specific structural support; this API
does not perform
+ * cryptographic operations.
+ */
+@DeveloperApi
+public interface KmsKeyProperties {
+
+ /**
+ * Returns the requested key reference.
+ *
+ * @return the key reference
+ */
+ KmsReference reference();
+
+ /**
+ * Returns the provider's normalized lifecycle state for the key.
+ *
+ * <p>This does not indicate caller authorization, service availability, or
that a subsequent
+ * operation is guaranteed to succeed.
+ *
+ * @return whether the key is enabled
+ */
+ boolean enabled();
+
+ /**
+ * Returns whether the key structurally supports wrapping data-encryption
keys.
+ *
+ * @return whether wrapping is supported
+ */
+ boolean supportsWrapping();
+
+ /**
+ * Returns whether the key structurally supports unwrapping data-encryption
keys.
+ *
+ * @return whether unwrapping is supported
+ */
+ boolean supportsUnwrapping();
+}
diff --git
a/common/src/test/java/org/apache/gravitino/dto/encryption/kms/TestKmsReferenceDTO.java
b/common/src/test/java/org/apache/gravitino/dto/encryption/kms/TestKmsReferenceDTO.java
new file mode 100644
index 0000000000..a7353f45ed
--- /dev/null
+++
b/common/src/test/java/org/apache/gravitino/dto/encryption/kms/TestKmsReferenceDTO.java
@@ -0,0 +1,91 @@
+/*
+ * 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.gravitino.dto.encryption.kms;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.gravitino.encryption.kms.KmsReference;
+import org.apache.gravitino.json.JsonUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestKmsReferenceDTO {
+
+ private final ObjectMapper objectMapper = JsonUtils.objectMapper();
+
+ @Test
+ void testRoundTripPreservesExactApi() throws JsonProcessingException {
+ KmsReference reference =
+ new KmsReference("google-cloud-kms", "analytics-prod",
"projects/p/keys/k");
+
+ KmsReferenceDTO dto = KmsReferenceDTO.fromKmsReference(reference);
+ String json = objectMapper.writeValueAsString(dto);
+ KmsReference restored = objectMapper.readValue(json,
KmsReferenceDTO.class).toKmsReference();
+
+ Assertions.assertEquals(reference, restored);
+ Assertions.assertEquals(
+ objectMapper.readTree(
+ "{\"api\":\"google-cloud-kms\",\"source\":\"analytics-prod\","
+ + "\"keyId\":\"projects/p/keys/k\"}"),
+ objectMapper.readTree(json));
+ }
+
+ @Test
+ void testAcceptsCustomApiIdentifier() throws JsonProcessingException {
+ KmsReference reference = new KmsReference("acme-kms-v2", "team-a",
"keys/primary");
+ KmsReferenceDTO dto = KmsReferenceDTO.fromKmsReference(reference);
+
+ String json = objectMapper.writeValueAsString(dto);
+ KmsReference restored = objectMapper.readValue(json,
KmsReferenceDTO.class).toKmsReference();
+
+ Assertions.assertEquals("acme-kms-v2", restored.api());
+ Assertions.assertEquals(reference, restored);
+ }
+
+ @Test
+ void testToKmsReferenceRejectsPaddedApi() {
+ KmsReferenceDTO dto =
+ KmsReferenceDTO.builder()
+ .withApi(" google-cloud-kms ")
+ .withSource("analytics-prod")
+ .withKeyId("projects/p/keys/k")
+ .build();
+
+ Assertions.assertThrows(IllegalArgumentException.class,
dto::toKmsReference);
+ }
+
+ @Test
+ void testToKmsReferenceRejectsInvalidApiFormat() {
+ KmsReferenceDTO uppercase =
+ KmsReferenceDTO.builder()
+ .withApi("AWS-KMS")
+ .withSource("production")
+ .withKeyId("key")
+ .build();
+ KmsReferenceDTO snakeCase =
+ KmsReferenceDTO.builder()
+ .withApi("aws_kms")
+ .withSource("production")
+ .withKeyId("key")
+ .build();
+
+ Assertions.assertThrows(IllegalArgumentException.class,
uppercase::toKmsReference);
+ Assertions.assertThrows(IllegalArgumentException.class,
snakeCase::toKmsReference);
+ }
+}
diff --git
a/common/src/test/java/org/apache/gravitino/encryption/kms/TestFakeKmsClient.java
b/common/src/test/java/org/apache/gravitino/encryption/kms/TestFakeKmsClient.java
new file mode 100644
index 0000000000..99683410ac
--- /dev/null
+++
b/common/src/test/java/org/apache/gravitino/encryption/kms/TestFakeKmsClient.java
@@ -0,0 +1,104 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestFakeKmsClient extends TestKmsClientContract {
+
+ private static final String API = "test-kms";
+ private static final String SOURCE = "test";
+ private static final String USABLE_KEY = "usable";
+ private static final String DISABLED_KEY = "disabled";
+ private static final String MISSING_KEY = "missing";
+
+ private final FakeKmsClient client =
+ new FakeKmsClient(API, SOURCE)
+ .putKey(USABLE_KEY, true, true, true)
+ .putKey(DISABLED_KEY, false, true, true);
+
+ @Test
+ void testRejectsBlankApi() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
FakeKmsClient(null, SOURCE));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
FakeKmsClient("", SOURCE));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
FakeKmsClient(" ", SOURCE));
+ }
+
+ @Test
+ void testRejectsPaddedApi() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new FakeKmsClient(" " + API,
SOURCE));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new FakeKmsClient(API + " ",
SOURCE));
+ }
+
+ @Test
+ void testRejectsInvalidApiFormat() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new FakeKmsClient("TEST-KMS",
SOURCE));
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new FakeKmsClient("test_kms",
SOURCE));
+ }
+
+ @Test
+ void testMatchesApiExactly() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> client.getKeyProperties(new KmsReference("other-kms", SOURCE,
USABLE_KEY)));
+ }
+
+ @Test
+ void testRejectsBlankSource() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
FakeKmsClient(API, null));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
FakeKmsClient(API, ""));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
FakeKmsClient(API, " "));
+ }
+
+ @Test
+ void testNormalizesSource() {
+ FakeKmsClient paddedSourceClient = new FakeKmsClient(API, " " + SOURCE + "
");
+
+ Assertions.assertDoesNotThrow(
+ () -> paddedSourceClient.getKeyProperties(new KmsReference(API,
SOURCE, MISSING_KEY)));
+ }
+
+ @Test
+ void testReportsDisabledKeyAsPresent() {
+ KmsKeyProperties properties =
+ client.getKeyProperties(new KmsReference(API, SOURCE,
DISABLED_KEY)).orElseThrow();
+
+ Assertions.assertFalse(properties.enabled());
+ }
+
+ @Override
+ protected KmsClient client() {
+ return client;
+ }
+
+ @Override
+ protected KmsReference usableKey() {
+ return new KmsReference(API, SOURCE, USABLE_KEY);
+ }
+
+ @Override
+ protected KmsReference missingKey() {
+ return new KmsReference(API, SOURCE, MISSING_KEY);
+ }
+}
diff --git
a/common/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClient.java
b/common/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClient.java
new file mode 100644
index 0000000000..9c2b6b4c08
--- /dev/null
+++
b/common/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClient.java
@@ -0,0 +1,95 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import java.util.Optional;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestKmsClient {
+
+ private static final KmsReference REFERENCE = new KmsReference("test-kms",
"production", "key");
+
+ @Test
+ void testReturnsProviderProperties() {
+ KmsKeyProperties properties = new TestProperties();
+ KmsClient client = reference -> Optional.of(properties);
+
+ Assertions.assertSame(properties,
client.getKeyProperties(REFERENCE).orElseThrow());
+ Assertions.assertTrue(properties.supportsWrapping());
+ Assertions.assertFalse(properties.supportsUnwrapping());
+ }
+
+ @Test
+ void testDefaultClose() {
+ KmsClient client = reference -> Optional.of(new TestProperties());
+
+ Assertions.assertDoesNotThrow(client::close);
+ }
+
+ @Test
+ void testPreservesProviderFailureType() {
+ KmsAuthenticationException authenticationFailure =
+ new KmsAuthenticationException("authentication failed");
+ ConnectionFailedException unavailableFailure =
+ new ConnectionFailedException("provider unavailable");
+ KmsClient authenticationClient =
+ reference -> {
+ throw authenticationFailure;
+ };
+ KmsClient unavailableClient =
+ reference -> {
+ throw unavailableFailure;
+ };
+
+ Assertions.assertSame(
+ authenticationFailure,
+ Assertions.assertThrows(
+ KmsAuthenticationException.class,
+ () -> authenticationClient.getKeyProperties(REFERENCE)));
+ Assertions.assertSame(
+ unavailableFailure,
+ Assertions.assertThrows(
+ ConnectionFailedException.class, () ->
unavailableClient.getKeyProperties(REFERENCE)));
+ }
+
+ private static final class TestProperties implements KmsKeyProperties {
+
+ @Override
+ public KmsReference reference() {
+ return REFERENCE;
+ }
+
+ @Override
+ public boolean enabled() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWrapping() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsUnwrapping() {
+ return false;
+ }
+ }
+}
diff --git
a/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/FakeKmsClient.java
b/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/FakeKmsClient.java
new file mode 100644
index 0000000000..591c13f203
--- /dev/null
+++
b/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/FakeKmsClient.java
@@ -0,0 +1,140 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/** In-memory KMS client for contract and consumer tests. */
+public final class FakeKmsClient implements KmsClient {
+
+ private final String api;
+ private final String source;
+ private final Map<String, KeyState> keys = new HashMap<>();
+
+ /**
+ * Creates an empty fake client.
+ *
+ * @param api exact KMS API identifier accepted by the client; lowercase
kebab-case with no
+ * surrounding whitespace
+ * @param source configured source accepted by the client
+ */
+ public FakeKmsClient(String api, String source) {
+ this.api = KmsApiIdentifiers.requireValid(api);
+ if (source == null || source.trim().isEmpty()) {
+ throw new IllegalArgumentException("KMS source cannot be blank");
+ }
+ this.source = source.trim();
+ }
+
+ /**
+ * Adds or replaces a key reported by this client.
+ *
+ * @param keyId provider-native key identifier
+ * @param enabled whether the key is enabled
+ * @param supportsWrapping whether the key supports wrapping
+ * @param supportsUnwrapping whether the key supports unwrapping
+ * @return this client
+ */
+ public FakeKmsClient putKey(
+ String keyId, boolean enabled, boolean supportsWrapping, boolean
supportsUnwrapping) {
+ keys.put(keyId, new KeyState(enabled, supportsWrapping,
supportsUnwrapping));
+ return this;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Optional<KmsKeyProperties> getKeyProperties(KmsReference reference) {
+ requireReference(reference);
+ KeyState state = keys.get(reference.keyId());
+ return state == null
+ ? Optional.empty()
+ : Optional.of(
+ new Properties(
+ reference, state.enabled, state.supportsWrapping,
state.supportsUnwrapping));
+ }
+
+ private void requireReference(KmsReference reference) {
+ if (reference == null) {
+ throw new IllegalArgumentException("KMS reference cannot be null");
+ }
+ if (!reference.api().equals(api)) {
+ throw new IllegalArgumentException(
+ String.format("Expected KMS API '%s' but received '%s'", api,
reference.api()));
+ }
+ if (!source.equals(reference.source())) {
+ throw new IllegalArgumentException(
+ String.format(
+ "KMS source %s does not match configured source %s",
reference.source(), source));
+ }
+ }
+
+ private static final class KeyState {
+
+ private final boolean enabled;
+ private final boolean supportsWrapping;
+ private final boolean supportsUnwrapping;
+
+ private KeyState(boolean enabled, boolean supportsWrapping, boolean
supportsUnwrapping) {
+ this.enabled = enabled;
+ this.supportsWrapping = supportsWrapping;
+ this.supportsUnwrapping = supportsUnwrapping;
+ }
+ }
+
+ private static final class Properties implements KmsKeyProperties {
+
+ private final KmsReference reference;
+ private final boolean enabled;
+ private final boolean supportsWrapping;
+ private final boolean supportsUnwrapping;
+
+ private Properties(
+ KmsReference reference,
+ boolean enabled,
+ boolean supportsWrapping,
+ boolean supportsUnwrapping) {
+ this.reference = reference;
+ this.enabled = enabled;
+ this.supportsWrapping = supportsWrapping;
+ this.supportsUnwrapping = supportsUnwrapping;
+ }
+
+ @Override
+ public KmsReference reference() {
+ return reference;
+ }
+
+ @Override
+ public boolean enabled() {
+ return enabled;
+ }
+
+ @Override
+ public boolean supportsWrapping() {
+ return supportsWrapping;
+ }
+
+ @Override
+ public boolean supportsUnwrapping() {
+ return supportsUnwrapping;
+ }
+ }
+}
diff --git
a/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientContract.java
b/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientContract.java
new file mode 100644
index 0000000000..868d64bd62
--- /dev/null
+++
b/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientContract.java
@@ -0,0 +1,95 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import java.util.Optional;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Common inspection contract for implemented KMS clients. */
+public abstract class TestKmsClientContract {
+
+ /**
+ * Returns the client under test.
+ *
+ * @return the client
+ */
+ protected abstract KmsClient client();
+
+ /**
+ * Returns a reference to an enabled key that supports wrapping and
unwrapping.
+ *
+ * @return the usable key reference
+ */
+ protected abstract KmsReference usableKey();
+
+ /**
+ * Returns a reference to a key that does not exist.
+ *
+ * @return the missing key reference
+ */
+ protected abstract KmsReference missingKey();
+
+ @Test
+ void testReportsUsableKeyProperties() {
+ KmsReference reference = usableKey();
+ Optional<KmsKeyProperties> result = client().getKeyProperties(reference);
+
+ Assertions.assertNotNull(result, "KMS clients must not return null");
+ KmsKeyProperties properties = result.orElseThrow();
+ Assertions.assertEquals(reference, properties.reference());
+ Assertions.assertTrue(properties.enabled());
+ Assertions.assertTrue(properties.supportsWrapping());
+ Assertions.assertTrue(properties.supportsUnwrapping());
+ }
+
+ @Test
+ void testReportsMissingKey() {
+ KmsReference reference = missingKey();
+ Optional<KmsKeyProperties> result = client().getKeyProperties(reference);
+
+ Assertions.assertNotNull(result, "KMS clients must not return null");
+ Assertions.assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void testRejectsNullReference() {
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
client().getKeyProperties(null));
+ }
+
+ @Test
+ void testRejectsMismatchedSource() {
+ KmsReference reference = usableKey();
+ KmsReference mismatched =
+ new KmsReference(reference.api(), reference.source() + "-other",
reference.keyId());
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
client().getKeyProperties(mismatched));
+ }
+
+ @Test
+ void testRejectsMismatchedApi() {
+ KmsReference reference = usableKey();
+ KmsReference mismatched =
+ new KmsReference(reference.api() + "-other", reference.source(),
reference.keyId());
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
client().getKeyProperties(mismatched));
+ }
+}
diff --git
a/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientFactoryContract.java
b/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientFactoryContract.java
new file mode 100644
index 0000000000..52eec8f5dc
--- /dev/null
+++
b/common/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientFactoryContract.java
@@ -0,0 +1,45 @@
+/*
+ * 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.gravitino.encryption.kms;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Common contract for KMS client factories. */
+public abstract class TestKmsClientFactoryContract {
+
+ /**
+ * Returns the factory under test.
+ *
+ * @return the factory
+ */
+ protected abstract KmsClientFactory factory();
+
+ /**
+ * Returns the API expected from the factory.
+ *
+ * @return the expected API identifier
+ */
+ protected abstract String expectedApi();
+
+ @Test
+ void testReportsExpectedApi() {
+ Assertions.assertEquals(expectedApi(), factory().api());
+ }
+}