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

Reply via email to