diff --git a/api/build.gradle.kts b/api/build.gradle.kts index f0fe3ba5ee9..616703cb1eb 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -18,6 +18,7 @@ */ plugins { `maven-publish` + `java-test-fixtures` id("java") id("idea") } @@ -32,6 +33,8 @@ dependencies { testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) testRuntimeOnly(libs.junit.jupiter.engine) + + testFixturesApi(libs.junit.jupiter.api) } tasks.build { diff --git a/api/src/main/java/org/apache/gravitino/encryption/kms/KmsApi.java b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsApi.java new file mode 100644 index 00000000000..e32e17d201f --- /dev/null +++ b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsApi.java @@ -0,0 +1,85 @@ +/* + * 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.Arrays; +import java.util.Locale; +import java.util.stream.Collectors; +import org.apache.gravitino.annotation.DeveloperApi; + +/** The KMS product API that defines key identifier and operation semantics. */ +@DeveloperApi +public enum KmsApi { + /** Amazon Web Services Key Management Service. */ + AWS_KMS("aws-kms"), + + /** Google Cloud Key Management Service. */ + GOOGLE_CLOUD_KMS("google-cloud-kms"), + + /** Microsoft Azure Key Vault. */ + AZURE_KEY_VAULT("azure-key-vault"), + + /** The OpenBao Transit secrets engine API. */ + OPENBAO_TRANSIT("openbao-transit"), + + /** The HashiCorp Vault Transit secrets engine API. */ + VAULT_TRANSIT("vault-transit"); + + private final String wireValue; + + KmsApi(String wireValue) { + this.wireValue = wireValue; + } + + /** + * Returns the stable configuration value for this API. + * + * @return the configuration value + */ + public String wireValue() { + return wireValue; + } + + /** + * Parses a configured API value. + * + * @param value configured API value + * @return the matching API + * @throws IllegalArgumentException if the value is null, blank, or unsupported + */ + public static KmsApi fromWireValue(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("KMS API cannot be blank"); + } + + String normalized = value.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(values()) + .filter(api -> api.wireValue.equals(normalized)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Unsupported KMS API '%s'; supported values are %s", + value, + Arrays.stream(values()) + .map(KmsApi::wireValue) + .collect(Collectors.joining(", "))))); + } +} diff --git a/api/src/main/java/org/apache/gravitino/encryption/kms/KmsAuthenticationException.java b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsAuthenticationException.java new file mode 100644 index 00000000000..d199c7cbd63 --- /dev/null +++ b/api/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/api/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java new file mode 100644 index 00000000000..bcc541f98dd --- /dev/null +++ b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsClient.java @@ -0,0 +1,47 @@ +/* + * 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; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +/** + * Inspects keys managed by a configured KMS. + * + *

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. + * + * @param reference key to inspect + * @return normalized key properties + * @throws IllegalArgumentException if the reference does not belong to this client + * @throws ConnectionFailedException if the provider cannot be queried + */ + KmsKeyProperties getKeyProperties(KmsReference reference); + + /** Releases resources owned by this client. */ + @Override + default void close() {} +} diff --git a/api/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.java new file mode 100644 index 00000000000..3cfc4898492 --- /dev/null +++ b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsClientFactory.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 java.util.Map; +import org.apache.gravitino.annotation.DeveloperApi; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +/** Creates server-side KMS clients for one supported KMS API. */ +@DeveloperApi +public interface KmsClientFactory { + + /** + * Returns the KMS API implemented by this factory. + * + * @return the supported KMS API + */ + KmsApi api(); + + /** + * Creates a client bound to a configured KMS source. + * + *

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 properties); +} diff --git a/api/src/main/java/org/apache/gravitino/encryption/kms/KmsConfigurationException.java b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsConfigurationException.java new file mode 100644 index 00000000000..ec1613666a6 --- /dev/null +++ b/api/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/api/src/main/java/org/apache/gravitino/encryption/kms/KmsKeyProperties.java b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsKeyProperties.java new file mode 100644 index 00000000000..ae1896a5fa8 --- /dev/null +++ b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsKeyProperties.java @@ -0,0 +1,65 @@ +/* + * 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 KMS key. + * + *

Capabilities describe downstream use; this API does not perform cryptographic operations. + */ +@DeveloperApi +public interface KmsKeyProperties { + + /** + * Returns the requested key reference. + * + * @return the key reference + */ + KmsReference reference(); + + /** + * Returns whether the provider reports that the key exists. + * + * @return whether the key exists + */ + boolean present(); + + /** + * Returns whether the provider reports that the key is enabled. + * + * @return whether the key is enabled + */ + boolean enabled(); + + /** + * Returns whether the key supports wrapping data-encryption keys. + * + * @return whether wrapping is supported + */ + boolean supportsWrapping(); + + /** + * Returns whether the key supports unwrapping data-encryption keys. + * + * @return whether unwrapping is supported + */ + boolean supportsUnwrapping(); +} 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 00000000000..9350e8844f5 --- /dev/null +++ b/api/src/main/java/org/apache/gravitino/encryption/kms/KmsReference.java @@ -0,0 +1,105 @@ +/* + * 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. + * + *

Contains no credentials or key material. + */ +@DeveloperApi +public final class KmsReference { + + private final KmsApi 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 + * @param source configured KMS client-instance name + * @param keyId provider-native key identifier + * @throws IllegalArgumentException if the API is null or either string is null or blank + */ + public KmsReference(KmsApi api, String source, String keyId) { + Preconditions.checkArgument(api != null, "KMS API cannot be null"); + Preconditions.checkArgument(StringUtils.isNotBlank(source), "KMS source cannot be blank"); + Preconditions.checkArgument(StringUtils.isNotBlank(keyId), "KMS key ID cannot be blank"); + + this.api = api; + this.source = source; + this.keyId = keyId; + } + + /** + * Returns the explicitly selected KMS API. + * + * @return the KMS API + */ + public KmsApi 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 == 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/TestFakeKmsClient.java b/api/src/test/java/org/apache/gravitino/encryption/kms/TestFakeKmsClient.java new file mode 100644 index 00000000000..7ad5e2acca8 --- /dev/null +++ b/api/src/test/java/org/apache/gravitino/encryption/kms/TestFakeKmsClient.java @@ -0,0 +1,44 @@ +/* + * 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; + +public class TestFakeKmsClient extends TestKmsClientContract { + + private static final String SOURCE = "test"; + private static final String USABLE_KEY = "usable"; + private static final String MISSING_KEY = "missing"; + + private final FakeKmsClient client = + new FakeKmsClient(KmsApi.AWS_KMS, SOURCE).putKey(USABLE_KEY, true, true, true); + + @Override + protected KmsClient client() { + return client; + } + + @Override + protected KmsReference usableKey() { + return new KmsReference(KmsApi.AWS_KMS, SOURCE, USABLE_KEY); + } + + @Override + protected KmsReference missingKey() { + return new KmsReference(KmsApi.AWS_KMS, SOURCE, MISSING_KEY); + } +} diff --git a/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsApi.java b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsApi.java new file mode 100644 index 00000000000..cae91fb0303 --- /dev/null +++ b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsApi.java @@ -0,0 +1,47 @@ +/* + * 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 TestKmsApi { + + @Test + void testParsesStableWireValues() { + Assertions.assertEquals(KmsApi.AWS_KMS, KmsApi.fromWireValue("aws-kms")); + Assertions.assertEquals(KmsApi.GOOGLE_CLOUD_KMS, KmsApi.fromWireValue(" GOOGLE-CLOUD-KMS ")); + Assertions.assertEquals("azure-key-vault", KmsApi.AZURE_KEY_VAULT.wireValue()); + Assertions.assertEquals( + KmsApi.OPENBAO_TRANSIT, KmsApi.fromWireValue(KmsApi.OPENBAO_TRANSIT.wireValue())); + Assertions.assertEquals(KmsApi.VAULT_TRANSIT, KmsApi.fromWireValue("vault-transit")); + } + + @Test + void testRejectsUnknownValues() { + Assertions.assertThrows(IllegalArgumentException.class, () -> KmsApi.fromWireValue(null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> KmsApi.fromWireValue(" ")); + + IllegalArgumentException exception = + Assertions.assertThrows( + IllegalArgumentException.class, () -> KmsApi.fromWireValue("custom")); + Assertions.assertTrue(exception.getMessage().contains("Unsupported KMS API 'custom'")); + Assertions.assertTrue(exception.getMessage().contains("openbao-transit")); + } +} diff --git a/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClient.java b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClient.java new file mode 100644 index 00000000000..14fe5d8b4c2 --- /dev/null +++ b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClient.java @@ -0,0 +1,73 @@ +/* + * 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 TestKmsClient { + + private static final KmsReference REFERENCE = + new KmsReference(KmsApi.AWS_KMS, "production", "key"); + + @Test + void testReturnsProviderProperties() { + KmsKeyProperties properties = new TestProperties(); + KmsClient client = reference -> properties; + + Assertions.assertSame(properties, client.getKeyProperties(REFERENCE)); + Assertions.assertTrue(properties.supportsWrapping()); + Assertions.assertFalse(properties.supportsUnwrapping()); + } + + @Test + void testDefaultClose() { + KmsClient client = reference -> new TestProperties(); + + Assertions.assertDoesNotThrow(client::close); + } + + private static final class TestProperties implements KmsKeyProperties { + + @Override + public KmsReference reference() { + return REFERENCE; + } + + @Override + public boolean present() { + return true; + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean supportsWrapping() { + return true; + } + + @Override + public boolean supportsUnwrapping() { + return false; + } + } +} 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 00000000000..fe6b566faa0 --- /dev/null +++ b/api/src/test/java/org/apache/gravitino/encryption/kms/TestKmsReference.java @@ -0,0 +1,71 @@ +/* + * 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 testStoresReferenceExactly() { + KmsReference reference = new KmsReference(KmsApi.AWS_KMS, "production", " alias/Customer-Key "); + + Assertions.assertEquals(KmsApi.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(KmsApi.AWS_KMS, null, "key")); + Assertions.assertThrows( + IllegalArgumentException.class, () -> new KmsReference(KmsApi.AWS_KMS, "production", null)); + Assertions.assertThrows( + IllegalArgumentException.class, () -> new KmsReference(KmsApi.AWS_KMS, "", "key")); + Assertions.assertThrows( + IllegalArgumentException.class, () -> new KmsReference(KmsApi.AWS_KMS, " ", "key")); + Assertions.assertThrows( + IllegalArgumentException.class, () -> new KmsReference(KmsApi.AWS_KMS, "production", "")); + Assertions.assertThrows( + IllegalArgumentException.class, () -> new KmsReference(KmsApi.AWS_KMS, "production", " ")); + } + + @Test + void testValueSemantics() { + KmsReference first = new KmsReference(KmsApi.AWS_KMS, "production", "key"); + KmsReference same = new KmsReference(KmsApi.AWS_KMS, "production", "key"); + KmsReference differentApi = new KmsReference(KmsApi.GOOGLE_CLOUD_KMS, "production", "key"); + KmsReference differentSource = new KmsReference(KmsApi.AWS_KMS, "recovery", "key"); + KmsReference differentKey = new KmsReference(KmsApi.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/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/FakeKmsClient.java b/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/FakeKmsClient.java new file mode 100644 index 00000000000..183ffb37b98 --- /dev/null +++ b/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/FakeKmsClient.java @@ -0,0 +1,142 @@ +/* + * 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; + +/** In-memory KMS client for contract and consumer tests. */ +public final class FakeKmsClient implements KmsClient { + + private final KmsApi api; + private final String source; + private final Map keys = new HashMap<>(); + + /** + * Creates an empty fake client. + * + * @param api KMS API accepted by the client + * @param source configured source accepted by the client + */ + public FakeKmsClient(KmsApi api, String source) { + this.api = api; + this.source = source; + } + + /** + * 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 KmsKeyProperties getKeyProperties(KmsReference reference) { + requireReference(reference); + KeyState state = keys.get(reference.keyId()); + return state == null + ? new Properties(reference, false, false, false, false) + : new Properties( + reference, true, 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() != 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 present; + private final boolean enabled; + private final boolean supportsWrapping; + private final boolean supportsUnwrapping; + + private Properties( + KmsReference reference, + boolean present, + boolean enabled, + boolean supportsWrapping, + boolean supportsUnwrapping) { + this.reference = reference; + this.present = present; + this.enabled = enabled; + this.supportsWrapping = supportsWrapping; + this.supportsUnwrapping = supportsUnwrapping; + } + + @Override + public KmsReference reference() { + return reference; + } + + @Override + public boolean present() { + return present; + } + + @Override + public boolean enabled() { + return enabled; + } + + @Override + public boolean supportsWrapping() { + return supportsWrapping; + } + + @Override + public boolean supportsUnwrapping() { + return supportsUnwrapping; + } + } +} diff --git a/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientContract.java b/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientContract.java new file mode 100644 index 00000000000..855b4bdf6d9 --- /dev/null +++ b/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientContract.java @@ -0,0 +1,98 @@ +/* + * 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.Arrays; +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(); + KmsKeyProperties properties = client().getKeyProperties(reference); + + Assertions.assertEquals(reference, properties.reference()); + Assertions.assertTrue(properties.present()); + Assertions.assertTrue(properties.enabled()); + Assertions.assertTrue(properties.supportsWrapping()); + Assertions.assertTrue(properties.supportsUnwrapping()); + } + + @Test + void testReportsMissingKey() { + KmsReference reference = missingKey(); + KmsKeyProperties properties = client().getKeyProperties(reference); + + Assertions.assertEquals(reference, properties.reference()); + Assertions.assertFalse(properties.present()); + } + + @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(); + KmsApi otherApi = + Arrays.stream(KmsApi.values()) + .filter(api -> api != reference.api()) + .findFirst() + .orElseThrow(IllegalStateException::new); + KmsReference mismatched = new KmsReference(otherApi, reference.source(), reference.keyId()); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> client().getKeyProperties(mismatched)); + } +} diff --git a/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientFactoryContract.java b/api/src/testFixtures/java/org/apache/gravitino/encryption/kms/TestKmsClientFactoryContract.java new file mode 100644 index 00000000000..4136e5de41e --- /dev/null +++ b/api/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 + */ + protected abstract KmsApi expectedApi(); + + @Test + void testReportsExpectedApi() { + Assertions.assertEquals(expectedApi(), factory().api()); + } +} diff --git a/bundles/aws-bundle/build.gradle.kts b/bundles/aws-bundle/build.gradle.kts index b7ee4927192..268cd47785e 100644 --- a/bundles/aws-bundle/build.gradle.kts +++ b/bundles/aws-bundle/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(project(":bundles:aws")) implementation(libs.hadoop3.aws) implementation(libs.aws.iam) + implementation(libs.aws.kms) implementation(libs.aws.policy) implementation(libs.aws.sts) implementation(libs.hadoop3.client.api) diff --git a/bundles/aws/build.gradle.kts b/bundles/aws/build.gradle.kts index 7b6d4f1ec12..bf2c933db6c 100644 --- a/bundles/aws/build.gradle.kts +++ b/bundles/aws/build.gradle.kts @@ -40,19 +40,32 @@ dependencies { implementation(libs.guava) compileOnly(libs.aws.iam) + compileOnly(libs.aws.kms) compileOnly(libs.aws.policy) compileOnly(libs.aws.sts) compileOnly(libs.hadoop3.aws) compileOnly(libs.hadoop3.client.api) testImplementation(libs.aws.iam) + testImplementation(libs.aws.kms) testImplementation(libs.aws.policy) testImplementation(libs.aws.sts) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) + testImplementation(libs.mockito.core) + testImplementation(libs.testcontainers) + testImplementation(libs.testcontainers.localstack) + testImplementation(testFixtures(project(":api"))) testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.slf4j.jdk14) } tasks.compileJava { dependsOn(":catalogs:catalog-fileset:runtimeJars") } + +tasks.test { + if (project.hasProperty("skipITs")) { + exclude("**/integration/test/**") + } +} diff --git a/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsClient.java b/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsClient.java new file mode 100644 index 00000000000..d0efcfb2b45 --- /dev/null +++ b/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsClient.java @@ -0,0 +1,106 @@ +/* + * 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.kms.aws; + +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsAuthenticationException; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.kms.model.DescribeKeyRequest; +import software.amazon.awssdk.services.kms.model.DescribeKeyResponse; +import software.amazon.awssdk.services.kms.model.InvalidArnException; +import software.amazon.awssdk.services.kms.model.KeyMetadata; +import software.amazon.awssdk.services.kms.model.KeyState; +import software.amazon.awssdk.services.kms.model.KeyUsageType; +import software.amazon.awssdk.services.kms.model.KmsException; +import software.amazon.awssdk.services.kms.model.NotFoundException; + +final class AwsKmsClient implements KmsClient { + + private final String source; + private final software.amazon.awssdk.services.kms.KmsClient delegate; + + AwsKmsClient(String source, software.amazon.awssdk.services.kms.KmsClient delegate) { + this.source = source; + this.delegate = delegate; + } + + @Override + public KmsKeyProperties getKeyProperties(KmsReference reference) { + validateReference(reference); + String providerKeyId = reference.keyId(); + + try { + DescribeKeyResponse response = + delegate.describeKey(DescribeKeyRequest.builder().keyId(providerKeyId).build()); + if (response == null || response.keyMetadata() == null) { + throw new ConnectionFailedException( + "AWS KMS returned no metadata for key '%s' from source '%s'", providerKeyId, source); + } + + KeyMetadata metadata = response.keyMetadata(); + boolean enabled = + Boolean.TRUE.equals(metadata.enabled()) && metadata.keyState() == KeyState.ENABLED; + boolean supportsEncryption = metadata.keyUsage() == KeyUsageType.ENCRYPT_DECRYPT; + return new AwsKmsKeyProperties( + reference, true, enabled, supportsEncryption, supportsEncryption); + } catch (NotFoundException e) { + return new AwsKmsKeyProperties(reference, false, false, false, false); + } catch (InvalidArnException e) { + throw new IllegalArgumentException( + String.format("Invalid AWS KMS key ID '%s'", providerKeyId), e); + } catch (KmsException e) { + if (e.statusCode() == 401 || e.statusCode() == 403) { + throw new KmsAuthenticationException( + e, "AWS KMS rejected credentials for source '%s'", source); + } + throw new ConnectionFailedException( + e, "Failed to inspect AWS KMS key '%s' from source '%s'", providerKeyId, source); + } catch (SdkClientException e) { + throw new ConnectionFailedException( + e, "Failed to inspect AWS KMS key '%s' from source '%s'", providerKeyId, source); + } + } + + @Override + public void close() { + delegate.close(); + } + + private void validateReference(KmsReference reference) { + if (reference == null) { + throw new IllegalArgumentException("KMS reference cannot be null"); + } + if (reference.api() != KmsApi.AWS_KMS) { + throw new IllegalArgumentException( + String.format( + "KMS source '%s' requires API '%s', not '%s'", + source, KmsApi.AWS_KMS.wireValue(), reference.api().wireValue())); + } + if (!source.equals(reference.source())) { + throw new IllegalArgumentException( + String.format( + "KMS reference source '%s' does not match AWS KMS source '%s'", + reference.source(), source)); + } + } +} diff --git a/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsClientFactory.java b/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsClientFactory.java new file mode 100644 index 00000000000..46c03781272 --- /dev/null +++ b/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsClientFactory.java @@ -0,0 +1,149 @@ +/* + * 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.kms.aws; + +import com.google.common.collect.ImmutableSet; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Set; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsConfigurationException; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.kms.KmsClientBuilder; + +/** Creates AWS KMS clients using the AWS SDK default credential provider chain. */ +public final class AwsKmsClientFactory implements KmsClientFactory { + + /** Required AWS region property. */ + public static final String REGION = "endpoint.region"; + + /** Optional HTTP(S) AWS KMS endpoint override property. */ + public static final String SERVICE_ADDRESS = "endpoint.serviceAddress"; + + /** Credential method property. */ + public static final String CREDENTIAL_METHOD = "credential.method"; + + private static final Set SUPPORTED_PROPERTIES = + ImmutableSet.of(REGION, SERVICE_ADDRESS, CREDENTIAL_METHOD); + + /** Creates an AWS KMS client factory. */ + public AwsKmsClientFactory() {} + + /** + * Returns the AWS KMS API. + * + * @return the AWS KMS API + */ + @Override + public KmsApi api() { + return KmsApi.AWS_KMS; + } + + /** + * Creates an AWS KMS client for a configured source. + * + *

Credentials are intentionally not accepted as source properties. The AWS SDK resolves + * credentials through its default credential provider chain when a request is made. + * + * @param source logical name of the configured AWS KMS instance + * @param properties AWS KMS properties + * @return the configured AWS KMS client + * @throws IllegalArgumentException if the source or properties are invalid + * @throws ConnectionFailedException if the AWS SDK client cannot be initialized + */ + @Override + public KmsClient create(String source, Map properties) { + validateSource(source); + if (properties == null) { + throw new KmsConfigurationException("AWS KMS properties cannot be null"); + } + validatePropertyNames(properties); + + String region = requiredProperty(properties, REGION); + String credentialMethod = requiredProperty(properties, CREDENTIAL_METHOD); + if (!"default".equals(credentialMethod)) { + throw new KmsConfigurationException( + "Unsupported AWS KMS credential method: %s", credentialMethod); + } + KmsClientBuilder builder = + software.amazon.awssdk.services.kms.KmsClient.builder().region(Region.of(region)); + if (properties.containsKey(SERVICE_ADDRESS)) { + builder.endpointOverride(parseServiceAddress(requiredProperty(properties, SERVICE_ADDRESS))); + } + + try { + return new AwsKmsClient(source, builder.build()); + } catch (SdkClientException e) { + throw new ConnectionFailedException( + e, "Failed to initialize AWS KMS client for source '%s'", source); + } + } + + private static void validateSource(String source) { + if (source == null || source.trim().isEmpty()) { + throw new KmsConfigurationException("AWS KMS source cannot be blank"); + } + } + + private static void validatePropertyNames(Map properties) { + for (String property : properties.keySet()) { + if (!SUPPORTED_PROPERTIES.contains(property)) { + throw new KmsConfigurationException("Unsupported AWS KMS property '%s'", property); + } + } + } + + private static String requiredProperty(Map properties, String property) { + String value = properties.get(property); + if (value == null || value.trim().isEmpty()) { + throw new KmsConfigurationException("AWS KMS property '%s' cannot be blank", property); + } + return value.trim(); + } + + private static URI parseServiceAddress(String serviceAddress) { + URI uri; + try { + uri = new URI(serviceAddress); + } catch (URISyntaxException e) { + throw new KmsConfigurationException( + e, "Invalid AWS KMS service address '%s'", serviceAddress); + } + + boolean http = "http".equalsIgnoreCase(uri.getScheme()); + boolean https = "https".equalsIgnoreCase(uri.getScheme()); + boolean rootPath = + uri.getPath() == null || uri.getPath().isEmpty() || "/".equals(uri.getPath()); + if ((!http && !https) + || uri.getHost() == null + || uri.getUserInfo() != null + || !rootPath + || uri.getQuery() != null + || uri.getFragment() != null) { + throw new KmsConfigurationException( + "Invalid AWS KMS service address '%s'; expected an HTTP(S) origin", serviceAddress); + } + return uri; + } +} diff --git a/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsKeyProperties.java b/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsKeyProperties.java new file mode 100644 index 00000000000..2442537084a --- /dev/null +++ b/bundles/aws/src/main/java/org/apache/gravitino/kms/aws/AwsKmsKeyProperties.java @@ -0,0 +1,69 @@ +/* + * 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.kms.aws; + +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; + +final class AwsKmsKeyProperties implements KmsKeyProperties { + + private final KmsReference reference; + private final boolean present; + private final boolean enabled; + private final boolean supportsWrapping; + private final boolean supportsUnwrapping; + + AwsKmsKeyProperties( + KmsReference reference, + boolean present, + boolean enabled, + boolean supportsWrapping, + boolean supportsUnwrapping) { + this.reference = reference; + this.present = present; + this.enabled = enabled; + this.supportsWrapping = supportsWrapping; + this.supportsUnwrapping = supportsUnwrapping; + } + + @Override + public KmsReference reference() { + return reference; + } + + @Override + public boolean present() { + return present; + } + + @Override + public boolean enabled() { + return enabled; + } + + @Override + public boolean supportsWrapping() { + return supportsWrapping; + } + + @Override + public boolean supportsUnwrapping() { + return supportsUnwrapping; + } +} diff --git a/bundles/aws/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory b/bundles/aws/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory new file mode 100644 index 00000000000..9dca5b002b4 --- /dev/null +++ b/bundles/aws/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory @@ -0,0 +1,20 @@ +# +# 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. +# + +org.apache.gravitino.kms.aws.AwsKmsClientFactory diff --git a/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/TestAwsKmsClient.java b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/TestAwsKmsClient.java new file mode 100644 index 00000000000..f4537049787 --- /dev/null +++ b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/TestAwsKmsClient.java @@ -0,0 +1,322 @@ +/* + * 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.kms.aws; + +import java.util.stream.Stream; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsAuthenticationException; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientContract; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.services.kms.model.DependencyTimeoutException; +import software.amazon.awssdk.services.kms.model.DescribeKeyRequest; +import software.amazon.awssdk.services.kms.model.DescribeKeyResponse; +import software.amazon.awssdk.services.kms.model.InvalidArnException; +import software.amazon.awssdk.services.kms.model.KeyMetadata; +import software.amazon.awssdk.services.kms.model.KeySpec; +import software.amazon.awssdk.services.kms.model.KeyState; +import software.amazon.awssdk.services.kms.model.KeyUsageType; +import software.amazon.awssdk.services.kms.model.KmsException; +import software.amazon.awssdk.services.kms.model.KmsInternalException; +import software.amazon.awssdk.services.kms.model.NotFoundException; + +/** Tests AWS KMS key inspection and normalized property mapping. */ +public class TestAwsKmsClient extends TestKmsClientContract { + + private static final String SOURCE = "primary-aws"; + private static final String USABLE_KEY_ID = "1234abcd-12ab-34cd-56ef-1234567890ab"; + private static final String MISSING_KEY_ID = "missing-key"; + + private software.amazon.awssdk.services.kms.KmsClient delegate; + private AwsKmsClient client; + + @BeforeEach + void setUp() { + delegate = Mockito.mock(software.amazon.awssdk.services.kms.KmsClient.class); + client = new AwsKmsClient(SOURCE, delegate); + Mockito.when(delegate.describeKey(Mockito.any(DescribeKeyRequest.class))) + .thenAnswer( + invocation -> { + DescribeKeyRequest request = invocation.getArgument(0); + if (MISSING_KEY_ID.equals(request.keyId())) { + throw NotFoundException.builder().message("Key does not exist").build(); + } + return response( + KeyMetadata.builder() + .enabled(true) + .keyState(KeyState.ENABLED) + .keyUsage(KeyUsageType.ENCRYPT_DECRYPT) + .build()); + }); + } + + @Override + protected KmsClient client() { + return client; + } + + @Override + protected KmsReference usableKey() { + return reference(USABLE_KEY_ID); + } + + @Override + protected KmsReference missingKey() { + return reference(MISSING_KEY_ID); + } + + @ParameterizedTest + @ValueSource( + strings = { + "1234abcd-12ab-34cd-56ef-1234567890ab", + "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + "alias/application-key", + "arn:aws:kms:us-west-2:111122223333:alias/application-key" + }) + void testPassesProviderNativeKeyIdentifierWithoutModification(String keyId) { + client.getKeyProperties(reference(keyId)); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(DescribeKeyRequest.class); + Mockito.verify(delegate).describeKey(requestCaptor.capture()); + Assertions.assertEquals(keyId, requestCaptor.getValue().keyId()); + } + + @ParameterizedTest + @EnumSource( + value = KeySpec.class, + names = {"SYMMETRIC_DEFAULT", "RSA_2048"}) + void testSupportsSymmetricAndAsymmetricEncryptionKeys(KeySpec keySpec) { + stubMetadata( + KeyMetadata.builder() + .enabled(true) + .keyState(KeyState.ENABLED) + .keyUsage(KeyUsageType.ENCRYPT_DECRYPT) + .keySpec(keySpec) + .build()); + + KmsKeyProperties properties = usableKeyProperties(); + + Assertions.assertTrue(properties.enabled()); + Assertions.assertTrue(properties.supportsWrapping()); + Assertions.assertTrue(properties.supportsUnwrapping()); + } + + @ParameterizedTest + @EnumSource( + value = KeyState.class, + names = { + "CREATING", + "DISABLED", + "PENDING_DELETION", + "PENDING_IMPORT", + "PENDING_REPLICA_DELETION", + "UNAVAILABLE", + "UPDATING", + "UNKNOWN_TO_SDK_VERSION" + }) + void testOnlyEnabledStateIsEnabled(KeyState keyState) { + stubMetadata( + KeyMetadata.builder() + .enabled(true) + .keyState(keyState) + .keyUsage(KeyUsageType.ENCRYPT_DECRYPT) + .build()); + + KmsKeyProperties properties = usableKeyProperties(); + + Assertions.assertTrue(properties.present()); + Assertions.assertFalse(properties.enabled()); + Assertions.assertTrue(properties.supportsWrapping()); + Assertions.assertTrue(properties.supportsUnwrapping()); + } + + @Test + void testEnabledFlagMustAlsoBeTrue() { + stubMetadata( + KeyMetadata.builder() + .enabled(false) + .keyState(KeyState.ENABLED) + .keyUsage(KeyUsageType.ENCRYPT_DECRYPT) + .build()); + + Assertions.assertFalse(usableKeyProperties().enabled()); + } + + @Test + void testMissingStateIsNotEnabled() { + stubMetadata( + KeyMetadata.builder().enabled(true).keyUsage(KeyUsageType.ENCRYPT_DECRYPT).build()); + + Assertions.assertFalse(usableKeyProperties().enabled()); + } + + @ParameterizedTest + @EnumSource( + value = KeyUsageType.class, + names = {"SIGN_VERIFY", "GENERATE_VERIFY_MAC", "KEY_AGREEMENT", "UNKNOWN_TO_SDK_VERSION"}) + void testNonEncryptionUsageDoesNotSupportWrapping(KeyUsageType keyUsage) { + stubMetadata( + KeyMetadata.builder().enabled(true).keyState(KeyState.ENABLED).keyUsage(keyUsage).build()); + + KmsKeyProperties properties = usableKeyProperties(); + + Assertions.assertTrue(properties.enabled()); + Assertions.assertFalse(properties.supportsWrapping()); + Assertions.assertFalse(properties.supportsUnwrapping()); + } + + @Test + void testMissingUsageDoesNotSupportWrapping() { + stubMetadata(KeyMetadata.builder().enabled(true).keyState(KeyState.ENABLED).build()); + + KmsKeyProperties properties = usableKeyProperties(); + + Assertions.assertFalse(properties.supportsWrapping()); + Assertions.assertFalse(properties.supportsUnwrapping()); + } + + @Test + void testUnknownProviderStateAndUsageAreConservative() { + stubMetadata( + KeyMetadata.builder() + .enabled(true) + .keyState("A_NEW_STATE") + .keyUsage("A_NEW_USAGE") + .build()); + + KmsKeyProperties properties = usableKeyProperties(); + + Assertions.assertFalse(properties.enabled()); + Assertions.assertFalse(properties.supportsWrapping()); + Assertions.assertFalse(properties.supportsUnwrapping()); + } + + @Test + void testMissingKeyReturnsAllFalseProperties() { + KmsReference reference = missingKey(); + + KmsKeyProperties properties = client.getKeyProperties(reference); + + Assertions.assertEquals(reference, properties.reference()); + Assertions.assertFalse(properties.present()); + Assertions.assertFalse(properties.enabled()); + Assertions.assertFalse(properties.supportsWrapping()); + Assertions.assertFalse(properties.supportsUnwrapping()); + } + + @Test + void testInvalidArnIsRejectedAsInvalidInput() { + InvalidArnException failure = InvalidArnException.builder().message("Invalid ARN").build(); + Mockito.when(delegate.describeKey(Mockito.any(DescribeKeyRequest.class))).thenThrow(failure); + + IllegalArgumentException thrown = + Assertions.assertThrows( + IllegalArgumentException.class, () -> client.getKeyProperties(usableKey())); + + Assertions.assertSame(failure, thrown.getCause()); + } + + @Test + void testRejectedCredentialsAreAuthenticationFailures() { + KmsException failure = + (KmsException) KmsException.builder().message("access denied").statusCode(403).build(); + Mockito.when(delegate.describeKey(Mockito.any(DescribeKeyRequest.class))).thenThrow(failure); + + KmsAuthenticationException thrown = + Assertions.assertThrows( + KmsAuthenticationException.class, () -> client.getKeyProperties(usableKey())); + + Assertions.assertSame(failure, thrown.getCause()); + } + + @ParameterizedTest + @MethodSource("connectionFailures") + void testProviderAndTransportFailuresAreConnectionFailures(RuntimeException failure) { + Mockito.when(delegate.describeKey(Mockito.any(DescribeKeyRequest.class))).thenThrow(failure); + + ConnectionFailedException thrown = + Assertions.assertThrows( + ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + + Assertions.assertSame(failure, thrown.getCause()); + } + + @Test + void testMissingResponseIsConnectionFailure() { + Mockito.when(delegate.describeKey(Mockito.any(DescribeKeyRequest.class))).thenReturn(null); + + Assertions.assertThrows( + ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + } + + @Test + void testMissingMetadataIsConnectionFailure() { + Mockito.when(delegate.describeKey(Mockito.any(DescribeKeyRequest.class))) + .thenReturn(DescribeKeyResponse.builder().build()); + + Assertions.assertThrows( + ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + } + + @Test + void testCloseDelegatesToAwsClient() { + client.close(); + + Mockito.verify(delegate).close(); + } + + private static Stream connectionFailures() { + return Stream.of( + Arguments.of(SdkClientException.create("transport unavailable")), + Arguments.of(DependencyTimeoutException.builder().message("dependency timeout").build()), + Arguments.of(KmsInternalException.builder().message("internal failure").build()), + Arguments.of(KmsException.builder().message("throttled").statusCode(429).build())); + } + + private static DescribeKeyResponse response(KeyMetadata metadata) { + return DescribeKeyResponse.builder().keyMetadata(metadata).build(); + } + + private KmsReference reference(String keyId) { + return new KmsReference(KmsApi.AWS_KMS, SOURCE, keyId); + } + + private KmsKeyProperties usableKeyProperties() { + return client.getKeyProperties(usableKey()); + } + + private void stubMetadata(KeyMetadata metadata) { + Mockito.when(delegate.describeKey(Mockito.any(DescribeKeyRequest.class))) + .thenReturn(response(metadata)); + } +} diff --git a/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/TestAwsKmsClientFactory.java b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/TestAwsKmsClientFactory.java new file mode 100644 index 00000000000..99a314089eb --- /dev/null +++ b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/TestAwsKmsClientFactory.java @@ -0,0 +1,169 @@ +/* + * 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.kms.aws; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.TestKmsClientFactoryContract; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Tests AWS KMS factory validation, initialization, and service discovery. */ +public class TestAwsKmsClientFactory extends TestKmsClientFactoryContract { + + private static final String SOURCE = "primary-aws"; + + private final AwsKmsClientFactory factory = new AwsKmsClientFactory(); + + @Override + protected KmsClientFactory factory() { + return factory; + } + + @Override + protected KmsApi expectedApi() { + return KmsApi.AWS_KMS; + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void testRejectsBlankSource(String source) { + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create(source, validProperties())); + } + + @Test + void testRejectsNullProperties() { + Assertions.assertThrows(IllegalArgumentException.class, () -> factory.create(SOURCE, null)); + } + + @Test + void testRejectsMissingRegion() { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> factory.create(SOURCE, Collections.emptyMap())); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + void testRejectsBlankRegion(String region) { + Map properties = new HashMap<>(); + properties.put(AwsKmsClientFactory.REGION, region); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create(SOURCE, properties)); + } + + @Test + void testRejectsUnknownProperty() { + Map properties = validProperties(); + properties.put("accessKeyId", "must-not-be-configured-here"); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create(SOURCE, properties)); + } + + @Test + void testRejectsNullPropertyName() { + Map properties = validProperties(); + properties.put(null, "value"); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create(SOURCE, properties)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "ftp://localhost:4566", "http:///missing-host", "not a uri"}) + void testRejectsInvalidServiceAddress(String serviceAddress) { + Map properties = validProperties(); + properties.put(AwsKmsClientFactory.SERVICE_ADDRESS, serviceAddress); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create(SOURCE, properties)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "http://user@localhost:4566", + "http://localhost:4566/path", + "http://localhost:4566?query=value", + "http://localhost:4566#fragment" + }) + void testRejectsServiceAddressThatIsNotAnOrigin(String serviceAddress) { + Map properties = validProperties(); + properties.put(AwsKmsClientFactory.SERVICE_ADDRESS, serviceAddress); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create(SOURCE, properties)); + } + + @Test + void testCreatesClientWithoutResolvingCredentialsOrContactingAws() { + try (KmsClient client = factory.create(SOURCE, validProperties())) { + Assertions.assertNotNull(client); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "http://localhost:4566", + "http://localhost:4566/", + "https://kms.us-west-2.amazonaws.com" + }) + void testCreatesClientWithServiceAddress(String serviceAddress) { + Map properties = validProperties(); + properties.put(AwsKmsClientFactory.SERVICE_ADDRESS, serviceAddress); + + try (KmsClient client = factory.create(SOURCE, properties)) { + Assertions.assertNotNull(client); + } + } + + @Test + void testFactoryIsDiscoverableWithServiceLoader() { + boolean discovered = false; + for (KmsClientFactory candidate : ServiceLoader.load(KmsClientFactory.class)) { + if (candidate.getClass() == AwsKmsClientFactory.class) { + discovered = true; + } + } + + Assertions.assertTrue(discovered); + } + + private static Map validProperties() { + Map properties = new HashMap<>(); + properties.put(AwsKmsClientFactory.REGION, "us-west-2"); + properties.put(AwsKmsClientFactory.CREDENTIAL_METHOD, "default"); + return properties; + } +} diff --git a/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/AwsKmsLiveIT.java b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/AwsKmsLiveIT.java new file mode 100644 index 00000000000..d16f0b8e385 --- /dev/null +++ b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/AwsKmsLiveIT.java @@ -0,0 +1,89 @@ +/* + * 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.kms.aws.integration.test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.kms.aws.AwsKmsClientFactory; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Optional read-only integration test against a live AWS KMS key. */ +class AwsKmsLiveIT { + + private static final Logger LOG = LoggerFactory.getLogger(AwsKmsLiveIT.class); + + private static final String ENV_REGION = "GRAVITINO_AWS_KMS_LIVE_REGION"; + private static final String ENV_KEY_ID = "GRAVITINO_AWS_KMS_LIVE_KEY_ID"; + private static final String SOURCE = "live-aws-kms"; + + private static String region; + private static String keyId; + + @BeforeAll + static void requireExplicitLiveFixture() { + region = System.getenv(ENV_REGION); + keyId = System.getenv(ENV_KEY_ID); + + List missing = new ArrayList<>(); + if (StringUtils.isBlank(region)) { + missing.add(ENV_REGION); + } + if (StringUtils.isBlank(keyId)) { + missing.add(ENV_KEY_ID); + } + if (!missing.isEmpty()) { + String message = + "Skipping live AWS KMS IT; missing required environment variables: " + + String.join(", ", missing); + LOG.warn(message); + Assumptions.assumeTrue(false, message); + } + } + + @Test + void testDescribesConfiguredKeyWithoutCryptographicOperations() { + AwsKmsClientFactory factory = new AwsKmsClientFactory(); + KmsReference reference = new KmsReference(KmsApi.AWS_KMS, SOURCE, keyId); + + try (KmsClient client = + factory.create( + SOURCE, + Map.of( + AwsKmsClientFactory.REGION, + region, + AwsKmsClientFactory.CREDENTIAL_METHOD, + "default"))) { + KmsKeyProperties properties = client.getKeyProperties(reference); + + Assertions.assertEquals(reference, properties.reference()); + Assertions.assertTrue(properties.present(), "The configured live AWS KMS key must exist"); + } + } +} diff --git a/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/AwsKmsLocalStackIT.java b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/AwsKmsLocalStackIT.java new file mode 100644 index 00000000000..8730fccffd1 --- /dev/null +++ b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/AwsKmsLocalStackIT.java @@ -0,0 +1,269 @@ +/* + * 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.kms.aws.integration.test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.commons.lang3.StringUtils; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.kms.aws.AwsKmsClientFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.kms.model.CreateAliasRequest; +import software.amazon.awssdk.services.kms.model.CreateKeyRequest; +import software.amazon.awssdk.services.kms.model.DisableKeyRequest; +import software.amazon.awssdk.services.kms.model.KeyMetadata; +import software.amazon.awssdk.services.kms.model.KeySpec; +import software.amazon.awssdk.services.kms.model.KeyUsageType; + +/** Integration tests for AWS KMS inspection against an isolated LocalStack KMS fixture. */ +@Tag("gravitino-docker-test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AwsKmsLocalStackIT { + + private static final Logger LOG = LoggerFactory.getLogger(AwsKmsLocalStackIT.class); + + private static final String ENV_LOCALSTACK_IMAGE = "GRAVITINO_AWS_KMS_LOCALSTACK_IMAGE"; + private static final String ENV_LOCALSTACK_AUTH_TOKEN = "LOCALSTACK_AUTH_TOKEN"; + private static final String AWS_ACCESS_KEY_ID_PROPERTY = "aws.accessKeyId"; + private static final String AWS_SECRET_ACCESS_KEY_PROPERTY = "aws.secretAccessKey"; + private static final String REGION = "us-east-1"; + private static final String SOURCE = "localstack-kms"; + + private LocalStackContainer localStack; + private software.amazon.awssdk.services.kms.KmsClient fixtureClient; + private KmsClient client; + private String previousAccessKeyId; + private String previousSecretAccessKey; + private boolean installedSystemCredentials; + private KeyMetadata symmetricKey; + private KeyMetadata asymmetricKey; + private KeyMetadata signingKey; + private KeyMetadata disabledKey; + private String aliasName; + private String aliasArn; + + @BeforeAll + void startLocalStackAndCreateFixtures() { + String image = System.getenv(ENV_LOCALSTACK_IMAGE); + String authToken = System.getenv(ENV_LOCALSTACK_AUTH_TOKEN); + abortWhenFixtureIsNotConfigured(image, authToken); + + DockerImageName imageName = pinnedLocalStackImage(image); + localStack = + new LocalStackContainer(imageName) + .withServices(LocalStackContainer.Service.KMS) + .withEnv(ENV_LOCALSTACK_AUTH_TOKEN, authToken) + .withEnv("ACTIVATE_PRO", "1") + .withEnv("AWS_DEFAULT_REGION", REGION); + localStack.start(); + + fixtureClient = + software.amazon.awssdk.services.kms.KmsClient.builder() + .endpointOverride(localStack.getEndpointOverride(LocalStackContainer.Service.KMS)) + .region(Region.of(REGION)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + localStack.getAccessKey(), localStack.getSecretKey()))) + .build(); + createFixtures(); + installDefaultChainCredentials(); + + Map properties = new HashMap<>(); + properties.put(AwsKmsClientFactory.REGION, REGION); + properties.put(AwsKmsClientFactory.CREDENTIAL_METHOD, "default"); + properties.put( + AwsKmsClientFactory.SERVICE_ADDRESS, + localStack.getEndpointOverride(LocalStackContainer.Service.KMS).toString()); + client = new AwsKmsClientFactory().create(SOURCE, properties); + } + + @AfterAll + void cleanUp() { + try { + if (client != null) { + client.close(); + } + } finally { + try { + if (fixtureClient != null) { + fixtureClient.close(); + } + } finally { + restoreDefaultChainCredentials(); + if (localStack != null) { + localStack.stop(); + } + } + } + } + + @Test + void testInspectsKeyByEveryAwsSupportedReferenceForm() { + List keyIdentifiers = + Arrays.asList(symmetricKey.keyId(), symmetricKey.arn(), aliasName, aliasArn); + + for (String keyIdentifier : keyIdentifiers) { + KmsReference reference = new KmsReference(KmsApi.AWS_KMS, SOURCE, keyIdentifier); + KmsKeyProperties properties = client.getKeyProperties(reference); + + Assertions.assertEquals(reference, properties.reference()); + Assertions.assertTrue(properties.present()); + Assertions.assertTrue(properties.enabled()); + Assertions.assertTrue(properties.supportsWrapping()); + Assertions.assertTrue(properties.supportsUnwrapping()); + } + } + + @Test + void testNormalizesKeySpecUsageAndState() { + KmsKeyProperties asymmetric = properties(asymmetricKey.keyId()); + Assertions.assertTrue(asymmetric.present()); + Assertions.assertTrue(asymmetric.enabled()); + Assertions.assertTrue(asymmetric.supportsWrapping()); + Assertions.assertTrue(asymmetric.supportsUnwrapping()); + + KmsKeyProperties signing = properties(signingKey.keyId()); + Assertions.assertTrue(signing.present()); + Assertions.assertTrue(signing.enabled()); + Assertions.assertFalse(signing.supportsWrapping()); + Assertions.assertFalse(signing.supportsUnwrapping()); + + KmsKeyProperties disabled = properties(disabledKey.keyId()); + Assertions.assertTrue(disabled.present()); + Assertions.assertFalse(disabled.enabled()); + Assertions.assertTrue(disabled.supportsWrapping()); + Assertions.assertTrue(disabled.supportsUnwrapping()); + } + + @Test + void testReportsMissingKeyWithoutFailingTheRequest() { + KmsKeyProperties missing = properties(UUID.randomUUID().toString()); + + Assertions.assertFalse(missing.present()); + Assertions.assertFalse(missing.enabled()); + Assertions.assertFalse(missing.supportsWrapping()); + Assertions.assertFalse(missing.supportsUnwrapping()); + } + + private static void abortWhenFixtureIsNotConfigured(String image, String authToken) { + List missing = new ArrayList<>(); + if (StringUtils.isBlank(image)) { + missing.add(ENV_LOCALSTACK_IMAGE); + } + if (StringUtils.isBlank(authToken)) { + missing.add(ENV_LOCALSTACK_AUTH_TOKEN); + } + if (!missing.isEmpty()) { + String message = + "Skipping AWS KMS LocalStack IT; missing required environment variables: " + + String.join(", ", missing); + LOG.warn(message); + Assumptions.assumeTrue(false, message); + } + } + + private static DockerImageName pinnedLocalStackImage(String image) { + DockerImageName imageName = DockerImageName.parse(image); + boolean officialProImage = "localstack/localstack-pro".equals(imageName.getUnversionedPart()); + boolean pinnedCalendarVersion = imageName.getVersionPart().matches("\\d{4}\\.\\d{2}\\.\\d+"); + if (!officialProImage || !pinnedCalendarVersion) { + throw new IllegalArgumentException( + String.format( + "%s must use a pinned localstack/localstack-pro:YYYY.MM.patch image, not '%s'", + ENV_LOCALSTACK_IMAGE, image)); + } + return imageName.asCompatibleSubstituteFor("localstack/localstack"); + } + + private void createFixtures() { + symmetricKey = createKey(KeySpec.SYMMETRIC_DEFAULT, KeyUsageType.ENCRYPT_DECRYPT); + asymmetricKey = createKey(KeySpec.RSA_2048, KeyUsageType.ENCRYPT_DECRYPT); + signingKey = createKey(KeySpec.RSA_2048, KeyUsageType.SIGN_VERIFY); + disabledKey = createKey(KeySpec.SYMMETRIC_DEFAULT, KeyUsageType.ENCRYPT_DECRYPT); + fixtureClient.disableKey(DisableKeyRequest.builder().keyId(disabledKey.keyId()).build()); + + aliasName = "alias/gravitino-kms-it-" + UUID.randomUUID(); + fixtureClient.createAlias( + CreateAliasRequest.builder() + .aliasName(aliasName) + .targetKeyId(symmetricKey.keyId()) + .build()); + aliasArn = symmetricKey.arn().substring(0, symmetricKey.arn().lastIndexOf(':') + 1) + aliasName; + } + + private KeyMetadata createKey(KeySpec keySpec, KeyUsageType keyUsage) { + return fixtureClient + .createKey( + CreateKeyRequest.builder() + .description("Gravitino AWS KMS LocalStack integration fixture") + .keySpec(keySpec) + .keyUsage(keyUsage) + .build()) + .keyMetadata(); + } + + private void installDefaultChainCredentials() { + previousAccessKeyId = System.getProperty(AWS_ACCESS_KEY_ID_PROPERTY); + previousSecretAccessKey = System.getProperty(AWS_SECRET_ACCESS_KEY_PROPERTY); + System.setProperty(AWS_ACCESS_KEY_ID_PROPERTY, localStack.getAccessKey()); + System.setProperty(AWS_SECRET_ACCESS_KEY_PROPERTY, localStack.getSecretKey()); + installedSystemCredentials = true; + } + + private void restoreDefaultChainCredentials() { + if (!installedSystemCredentials) { + return; + } + restoreSystemProperty(AWS_ACCESS_KEY_ID_PROPERTY, previousAccessKeyId); + restoreSystemProperty(AWS_SECRET_ACCESS_KEY_PROPERTY, previousSecretAccessKey); + } + + private static void restoreSystemProperty(String property, String previousValue) { + if (previousValue == null) { + System.clearProperty(property); + } else { + System.setProperty(property, previousValue); + } + } + + private KmsKeyProperties properties(String keyId) { + return client.getKeyProperties(new KmsReference(KmsApi.AWS_KMS, SOURCE, keyId)); + } +} diff --git a/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/README.md b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/README.md new file mode 100644 index 00000000000..7186d9c1ac9 --- /dev/null +++ b/bundles/aws/src/test/java/org/apache/gravitino/kms/aws/integration/test/README.md @@ -0,0 +1,118 @@ + + +# AWS KMS integration tests + +The AWS bundle has two KMS integration-test modes. `AwsKmsLocalStackIT` creates disposable KMS +fixtures in a LocalStack container. `AwsKmsLiveIT` performs one read-only `DescribeKey` request +against an explicitly selected AWS key. Neither test sends key material to Gravitino, and the live +test never calls an encrypt, decrypt, wrap, unwrap, create, update, disable, or delete API. + +## Unit and contract tests + +Run the tests that require no external backend: + +```shell +./gradlew :bundles:aws:test -PskipITs +``` + +`-PskipITs` excludes `**/integration/test/**` in this module. Docker-tagged tests are also excluded +unless `-PskipDockerTests=false` is supplied. + +## LocalStack KMS fixture + +Prerequisites: + +- A working Docker daemon. +- An explicit, immutable `localstack/localstack-pro:YYYY.MM.patch` image tag. The current pinned + test image as of July 2026 is `localstack/localstack-pro:2026.06.2`. +- A LocalStack CI Auth Token in `LOCALSTACK_AUTH_TOKEN`. LocalStack requires CI tokens for CI + environments; a developer token must not be used there. +- Network access to pull the image and activate its license with LocalStack. + +Run only the LocalStack KMS test: + +```shell +GRAVITINO_AWS_KMS_LOCALSTACK_IMAGE=localstack/localstack-pro:2026.06.2 \ +LOCALSTACK_AUTH_TOKEN='' \ +./gradlew :bundles:aws:test \ + -PskipTests -PskipDockerTests=false \ + --tests org.apache.gravitino.kms.aws.integration.test.AwsKmsLocalStackIT +``` + +The test supplies disposable AWS credentials internally. Do not set real AWS credentials for this +fixture. It creates symmetric encryption, RSA encryption, signing-only, disabled, aliased, and +missing-key cases. It verifies key ID, key ARN, alias name, and alias ARN lookups through the +production factory and client. + +The Pro image, service availability, and Auth Token are governed by LocalStack's licensing and plan +terms, not the Apache License. Never commit, print, or place the token in a command checked into CI; +store it as a masked CI secret. See the official [Auth Token documentation][localstack-auth] and +[Docker image documentation][localstack-images]. + +## Live AWS read-only fixture + +The principal resolved by the AWS SDK default credential provider chain needs only +`kms:DescribeKey` for the selected key, permitted by its IAM policy and the key policy. No +cryptographic or mutating permission is needed. + +Required fixture variables: + +- `GRAVITINO_AWS_KMS_LIVE_REGION`: Region containing the key. +- `GRAVITINO_AWS_KMS_LIVE_KEY_ID`: key ID, key ARN, alias name, or alias ARN. + +Configure credentials through the normal AWS SDK default chain, such as `AWS_PROFILE`, IAM Identity +Center, `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN`, web identity, or an attached +container/instance role. Then run: + +```shell +AWS_PROFILE='' \ +GRAVITINO_AWS_KMS_LIVE_REGION='us-west-2' \ +GRAVITINO_AWS_KMS_LIVE_KEY_ID='alias/gravitino-read-only-test' \ +./gradlew :bundles:aws:test \ + -PskipTests \ + --tests org.apache.gravitino.kms.aws.integration.test.AwsKmsLiveIT +``` + +AWS may charge for the `DescribeKey` request according to the account's service pricing. + +## Skip and failure behavior + +- With `-PskipITs`, neither integration test is selected. +- Without `-PskipDockerTests=false`, the LocalStack test's `gravitino-docker-test` tag is excluded. +- If a selected LocalStack test lacks `GRAVITINO_AWS_KMS_LOCALSTACK_IMAGE` or + `LOCALSTACK_AUTH_TOKEN`, it logs a warning naming every missing variable and is reported as + aborted/skipped before pulling an image. +- If the live test lacks either live fixture variable, it logs the missing names and is reported as + aborted/skipped before creating a client. +- Once all required fixture variables are present, invalid image tags, Docker failures, license or + token failures, missing credentials, denied access, missing keys, and contract mismatches fail the + test. They are not converted to skips. + +## Cleanup + +The LocalStack test closes both AWS clients, restores any JVM credential system properties it +temporarily replaced, and stops/removes its container in `@AfterAll`. All keys and aliases exist only +inside that disposable container. If the JVM is forcibly terminated before cleanup, remove the +leftover container with `docker ps` and `docker rm -f `. + +The live test creates no resources and requires no cleanup. + +[localstack-auth]: https://docs.localstack.cloud/aws/getting-started/auth-token/ +[localstack-images]: https://docs.localstack.cloud/aws/customization/other-installations/docker-images/ diff --git a/bundles/azure-bundle/build.gradle.kts b/bundles/azure-bundle/build.gradle.kts index 632cc83d539..69d793f331b 100644 --- a/bundles/azure-bundle/build.gradle.kts +++ b/bundles/azure-bundle/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { } implementation(libs.azure.identity) + implementation(libs.azure.security.keyvault.keys) implementation(libs.azure.storage.file.datalake) implementation(libs.hadoop3.abs) implementation(libs.hadoop3.client.api) diff --git a/bundles/azure/README.md b/bundles/azure/README.md new file mode 100644 index 00000000000..8aadfa75928 --- /dev/null +++ b/bundles/azure/README.md @@ -0,0 +1,272 @@ + + +# Azure Key Vault KMS testing + +The Azure KMS client has deterministic unit tests backed by an in-memory Azure SDK HTTP transport +and an optional read-only integration test against a real Azure Key Vault. The live test is never +enabled implicitly. + +## Local tests + +Run the module's unit tests without discovering live integration tests: + +```shell +./gradlew :bundles:azure:test -PskipITs +``` + +The unit suite covers key URL validation, latest and pinned SDK requests, 404 handling, provider +and transport failures, time-based enabled state, capability mapping, factory configuration, and +ServiceLoader discovery. It needs neither Azure credentials nor network access. + +The module routes `-PskipITs` by excluding `**/integration/test/**`. Supplying that property always +excludes the live test, even when its opt-in environment variable is set. + +## Why the live test requires Azure + +There is no supported local Key Vault emulator. Azurite emulates Azure Storage Blob, Queue, and +Table services; it does not emulate Azure Key Vault. Do not point this test at Azurite or treat an +HTTP stub as provider integration coverage. See the [Azurite service support documentation][azurite]. + +The live test is +`org.apache.gravitino.encryption.kms.azure.integration.test.TestAzureKeyVaultKmsLiveIT`. It only +calls the Key Vault Get Key API. A successful run makes three read requests: + +1. Read one explicitly versioned key. +2. Read the latest version of the same key. +3. Verify that a reserved key name is absent. + +It does not wrap or unwrap data, retrieve private key material, or create, rotate, enable, disable, +update, delete, recover, or purge any Azure resource. + +## Live fixture contract + +The test reads these variables: + +| Variable | Required | Meaning | +| --- | --- | --- | +| `GRAVITINO_AZURE_KMS_IT` | Yes to run | Must be exactly `true`, ignoring case. | +| `GRAVITINO_AZURE_KMS_VAULT_URL` | Yes | Vault HTTPS origin, such as `https://example.vault.azure.net`. | +| `GRAVITINO_AZURE_KMS_KEY_ID` | Yes | Full, versioned key URL ending in `/keys//`. | +| `GRAVITINO_AZURE_KMS_MANAGED_IDENTITY_CLIENT_ID` | No | Client ID of a user-assigned managed identity. Do not set it for a system-assigned identity or other credential flows. | + +The configured key version and its latest version must both be present, enabled, currently valid, +and allow the `wrapKey` and `unwrapKey` operations. The reserved name +`gravitino-kms-it-absent-8bb16e12832d4dd9a6e33dc621647c31` must not exist in the fixture vault. + +### Fixture ownership and provisioning + +Use a dedicated non-production vault. A cloud/KMS fixture owner should create and own the vault, +key, RBAC assignments, rotation policy, and eventual cleanup. The CI identity must not own or +provision them. Keep the versioned key ID stable; if the fixture owner rotates the key, both the +pinned version and new latest version must remain enabled with the expected operations. + +An owner with provisioning permission can create the software-protected RSA fixture and obtain its +URLs with Azure CLI: + +```shell +export AZURE_KMS_VAULT_NAME='' +export AZURE_KMS_FIXTURE_KEY_NAME='gravitino-kms-it' + +az keyvault key create \ + --vault-name "$AZURE_KMS_VAULT_NAME" \ + --name "$AZURE_KMS_FIXTURE_KEY_NAME" \ + --kty RSA \ + --disabled false \ + --ops wrapKey unwrapKey \ + --tags owner='' purpose='gravitino-kms-it' + +export GRAVITINO_AZURE_KMS_VAULT_URL="$( + az keyvault show \ + --name "$AZURE_KMS_VAULT_NAME" \ + --query properties.vaultUri \ + --output tsv +)" +export GRAVITINO_AZURE_KMS_KEY_ID="$( + az keyvault key show \ + --vault-name "$AZURE_KMS_VAULT_NAME" \ + --name "$AZURE_KMS_FIXTURE_KEY_NAME" \ + --query key.kid \ + --output tsv +)" +``` + +`az keyvault key create` creates a new version when the name already exists. Provisioning is an +owner action, not part of the test. The accepted `--ops` values and create behavior are documented +in the [Azure CLI key reference][key-cli]. + +### Least-privilege authorization + +For an RBAC-enabled vault, assign the built-in **Key Vault Reader** role to the test principal at +the dedicated fixture-vault scope. It provides metadata reads without secret contents or private +key material and is the least-privilege built-in role needed for the present-key and absent-key +reads. Do not grant Key Vault Administrator, Key Vault Crypto Officer, or cryptographic-operation +roles to the test principal. + +```shell +export AZURE_KMS_TEST_PRINCIPAL_OBJECT_ID='' +export AZURE_KMS_VAULT_SCOPE="$( + az keyvault show \ + --name "$AZURE_KMS_VAULT_NAME" \ + --query id \ + --output tsv +)" + +az role assignment create \ + --assignee-object-id "$AZURE_KMS_TEST_PRINCIPAL_OBJECT_ID" \ + --role 'Key Vault Reader' \ + --scope "$AZURE_KMS_VAULT_SCOPE" +``` + +Use the principal's object ID, not its application/client ID, for +`AZURE_KMS_TEST_PRINCIPAL_OBJECT_ID`. Azure documents the role's data actions in the +[built-in role reference][key-vault-reader]. Allow time for a new assignment to propagate before +running the test. + +For a legacy access-policy vault, grant only `get` for keys: + +```shell +az keyvault set-policy \ + --name "$AZURE_KMS_VAULT_NAME" \ + --object-id "$AZURE_KMS_TEST_PRINCIPAL_OBJECT_ID" \ + --key-permissions get +``` + +Azure recommends RBAC for new deployments; access policies are retained here only for existing +fixtures. See the [access-policy guidance][access-policy]. + +## Authentication with `DefaultAzureCredential` + +The factory deliberately accepts no credential secrets. It builds Azure +`DefaultAzureCredential`, so configure exactly one appropriate runtime flow and assign that +identity the read role described above. The principal examples below are alternatives, not a +single combined configuration. + +Local Azure CLI login: + +```shell +az login +az account set --subscription '' +``` + +The signed-in user needs the Key Vault Reader assignment. `DefaultAzureCredential` can reuse the +Azure CLI login. + +Service principal with a client secret, commonly used by a protected CI secret store: + +```shell +export AZURE_TENANT_ID='' +export AZURE_CLIENT_ID='' +export AZURE_CLIENT_SECRET='' +``` + +Service principal with a certificate: + +```shell +export AZURE_TENANT_ID='' +export AZURE_CLIENT_ID='' +export AZURE_CLIENT_CERTIFICATE_PATH='' +# Set AZURE_CLIENT_CERTIFICATE_PASSWORD only when the certificate requires it. +``` + +Federated workload identity, preferred for CI systems that can issue OIDC tokens: + +```shell +export AZURE_TENANT_ID='' +export AZURE_CLIENT_ID='' +export AZURE_FEDERATED_TOKEN_FILE='' +``` + +For an Azure-hosted runner with a system-assigned managed identity, do not set credential +variables. For a user-assigned identity, attach it to the runner and set: + +```shell +export GRAVITINO_AZURE_KMS_MANAGED_IDENTITY_CLIENT_ID='' +``` + +Sovereign clouds may also require `AZURE_AUTHORITY_HOST`, and the vault URL must use that cloud's +Key Vault DNS suffix. See the official [Azure Identity Java documentation][azure-identity] and +[credential-chain guidance][credential-chain]. In CI, expose only the intended flow; stale partial +environment credentials or cached developer-tool sessions can make a credential chain select an +unexpected identity. + +## Running the live test + +After configuring one credential flow and the two fixture variables, opt in explicitly: + +```shell +export GRAVITINO_AZURE_KMS_IT=true + +./gradlew :bundles:azure:test \ + --tests 'org.apache.gravitino.encryption.kms.azure.integration.test.TestAzureKeyVaultKmsLiveIT' \ + --rerun-tasks +``` + +The repository's integration-only routing is equivalent for this module: + +```shell +./gradlew :bundles:azure:test -PskipTests --rerun-tasks +``` + +The `--rerun-tasks` option prevents Gradle from reusing a previous test result after environment +variables or Azure RBAC assignments change. + +### Skip versus fail behavior + +- With `-PskipITs`, Gradle excludes the live-test package entirely. +- Without `-PskipITs`, when `GRAVITINO_AZURE_KMS_IT` is absent or not `true`, the class logs one + warning naming any missing fixture variables and aborts through a JUnit assumption. +- Once `GRAVITINO_AZURE_KMS_IT=true`, missing fixture variables fail setup. Malformed URLs, missing + credentials, authentication failures, authorization failures, disabled or expired fixtures, + unexpected capabilities, and connectivity or provider errors also fail the run. They are never + converted into skips. + +This split makes ordinary builds safe while ensuring an opted-in CI job cannot silently pass with +a broken cloud configuration. + +## Cost and cleanup + +The live test creates no resources and performs three Key Vault read operations per successful +run. Azure may charge for Key Vault operations, and price varies by region, agreement, and key +type; consult the current [Key Vault pricing page][pricing]. A standard software RSA key avoids the +additional characteristics of HSM-backed fixtures. + +Because the fixture is persistent, the test performs no automatic cleanup. The fixture owner +should periodically review its tags, RBAC assignment, version lifecycle, and usage. When the test +environment is retired, the owner—not CI—should remove the role assignment and delete the key or +dedicated resource group according to organizational retention rules. Key Vault soft-delete and +purge-protection policies can keep deleted resources recoverable and reserve names, so cleanup may +not be immediate. + +## Azure SDK version alignment + +This module pins `azure-security-keyvault-keys` to `4.8.6`. That release resolves with the existing +`azure-identity` `1.13.1` and `azure-storage-file-datalake` `12.20.0` dependencies on +`azure-core` `1.50.0` and `azure-core-http-netty` `1.15.2`. Keeping one Azure Core/HTTP stack avoids +class and method incompatibilities inside the shared Azure bundle. Do not upgrade the Key Vault +Keys client alone; evaluate and upgrade the Azure identity, storage, core, and HTTP dependencies as +one family, then rebuild the shaded bundle and rerun both deterministic and live tests. + +[access-policy]: https://learn.microsoft.com/en-us/azure/key-vault/general/assign-access-policy +[azure-identity]: https://learn.microsoft.com/en-us/java/api/overview/azure/identity-readme?view=azure-java-stable +[azurite]: https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite +[credential-chain]: https://learn.microsoft.com/en-us/azure/developer/java/sdk/authentication/credential-chains +[key-cli]: https://learn.microsoft.com/en-us/cli/azure/keyvault/key?view=azure-cli-latest +[key-vault-reader]: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-reader +[pricing]: https://azure.microsoft.com/en-us/pricing/details/key-vault/ diff --git a/bundles/azure/build.gradle.kts b/bundles/azure/build.gradle.kts index 031c7c5e8cd..4879d620421 100644 --- a/bundles/azure/build.gradle.kts +++ b/bundles/azure/build.gradle.kts @@ -40,16 +40,30 @@ dependencies { implementation(libs.guava) compileOnly(libs.azure.identity) + compileOnly(libs.azure.security.keyvault.keys) compileOnly(libs.azure.storage.file.datalake) compileOnly(libs.hadoop3.abs) compileOnly(libs.hadoop3.client.api) testImplementation(libs.azure.identity) + testImplementation(libs.azure.security.keyvault.keys) + testImplementation(testFixtures(project(":api"))) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.slf4j.simple) } tasks.compileJava { dependsOn(":catalogs:catalog-fileset:runtimeJars") } + +tasks.test { + if (project.hasProperty("skipITs")) { + exclude("**/integration/test/**") + } else { + testLogging.showStandardStreams = true + systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn") + systemProperty("org.slf4j.simpleLogger.log.com.azure", "off") + } +} diff --git a/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKeyIdentifier.java b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKeyIdentifier.java new file mode 100644 index 00000000000..1bd6ea12d8e --- /dev/null +++ b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKeyIdentifier.java @@ -0,0 +1,114 @@ +/* + * 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.azure; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.regex.Pattern; +import javax.annotation.Nullable; + +final class AzureKeyVaultKeyIdentifier { + + private static final Pattern KEY_NAME = Pattern.compile("[A-Za-z0-9-]{1,127}"); + private static final Pattern KEY_VERSION = Pattern.compile("[A-Za-z0-9-]+"); + + private final String name; + @Nullable private final String version; + + private AzureKeyVaultKeyIdentifier(String name, @Nullable String version) { + this.name = name; + this.version = version; + } + + static AzureKeyVaultKeyIdentifier parse(String keyId, URI vaultUri) { + URI keyUri; + try { + keyUri = new URI(keyId); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Azure Key Vault key ID must be a valid URI", e); + } + + validateAbsoluteKeyUri(keyUri); + if (!sameOrigin(vaultUri, keyUri)) { + throw new IllegalArgumentException( + String.format("Azure Key Vault key ID must belong to configured vault '%s'", vaultUri)); + } + + String[] pathSegments = keyUri.getRawPath().split("/", -1); + if ((pathSegments.length != 3 && pathSegments.length != 4) + || !pathSegments[0].isEmpty() + || !"keys".equalsIgnoreCase(pathSegments[1]) + || !KEY_NAME.matcher(pathSegments[2]).matches() + || (pathSegments.length == 4 && !KEY_VERSION.matcher(pathSegments[3]).matches())) { + throw new IllegalArgumentException( + "Azure Key Vault key ID must have the form https:///keys/[/]"); + } + + return new AzureKeyVaultKeyIdentifier( + pathSegments[2], pathSegments.length == 4 ? pathSegments[3] : null); + } + + static URI validateVaultUri(URI vaultUri) { + if (vaultUri == null + || !vaultUri.isAbsolute() + || !"https".equalsIgnoreCase(vaultUri.getScheme()) + || vaultUri.getHost() == null + || vaultUri.getUserInfo() != null + || vaultUri.getPort() != -1 + || vaultUri.getRawQuery() != null + || vaultUri.getRawFragment() != null + || !(vaultUri.getRawPath().isEmpty() || "/".equals(vaultUri.getRawPath()))) { + throw new IllegalArgumentException( + "Azure Key Vault URI must be an HTTPS origin without credentials, a port, query, or fragment"); + } + return vaultUri; + } + + String name() { + return name; + } + + @Nullable + String version() { + return version; + } + + private static void validateAbsoluteKeyUri(URI keyUri) { + if (!keyUri.isAbsolute() + || !"https".equalsIgnoreCase(keyUri.getScheme()) + || keyUri.getHost() == null + || keyUri.getUserInfo() != null + || keyUri.getPort() != -1 + || keyUri.getRawQuery() != null + || keyUri.getRawFragment() != null) { + throw new IllegalArgumentException( + "Azure Key Vault key ID must be an HTTPS URL without credentials, a port, query, or fragment"); + } + } + + private static boolean sameOrigin(URI first, URI second) { + return first + .getScheme() + .toLowerCase(Locale.ROOT) + .equals(second.getScheme().toLowerCase(Locale.ROOT)) + && first.getHost().equalsIgnoreCase(second.getHost()) + && first.getPort() == second.getPort(); + } +} diff --git a/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsClient.java b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsClient.java new file mode 100644 index 00000000000..336d854f43e --- /dev/null +++ b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsClient.java @@ -0,0 +1,138 @@ +/* + * 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.azure; + +import com.azure.core.exception.AzureException; +import com.azure.core.exception.ClientAuthenticationException; +import com.azure.core.exception.ResourceNotFoundException; +import com.azure.security.keyvault.keys.KeyClient; +import com.azure.security.keyvault.keys.models.KeyOperation; +import com.azure.security.keyvault.keys.models.KeyProperties; +import com.azure.security.keyvault.keys.models.KeyVaultKey; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsAuthenticationException; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +final class AzureKeyVaultKmsClient implements KmsClient { + + private final String source; + private final URI vaultUri; + private final KeyClient keyClient; + private final Clock clock; + + AzureKeyVaultKmsClient(String source, URI vaultUri, KeyClient keyClient) { + this(source, vaultUri, keyClient, Clock.systemUTC()); + } + + AzureKeyVaultKmsClient(String source, URI vaultUri, KeyClient keyClient, Clock clock) { + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("Azure Key Vault source cannot be blank"); + } + if (keyClient == null || clock == null) { + throw new IllegalArgumentException("Azure Key Vault SDK client and clock cannot be null"); + } + + this.source = source; + this.vaultUri = AzureKeyVaultKeyIdentifier.validateVaultUri(vaultUri); + this.keyClient = keyClient; + this.clock = clock; + } + + @Override + public KmsKeyProperties getKeyProperties(KmsReference reference) { + validateReference(reference); + AzureKeyVaultKeyIdentifier identifier = + AzureKeyVaultKeyIdentifier.parse(reference.keyId(), vaultUri); + + try { + KeyVaultKey key = keyClient.getKey(identifier.name(), identifier.version()); + return normalize(reference, key); + } catch (ResourceNotFoundException e) { + return new AzureKeyVaultKmsKeyProperties(reference, false, false, false, false); + } catch (ConnectionFailedException e) { + throw e; + } catch (ClientAuthenticationException e) { + throw new KmsAuthenticationException( + e, "Azure Key Vault rejected credentials for source '%s'", source); + } catch (AzureException e) { + throw connectionFailure(reference, e); + } catch (RuntimeException e) { + throw connectionFailure(reference, e); + } + } + + private void validateReference(KmsReference reference) { + if (reference == null) { + throw new IllegalArgumentException("Azure Key Vault reference cannot be null"); + } + if (reference.api() != KmsApi.AZURE_KEY_VAULT) { + throw new IllegalArgumentException( + String.format("KMS reference API must be '%s'", KmsApi.AZURE_KEY_VAULT.wireValue())); + } + if (!source.equals(reference.source())) { + throw new IllegalArgumentException( + String.format("KMS reference source must be '%s'", source)); + } + } + + private KmsKeyProperties normalize(KmsReference reference, KeyVaultKey key) { + if (key == null || key.getProperties() == null) { + throw new ConnectionFailedException( + "Azure Key Vault returned incomplete properties for key '%s'", reference.keyId()); + } + + KeyProperties properties = key.getProperties(); + List operations = key.getKeyOperations(); + boolean supportsWrapping = operations != null && operations.contains(KeyOperation.WRAP_KEY); + boolean supportsUnwrapping = operations != null && operations.contains(KeyOperation.UNWRAP_KEY); + return new AzureKeyVaultKmsKeyProperties( + reference, + true, + isEffectivelyEnabled(properties, clock.instant()), + supportsWrapping, + supportsUnwrapping); + } + + private static boolean isEffectivelyEnabled(KeyProperties properties, Instant now) { + if (!Boolean.TRUE.equals(properties.isEnabled())) { + return false; + } + + OffsetDateTime notBefore = properties.getNotBefore(); + if (notBefore != null && now.isBefore(notBefore.toInstant())) { + return false; + } + + OffsetDateTime expiresOn = properties.getExpiresOn(); + return expiresOn == null || now.isBefore(expiresOn.toInstant()); + } + + private ConnectionFailedException connectionFailure(KmsReference reference, RuntimeException e) { + return new ConnectionFailedException( + e, "Failed to inspect Azure Key Vault key '%s' for source '%s'", reference.keyId(), source); + } +} diff --git a/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsClientFactory.java b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsClientFactory.java new file mode 100644 index 00000000000..959f67d63c3 --- /dev/null +++ b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsClientFactory.java @@ -0,0 +1,131 @@ +/* + * 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.azure; + +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.security.keyvault.keys.KeyClient; +import com.azure.security.keyvault.keys.KeyClientBuilder; +import com.google.common.collect.ImmutableSet; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Set; +import org.apache.gravitino.annotation.DeveloperApi; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsConfigurationException; + +/** Creates key-inspection clients for Microsoft Azure Key Vault. */ +@DeveloperApi +public final class AzureKeyVaultKmsClientFactory implements KmsClientFactory { + + static final String VAULT_URL = "endpoint.vaultUrl"; + static final String CREDENTIAL_METHOD = "credential.method"; + static final String MANAGED_IDENTITY_CLIENT_ID = "credential.managedIdentityClientId"; + + private static final Set SUPPORTED_PROPERTIES = + ImmutableSet.of(VAULT_URL, CREDENTIAL_METHOD, MANAGED_IDENTITY_CLIENT_ID); + + /** Creates an Azure Key Vault KMS client factory. */ + public AzureKeyVaultKmsClientFactory() {} + + /** + * Returns the Azure Key Vault API. + * + * @return the Azure Key Vault API + */ + @Override + public KmsApi api() { + return KmsApi.AZURE_KEY_VAULT; + } + + /** + * Creates a client using Azure's default credential chain. + * + *

The required {@code endpoint.vaultUrl} property is the vault HTTPS origin. The {@code + * credential.method} must be {@code default}. The optional {@code + * credential.managedIdentityClientId} selects a user-assigned managed identity. + * + * @param source logical name of the configured Azure Key Vault source + * @param properties Azure Key Vault client properties + * @return the configured key-inspection client + * @throws IllegalArgumentException if the source or properties are invalid + */ + @Override + public KmsClient create(String source, Map properties) { + validateSource(source); + if (properties == null) { + throw new KmsConfigurationException("Azure Key Vault properties cannot be null"); + } + + for (String property : properties.keySet()) { + if (!SUPPORTED_PROPERTIES.contains(property)) { + throw new KmsConfigurationException("Unsupported Azure Key Vault property '%s'", property); + } + } + + URI vaultUri = parseVaultUri(requiredProperty(properties, VAULT_URL)); + String credentialMethod = requiredProperty(properties, CREDENTIAL_METHOD); + if (!"default".equals(credentialMethod)) { + throw new KmsConfigurationException( + "Unsupported Azure Key Vault credential method: %s", credentialMethod); + } + DefaultAzureCredentialBuilder credentialBuilder = new DefaultAzureCredentialBuilder(); + String managedIdentityClientId = properties.get(MANAGED_IDENTITY_CLIENT_ID); + if (managedIdentityClientId != null) { + if (managedIdentityClientId.trim().isEmpty()) { + throw new KmsConfigurationException( + "Azure Key Vault property '%s' cannot be blank", MANAGED_IDENTITY_CLIENT_ID); + } + credentialBuilder.managedIdentityClientId(managedIdentityClientId.trim()); + } + + KeyClient keyClient = + new KeyClientBuilder() + .vaultUrl(vaultUri.toString()) + .credential(credentialBuilder.build()) + .buildClient(); + return new AzureKeyVaultKmsClient(source, vaultUri, keyClient); + } + + private static void validateSource(String source) { + if (source == null || source.trim().isEmpty()) { + throw new KmsConfigurationException("Azure Key Vault source cannot be blank"); + } + } + + private static String requiredProperty(Map properties, String property) { + String value = properties.get(property); + if (value == null || value.trim().isEmpty()) { + throw new KmsConfigurationException("Azure Key Vault property '%s' is required", property); + } + return value.trim(); + } + + private static URI parseVaultUri(String value) { + URI vaultUri; + try { + vaultUri = new URI(value); + } catch (URISyntaxException e) { + throw new KmsConfigurationException(e, "Azure Key Vault URL must be a valid URI"); + } + return AzureKeyVaultKeyIdentifier.validateVaultUri(vaultUri); + } +} diff --git a/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsKeyProperties.java b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsKeyProperties.java new file mode 100644 index 00000000000..91d909da5a4 --- /dev/null +++ b/bundles/azure/src/main/java/org/apache/gravitino/encryption/kms/azure/AzureKeyVaultKmsKeyProperties.java @@ -0,0 +1,69 @@ +/* + * 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.azure; + +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; + +final class AzureKeyVaultKmsKeyProperties implements KmsKeyProperties { + + private final KmsReference reference; + private final boolean present; + private final boolean enabled; + private final boolean supportsWrapping; + private final boolean supportsUnwrapping; + + AzureKeyVaultKmsKeyProperties( + KmsReference reference, + boolean present, + boolean enabled, + boolean supportsWrapping, + boolean supportsUnwrapping) { + this.reference = reference; + this.present = present; + this.enabled = enabled; + this.supportsWrapping = supportsWrapping; + this.supportsUnwrapping = supportsUnwrapping; + } + + @Override + public KmsReference reference() { + return reference; + } + + @Override + public boolean present() { + return present; + } + + @Override + public boolean enabled() { + return enabled; + } + + @Override + public boolean supportsWrapping() { + return supportsWrapping; + } + + @Override + public boolean supportsUnwrapping() { + return supportsUnwrapping; + } +} diff --git a/bundles/azure/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory b/bundles/azure/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory new file mode 100644 index 00000000000..6619c9e5e03 --- /dev/null +++ b/bundles/azure/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory @@ -0,0 +1,20 @@ +# +# 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. +# + +org.apache.gravitino.encryption.kms.azure.AzureKeyVaultKmsClientFactory diff --git a/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/TestAzureKeyVaultKmsClient.java b/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/TestAzureKeyVaultKmsClient.java new file mode 100644 index 00000000000..709915802f8 --- /dev/null +++ b/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/TestAzureKeyVaultKmsClient.java @@ -0,0 +1,384 @@ +/* + * 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.azure; + +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.FixedDelayOptions; +import com.azure.core.http.policy.RetryOptions; +import com.azure.security.keyvault.keys.KeyClient; +import com.azure.security.keyvault.keys.KeyClientBuilder; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import javax.annotation.Nullable; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientContract; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class TestAzureKeyVaultKmsClient extends TestKmsClientContract { + + private static final String SOURCE = "azure-primary"; + private static final String VAULT_URL = "https://example.vault.azure.net"; + private static final String VERSION = "0123456789abcdef0123456789abcdef"; + private static final Instant NOW = Instant.parse("2030-01-01T00:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final TokenCredential CREDENTIAL = + request -> + Mono.just( + new AccessToken( + "test-token", + OffsetDateTime.ofInstant(NOW.plus(Duration.ofDays(1)), ZoneOffset.UTC))); + + private final RecordingHttpClient httpClient = new RecordingHttpClient(this::defaultResponse); + private final AzureKeyVaultKmsClient client = createClient(httpClient, CLOCK); + + @Override + protected KmsClient client() { + return client; + } + + @Override + protected KmsReference usableKey() { + return reference("usable"); + } + + @Override + protected KmsReference missingKey() { + return reference("missing"); + } + + @Test + void testRequestsLatestAndPinnedVersionsThroughSdk() { + KmsReference latest = reference("usable"); + KmsReference pinned = reference("usable", VERSION); + + client.getKeyProperties(latest); + Assertions.assertEquals("/keys/usable/", httpClient.lastRequest().getUrl().getPath()); + + client.getKeyProperties(pinned); + Assertions.assertEquals("/keys/usable/" + VERSION, httpClient.lastRequest().getUrl().getPath()); + Assertions.assertTrue(httpClient.lastRequest().getUrl().getQuery().contains("api-version=")); + } + + @Test + void testPreservesExactRequestedReference() { + KmsReference reference = reference("usable", VERSION); + + Assertions.assertSame(reference, client.getKeyProperties(reference).reference()); + } + + @Test + void testNormalizesEffectiveEnabledState() { + Assertions.assertTrue(properties(true, null, null, "\"wrapKey\"").enabled()); + Assertions.assertFalse(properties(false, null, null, "\"wrapKey\"").enabled()); + Assertions.assertFalse(properties(null, null, null, "\"wrapKey\"").enabled()); + Assertions.assertFalse(properties(true, NOW.plusSeconds(1), null, "\"wrapKey\"").enabled()); + Assertions.assertTrue(properties(true, NOW, null, "\"wrapKey\"").enabled()); + Assertions.assertFalse(properties(true, null, NOW, "\"wrapKey\"").enabled()); + Assertions.assertTrue(properties(true, null, NOW.plusSeconds(1), "\"wrapKey\"").enabled()); + } + + @Test + void testMapsOnlyNativeWrapCapabilities() { + KmsKeyProperties encryptDecrypt = properties(true, null, null, "\"encrypt\",\"decrypt\""); + Assertions.assertFalse(encryptDecrypt.supportsWrapping()); + Assertions.assertFalse(encryptDecrypt.supportsUnwrapping()); + + KmsKeyProperties wrap = properties(true, null, null, "\"wrapKey\""); + Assertions.assertTrue(wrap.supportsWrapping()); + Assertions.assertFalse(wrap.supportsUnwrapping()); + + KmsKeyProperties unwrap = properties(true, null, null, "\"unwrapKey\""); + Assertions.assertFalse(unwrap.supportsWrapping()); + Assertions.assertTrue(unwrap.supportsUnwrapping()); + } + + @Test + void testMissingKeyHasAllCapabilitiesDisabled() { + KmsKeyProperties properties = client.getKeyProperties(missingKey()); + + Assertions.assertFalse(properties.present()); + Assertions.assertFalse(properties.enabled()); + Assertions.assertFalse(properties.supportsWrapping()); + Assertions.assertFalse(properties.supportsUnwrapping()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 401, 403, 408, 429, 500, 503}) + void testNormalizesProviderFailures(int statusCode) { + AzureKeyVaultKmsClient failingClient = + createClient( + new RecordingHttpClient(request -> response(request, statusCode, errorJson())), CLOCK); + + Assertions.assertThrows( + ConnectionFailedException.class, () -> failingClient.getKeyProperties(reference("usable"))); + } + + @Test + void testNormalizesTransportFailure() { + AzureKeyVaultKmsClient failingClient = + createClient( + new RecordingHttpClient( + request -> { + throw new IllegalStateException("transport unavailable"); + }), + CLOCK); + + Assertions.assertThrows( + ConnectionFailedException.class, () -> failingClient.getKeyProperties(reference("usable"))); + } + + @Test + void testNormalizesMalformedSuccessfulResponse() { + AzureKeyVaultKmsClient malformedClient = + createClient( + new RecordingHttpClient(request -> response(request, 200, "{\"key\":null}")), CLOCK); + + Assertions.assertThrows( + ConnectionFailedException.class, + () -> malformedClient.getKeyProperties(reference("usable"))); + } + + @ParameterizedTest + @ValueSource( + strings = { + "usable", + "http://example.vault.azure.net/keys/usable", + "https://other.vault.azure.net/keys/usable", + "https://example.vault.azure.net/secrets/usable", + "https://example.vault.azure.net/keys", + "https://example.vault.azure.net/keys/usable/", + "https://example.vault.azure.net/keys/usable/version/extra", + "https://example.vault.azure.net/keys/usable?version=latest", + "https://example.vault.azure.net/keys/usable#fragment", + "https://user@example.vault.azure.net/keys/usable", + "https://example.vault.azure.net:443/keys/usable", + "https://example.vault.azure.net/keys/encoded%2Fname", + "https://example.vault.azure.net/keys/not_valid" + }) + void testRejectsMalformedAndCrossVaultKeyIds(String keyId) { + int requestCount = httpClient.requestCount(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> client.getKeyProperties(new KmsReference(KmsApi.AZURE_KEY_VAULT, SOURCE, keyId))); + Assertions.assertEquals(requestCount, httpClient.requestCount()); + } + + @Test + void testVaultOriginComparisonIsCaseInsensitive() { + KmsReference reference = + new KmsReference( + KmsApi.AZURE_KEY_VAULT, SOURCE, "HTTPS://EXAMPLE.VAULT.AZURE.NET/keys/usable"); + + Assertions.assertTrue(client.getKeyProperties(reference).present()); + } + + @Test + void testRejectsInvalidClientConstruction() { + KeyClient sdkClient = createSdkClient(new RecordingHttpClient(this::defaultResponse)); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new AzureKeyVaultKmsClient(" ", URI.create(VAULT_URL), sdkClient, CLOCK)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + new AzureKeyVaultKmsClient( + SOURCE, URI.create("http://example.vault.azure.net"), sdkClient, CLOCK)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + new AzureKeyVaultKmsClient(SOURCE, URI.create(VAULT_URL + "/keys"), sdkClient, CLOCK)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new AzureKeyVaultKmsClient(SOURCE, URI.create(VAULT_URL), null, CLOCK)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new AzureKeyVaultKmsClient(SOURCE, URI.create(VAULT_URL), sdkClient, null)); + } + + private KmsKeyProperties properties( + @Nullable Boolean enabled, + @Nullable Instant notBefore, + @Nullable Instant expiresOn, + String operations) { + RecordingHttpClient transport = + new RecordingHttpClient( + request -> + response( + request, 200, keyJson("temporal", enabled, notBefore, expiresOn, operations))); + return createClient(transport, CLOCK).getKeyProperties(reference("temporal")); + } + + private HttpResponse defaultResponse(HttpRequest request) { + if (request.getUrl().getPath().contains("/missing")) { + return response(request, 404, errorJson()); + } + return response(request, 200, keyJson("usable", true, null, null, "\"wrapKey\",\"unwrapKey\"")); + } + + private static AzureKeyVaultKmsClient createClient(HttpClient httpClient, Clock clock) { + return new AzureKeyVaultKmsClient( + SOURCE, URI.create(VAULT_URL), createSdkClient(httpClient), clock); + } + + private static KeyClient createSdkClient(HttpClient httpClient) { + return new KeyClientBuilder() + .vaultUrl(VAULT_URL) + .credential(CREDENTIAL) + .httpClient(httpClient) + .retryOptions(new RetryOptions(new FixedDelayOptions(0, Duration.ZERO))) + .buildClient(); + } + + private static KmsReference reference(String name) { + return new KmsReference(KmsApi.AZURE_KEY_VAULT, SOURCE, VAULT_URL + "/keys/" + name); + } + + private static KmsReference reference(String name, String version) { + return new KmsReference( + KmsApi.AZURE_KEY_VAULT, SOURCE, VAULT_URL + "/keys/" + name + "/" + version); + } + + private static String keyJson( + String name, + @Nullable Boolean enabled, + @Nullable Instant notBefore, + @Nullable Instant expiresOn, + String operations) { + String enabledValue = enabled == null ? "null" : enabled.toString(); + String notBeforeField = notBefore == null ? "" : ",\"nbf\":" + notBefore.getEpochSecond(); + String expiresOnField = expiresOn == null ? "" : ",\"exp\":" + expiresOn.getEpochSecond(); + return String.format( + "{\"key\":{\"kid\":\"%s/keys/%s/%s\",\"kty\":\"RSA\"," + + "\"key_ops\":[%s],\"n\":\"AQAB\",\"e\":\"AQAB\"}," + + "\"attributes\":{\"enabled\":%s,\"recoveryLevel\":\"Recoverable\"%s%s}}", + VAULT_URL, name, VERSION, operations, enabledValue, notBeforeField, expiresOnField); + } + + private static String errorJson() { + return "{\"error\":{\"code\":\"KeyNotFound\",\"message\":\"not found\"}}"; + } + + private static HttpResponse response(HttpRequest request, int statusCode, String body) { + return new StaticHttpResponse(request, statusCode, body); + } + + private static final class RecordingHttpClient implements HttpClient { + + private final Function responseFactory; + private final List requests = new ArrayList<>(); + + private RecordingHttpClient(Function responseFactory) { + this.responseFactory = responseFactory; + } + + @Override + public Mono send(HttpRequest request) { + requests.add(request); + return Mono.fromSupplier(() -> responseFactory.apply(request)); + } + + private HttpRequest lastRequest() { + return requests.get(requests.size() - 1); + } + + private int requestCount() { + return requests.size(); + } + } + + private static final class StaticHttpResponse extends HttpResponse { + + private final int statusCode; + private final byte[] body; + private final HttpHeaders headers; + + private StaticHttpResponse(HttpRequest request, int statusCode, String body) { + super(request); + this.statusCode = statusCode; + this.body = body.getBytes(StandardCharsets.UTF_8); + this.headers = + new HttpHeaders() + .set(HttpHeaderName.CONTENT_TYPE, "application/json") + .set(HttpHeaderName.CONTENT_LENGTH, Integer.toString(this.body.length)); + } + + @Override + public int getStatusCode() { + return statusCode; + } + + @SuppressWarnings("deprecation") + @Override + public String getHeaderValue(String name) { + return headers.getValue(name); + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + + @Override + public Flux getBody() { + return Flux.defer(() -> Flux.just(ByteBuffer.wrap(body))); + } + + @Override + public Mono getBodyAsByteArray() { + return Mono.just(body.clone()); + } + + @Override + public Mono getBodyAsString() { + return Mono.just(new String(body, StandardCharsets.UTF_8)); + } + + @Override + public Mono getBodyAsString(Charset charset) { + return Mono.just(new String(body, charset)); + } + } +} diff --git a/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/TestAzureKeyVaultKmsClientFactory.java b/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/TestAzureKeyVaultKmsClientFactory.java new file mode 100644 index 00000000000..bf9db215861 --- /dev/null +++ b/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/TestAzureKeyVaultKmsClientFactory.java @@ -0,0 +1,166 @@ +/* + * 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.azure; + +import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.TestKmsClientFactoryContract; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class TestAzureKeyVaultKmsClientFactory extends TestKmsClientFactoryContract { + + private static final String VAULT_URL = "https://example.vault.azure.net"; + + private final AzureKeyVaultKmsClientFactory factory = new AzureKeyVaultKmsClientFactory(); + + @Override + protected KmsClientFactory factory() { + return factory; + } + + @Override + protected KmsApi expectedApi() { + return KmsApi.AZURE_KEY_VAULT; + } + + @Test + void testCreatesClientWithDefaultCredentialChain() { + KmsClient client = + factory.create( + "primary", + ImmutableMap.of( + AzureKeyVaultKmsClientFactory.VAULT_URL, + VAULT_URL, + AzureKeyVaultKmsClientFactory.CREDENTIAL_METHOD, + "default")); + + Assertions.assertInstanceOf(AzureKeyVaultKmsClient.class, client); + } + + @Test + void testCreatesClientForUserAssignedManagedIdentity() { + KmsClient client = + factory.create( + "primary", + ImmutableMap.of( + AzureKeyVaultKmsClientFactory.VAULT_URL, + VAULT_URL + "/", + AzureKeyVaultKmsClientFactory.CREDENTIAL_METHOD, + "default", + AzureKeyVaultKmsClientFactory.MANAGED_IDENTITY_CLIENT_ID, + "client-id")); + + Assertions.assertInstanceOf(AzureKeyVaultKmsClient.class, client); + } + + @Test + void testLoadsFactoryThroughServiceLoader() { + int matchingFactories = 0; + for (KmsClientFactory loadedFactory : ServiceLoader.load(KmsClientFactory.class)) { + if (loadedFactory instanceof AzureKeyVaultKmsClientFactory) { + matchingFactories++; + } + } + + Assertions.assertEquals(1, matchingFactories); + } + + @Test + void testRejectsMissingAndUnsupportedProperties() { + Assertions.assertThrows(IllegalArgumentException.class, () -> factory.create("primary", null)); + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create("primary", ImmutableMap.of())); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + factory.create( + "primary", ImmutableMap.of(AzureKeyVaultKmsClientFactory.VAULT_URL, " "))); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + factory.create( + "primary", + ImmutableMap.of( + AzureKeyVaultKmsClientFactory.VAULT_URL, + VAULT_URL, + "clientSecret", + "must-not-be-accepted"))); + + Map properties = new HashMap<>(); + properties.put(AzureKeyVaultKmsClientFactory.VAULT_URL, VAULT_URL); + properties.put(AzureKeyVaultKmsClientFactory.CREDENTIAL_METHOD, "default"); + properties.put(null, "value"); + Assertions.assertThrows( + IllegalArgumentException.class, () -> factory.create("primary", properties)); + } + + @Test + void testRejectsInvalidSourcesAndManagedIdentity() { + Map properties = + ImmutableMap.of( + AzureKeyVaultKmsClientFactory.VAULT_URL, + VAULT_URL, + AzureKeyVaultKmsClientFactory.CREDENTIAL_METHOD, + "default"); + + Assertions.assertThrows(IllegalArgumentException.class, () -> factory.create(null, properties)); + Assertions.assertThrows(IllegalArgumentException.class, () -> factory.create(" ", properties)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + factory.create( + "primary", + ImmutableMap.of( + AzureKeyVaultKmsClientFactory.VAULT_URL, + VAULT_URL, + AzureKeyVaultKmsClientFactory.CREDENTIAL_METHOD, + "default", + AzureKeyVaultKmsClientFactory.MANAGED_IDENTITY_CLIENT_ID, + " "))); + } + + @ParameterizedTest + @ValueSource( + strings = { + "example.vault.azure.net", + "http://example.vault.azure.net", + "https://", + "https://user@example.vault.azure.net", + "https://example.vault.azure.net:443", + "https://example.vault.azure.net/keys", + "https://example.vault.azure.net?tenant=example", + "https://example.vault.azure.net#fragment" + }) + void testRejectsInvalidServiceAddresses(String serviceAddress) { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + factory.create( + "primary", + ImmutableMap.of(AzureKeyVaultKmsClientFactory.VAULT_URL, serviceAddress))); + } +} diff --git a/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/integration/test/TestAzureKeyVaultKmsLiveIT.java b/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/integration/test/TestAzureKeyVaultKmsLiveIT.java new file mode 100644 index 00000000000..110279b35a4 --- /dev/null +++ b/bundles/azure/src/test/java/org/apache/gravitino/encryption/kms/azure/integration/test/TestAzureKeyVaultKmsLiveIT.java @@ -0,0 +1,172 @@ +/* + * 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.azure.integration.test; + +import com.google.common.collect.ImmutableList; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.azure.AzureKeyVaultKmsClientFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Read-only integration tests against an explicitly configured live Azure Key Vault fixture. */ +class TestAzureKeyVaultKmsLiveIT { + + private static final Logger LOG = LoggerFactory.getLogger(TestAzureKeyVaultKmsLiveIT.class); + private static final String SOURCE = "azure-live-it"; + private static final String VAULT_URL = "endpoint.vaultUrl"; + private static final String CREDENTIAL_METHOD = "credential.method"; + private static final String MANAGED_IDENTITY_CLIENT_ID = "credential.managedIdentityClientId"; + private static final String ENV_OPT_IN = "GRAVITINO_AZURE_KMS_IT"; + private static final String ENV_VAULT_URL = "GRAVITINO_AZURE_KMS_VAULT_URL"; + private static final String ENV_KEY_ID = "GRAVITINO_AZURE_KMS_KEY_ID"; + private static final String ENV_MANAGED_IDENTITY_CLIENT_ID = + "GRAVITINO_AZURE_KMS_MANAGED_IDENTITY_CLIENT_ID"; + private static final String ABSENT_KEY_NAME = + "gravitino-kms-it-absent-8bb16e12832d4dd9a6e33dc621647c31"; + private static final List REQUIRED_FIXTURE_VARIABLES = + ImmutableList.of(ENV_VAULT_URL, ENV_KEY_ID); + + @Nullable private static KmsClient client; + private static String pinnedKeyId = ""; + private static String latestKeyId = ""; + private static String absentKeyId = ""; + + @BeforeAll + static void setUpFixture() { + Map environment = System.getenv(); + List missingVariables = missingFixtureVariables(environment); + if (!"true".equalsIgnoreCase(StringUtils.trimToEmpty(environment.get(ENV_OPT_IN)))) { + LOG.warn( + "Skipping Azure Key Vault KMS live integration test because {} is not true. Missing fixture variables: {}.", + ENV_OPT_IN, + missingVariables.isEmpty() ? "none" : String.join(", ", missingVariables)); + Assumptions.assumeTrue(false, ENV_OPT_IN + " must be true to run the live test"); + } + + Assertions.assertTrue( + missingVariables.isEmpty(), + "Azure Key Vault KMS live integration test opted in but required fixture variables are missing: " + + String.join(", ", missingVariables)); + + String vaultUrl = environment.get(ENV_VAULT_URL).trim(); + pinnedKeyId = environment.get(ENV_KEY_ID).trim(); + String keyName = versionedKeyName(pinnedKeyId); + String normalizedVaultUrl = StringUtils.removeEnd(vaultUrl, "/"); + latestKeyId = normalizedVaultUrl + "/keys/" + keyName; + absentKeyId = normalizedVaultUrl + "/keys/" + ABSENT_KEY_NAME; + + Map properties = new HashMap<>(); + properties.put(VAULT_URL, vaultUrl); + properties.put(CREDENTIAL_METHOD, "default"); + String managedIdentityClientId = environment.get(ENV_MANAGED_IDENTITY_CLIENT_ID); + if (StringUtils.isNotBlank(managedIdentityClientId)) { + properties.put(MANAGED_IDENTITY_CLIENT_ID, managedIdentityClientId.trim()); + } + client = new AzureKeyVaultKmsClientFactory().create(SOURCE, properties); + } + + @AfterAll + static void closeClient() { + if (client != null) { + client.close(); + } + } + + @Test + void testReadsPinnedEnabledWrappingKey() { + assertUsable(inspect(pinnedKeyId), pinnedKeyId); + } + + @Test + void testReadsLatestEnabledWrappingKey() { + assertUsable(inspect(latestKeyId), latestKeyId); + } + + @Test + void testReportsKnownAbsentKey() { + KmsKeyProperties properties = inspect(absentKeyId); + + Assertions.assertEquals(reference(absentKeyId), properties.reference()); + Assertions.assertFalse(properties.present()); + Assertions.assertFalse(properties.enabled()); + Assertions.assertFalse(properties.supportsWrapping()); + Assertions.assertFalse(properties.supportsUnwrapping()); + } + + private static List missingFixtureVariables(Map environment) { + List missingVariables = new ArrayList<>(); + for (String variable : REQUIRED_FIXTURE_VARIABLES) { + if (StringUtils.isBlank(environment.get(variable))) { + missingVariables.add(variable); + } + } + return missingVariables; + } + + private static String versionedKeyName(String keyId) { + URI uri; + try { + uri = new URI(keyId); + } catch (URISyntaxException e) { + throw new AssertionError(ENV_KEY_ID + " must be a valid URI", e); + } + + String[] pathSegments = uri.getRawPath().split("/", -1); + Assertions.assertTrue( + pathSegments.length == 4 + && pathSegments[0].isEmpty() + && "keys".equals(pathSegments[1]) + && StringUtils.isNoneBlank(pathSegments[2], pathSegments[3]), + ENV_KEY_ID + " must be a full, versioned key URL ending in /keys//"); + return pathSegments[2]; + } + + private static KmsKeyProperties inspect(String keyId) { + Assertions.assertNotNull(client); + return client.getKeyProperties(reference(keyId)); + } + + private static KmsReference reference(String keyId) { + return new KmsReference(KmsApi.AZURE_KEY_VAULT, SOURCE, keyId); + } + + private static void assertUsable(KmsKeyProperties properties, String keyId) { + Assertions.assertEquals(reference(keyId), properties.reference()); + Assertions.assertTrue(properties.present()); + Assertions.assertTrue(properties.enabled()); + Assertions.assertTrue(properties.supportsWrapping()); + Assertions.assertTrue(properties.supportsUnwrapping()); + } +} diff --git a/bundles/gcp/README.md b/bundles/gcp/README.md new file mode 100644 index 00000000000..f18ddd8578b --- /dev/null +++ b/bundles/gcp/README.md @@ -0,0 +1,122 @@ + + +# Google Cloud KMS testing + +The Google Cloud KMS client reads key metadata through the provider's `GetCryptoKey` API. It does +not create keys or key versions, encrypt or decrypt data, rotate keys, or change IAM policies. Unit +tests mock the Google SDK. The live integration test is a separate, explicit opt-in because Google +Cloud does not provide an official Cloud KMS emulator. + +## Live fixtures + +Provision these four fixtures in a dedicated test project and pass their complete CryptoKey resource +names. Short key names and CryptoKeyVersion names are not accepted. + +| Environment variable | Required fixture state | +| --- | --- | +| `GRAVITINO_GCP_KMS_KEY_ID` | An `ENCRYPT_DECRYPT` CryptoKey with an `ENABLED` primary version | +| `GRAVITINO_GCP_KMS_DISABLED_KEY_ID` | An `ENCRYPT_DECRYPT` CryptoKey whose primary version is not `ENABLED` | +| `GRAVITINO_GCP_KMS_NON_ENCRYPTION_KEY_ID` | A non-encryption CryptoKey, such as `ASYMMETRIC_SIGN`, with an `ENABLED` primary version | +| `GRAVITINO_GCP_KMS_MISSING_KEY_ID` | A valid but deliberately nonexistent CryptoKey name in a readable project and key ring | + +Every value must use this form: + +```text +projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/CRYPTO_KEY +``` + +The fixture owner should be the infrastructure or security team, not the test process. Keep the +fixtures in a labeled, budget-monitored test project and record an owner and review date. Cloud KMS +keys and versions have an ongoing cost, and API calls consume quota and may have request costs; use +the current Google Cloud pricing page when budgeting. The test performs no cleanup because it makes +no changes. + +## Authentication and permission + +The client accepts no credential properties. It uses Google Application Default Credentials (ADC) +only. The test identity needs `cloudkms.cryptoKeys.get` for every fixture. `roles/cloudkms.viewer` +provides read-only access, or use a custom role containing only that permission. Grant it at a test +project or key-ring scope that also covers the deliberately missing name; do not grant encrypt, +decrypt, create, update, destroy, or IAM-administration permissions. + +For local development, authenticate ADC with: + +```shell +gcloud auth application-default login +``` + +For CI, prefer Workload Identity Federation (WIF) and a short-lived identity. Configure the CI +provider to produce an ADC-compatible external-account credential file and expose its path through +`GOOGLE_APPLICATION_CREDENTIALS`, or use the platform's attached workload identity. Do not store or +pass a service-account JSON key as a KMS client property. + +## Commands and outcomes + +Run all local checks while excluding live integration tests: + +```shell +./gradlew :bundles:gcp:check -PskipITs +``` + +Exercise the safe default skip path without credentials or fixtures: + +```shell +./gradlew :bundles:gcp:test \ + --tests org.apache.gravitino.kms.gcp.integration.test.TestGoogleCloudKmsClientIT +``` + +That command emits one `WARN` naming missing fixture variables, aborts the test class as skipped, and +does not construct the Google SDK client. + +Run the live test only after provisioning all fixtures and ADC: + +```shell +GRAVITINO_GCP_KMS_IT=true \ +GRAVITINO_GCP_KMS_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/enabled-encryption \ +GRAVITINO_GCP_KMS_DISABLED_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/disabled-encryption \ +GRAVITINO_GCP_KMS_NON_ENCRYPTION_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/enabled-signing \ +GRAVITINO_GCP_KMS_MISSING_KEY_ID=projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/intentionally-absent \ +./gradlew :bundles:gcp:test \ + --tests org.apache.gravitino.kms.gcp.integration.test.TestGoogleCloudKmsClientIT +``` + +The skip/fail boundary is intentional: + +- `-PskipITs` excludes the test class through Gradle before JUnit runs. +- Without `GRAVITINO_GCP_KMS_IT=true`, JUnit logs one warning and reports the class skipped before + ADC initialization. +- Once `GRAVITINO_GCP_KMS_IT=true`, missing or malformed fixture variables, duplicate fixtures, + invalid ADC, insufficient permission, unavailable service, missing expected fixtures, or unexpected + key state fail the test. An opted-in CI job never turns broken configuration into a skip. + +## Dependency updates + +The Cloud KMS SDK is pinned as `google-cloud-kms` 2.97.0 in `gradle/libs.versions.toml`. That SDK +currently causes Gradle to select `google-auth-library-oauth2-http` 1.49.0 over the GCP bundle's +direct 1.28.0 declaration. The GCP bundle does not relocate `com.google.auth` or `com.google.api.gax`, +so treat KMS SDK and authentication-library updates as one compatibility change. Before updating, +run: + +```shell +./gradlew :bundles:gcp:dependencyInsight \ + --configuration runtimeClasspath \ + --dependency google-auth-library-oauth2-http +./gradlew :bundles:gcp:check -PskipITs +``` diff --git a/bundles/gcp/build.gradle.kts b/bundles/gcp/build.gradle.kts index 80658d352a7..b204a062e57 100644 --- a/bundles/gcp/build.gradle.kts +++ b/bundles/gcp/build.gradle.kts @@ -37,17 +37,31 @@ dependencies { } implementation(libs.commons.lang3) + implementation(libs.google.cloud.kms) implementation(libs.guava) compileOnly(libs.hadoop3.client.api) compileOnly(libs.hadoop3.gcs) compileOnly(libs.google.auth.http) + testImplementation(testFixtures(project(":api"))) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) + testImplementation(libs.mockito.inline) + testImplementation(libs.slf4j.api) testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.slf4j.simple) } tasks.compileJava { dependsOn(":catalogs:catalog-fileset:runtimeJars") } + +tasks.test { + val skipITs = project.hasProperty("skipITs") + if (skipITs) { + exclude("**/integration/test/**") + } else { + dependsOn(tasks.jar) + } +} diff --git a/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsClient.java b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsClient.java new file mode 100644 index 00000000000..8dfce085b98 --- /dev/null +++ b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsClient.java @@ -0,0 +1,116 @@ +/* + * 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.kms.gcp; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; + +final class GoogleCloudKmsClient implements KmsClient { + + private static final Pattern CRYPTO_KEY_NAME = + Pattern.compile("projects/([^/]+)/locations/([^/]+)/keyRings/[^/]+/cryptoKeys/[^/]+"); + + private final String source; + private final String projectId; + private final String location; + private final GoogleCloudKmsMetadataService metadataService; + private final AtomicBoolean closed = new AtomicBoolean(); + + GoogleCloudKmsClient( + String source, + String projectId, + String location, + GoogleCloudKmsMetadataService metadataService) { + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("Google Cloud KMS source cannot be blank"); + } + if (metadataService == null) { + throw new IllegalArgumentException("Google Cloud KMS metadata service cannot be null"); + } + + this.source = source; + this.projectId = projectId; + this.location = location; + this.metadataService = metadataService; + } + + /** {@inheritDoc} */ + @Override + public KmsKeyProperties getKeyProperties(KmsReference reference) { + validateReference(reference); + + Optional metadata = metadataService.getKey(reference.keyId()); + if (metadata == null) { + throw new IllegalStateException("Google Cloud KMS metadata service returned null"); + } + if (!metadata.isPresent()) { + return GoogleCloudKmsKeyProperties.missing(reference); + } + + GoogleCloudKmsKeyMetadata key = metadata.get(); + return new GoogleCloudKmsKeyProperties( + reference, + true, + key.primaryVersionEnabled(), + key.encryptDecryptPurpose(), + key.encryptDecryptPurpose()); + } + + /** {@inheritDoc} */ + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + metadataService.close(); + } + } + + private void validateReference(KmsReference reference) { + if (reference == null) { + throw new IllegalArgumentException("Google Cloud KMS reference cannot be null"); + } + if (reference.api() != KmsApi.GOOGLE_CLOUD_KMS) { + throw new IllegalArgumentException( + String.format("KMS API %s does not match Google Cloud KMS", reference.api())); + } + if (!source.equals(reference.source())) { + throw new IllegalArgumentException( + String.format( + "Google Cloud KMS source %s does not match configured source %s", + reference.source(), source)); + } + String providerKeyId = reference.keyId(); + Matcher matcher = CRYPTO_KEY_NAME.matcher(providerKeyId); + if (!matcher.matches()) { + throw new IllegalArgumentException( + String.format("Invalid Google Cloud KMS CryptoKey resource name: %s", providerKeyId)); + } + if (!projectId.equals(matcher.group(1)) || !location.equals(matcher.group(2))) { + throw new IllegalArgumentException( + String.format( + "Google Cloud KMS key %s is outside configured project %s and location %s", + providerKeyId, projectId, location)); + } + } +} diff --git a/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsClientFactory.java b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsClientFactory.java new file mode 100644 index 00000000000..92e8ea7307c --- /dev/null +++ b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsClientFactory.java @@ -0,0 +1,124 @@ +/* + * 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.kms.gcp; + +import com.google.cloud.kms.v1.KeyManagementServiceClient; +import java.io.IOException; +import java.util.Map; +import org.apache.gravitino.annotation.DeveloperApi; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsConfigurationException; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +/** + * Creates metadata-only Google Cloud KMS clients using Application Default Credentials. + * + *

Google Cloud project, location, key ring, and key are supplied in each full CryptoKey resource + * name. Credentials are resolved by the Google Cloud client library and are not accepted as source + * properties. + */ +@DeveloperApi +public final class GoogleCloudKmsClientFactory implements KmsClientFactory { + + /** Google Cloud project property. */ + public static final String PROJECT_ID = "endpoint.projectId"; + + /** Google Cloud location property. */ + public static final String LOCATION = "endpoint.location"; + + /** Credential method property. */ + public static final String CREDENTIAL_METHOD = "credential.method"; + + private final MetadataServiceFactory metadataServiceFactory; + + /** Creates a factory that uses the default Google Cloud KMS endpoint and credentials. */ + public GoogleCloudKmsClientFactory() { + this(GoogleCloudKmsClientFactory::createDefaultMetadataService); + } + + GoogleCloudKmsClientFactory(MetadataServiceFactory metadataServiceFactory) { + if (metadataServiceFactory == null) { + throw new IllegalArgumentException( + "Google Cloud KMS metadata service factory cannot be null"); + } + this.metadataServiceFactory = metadataServiceFactory; + } + + /** {@inheritDoc} */ + @Override + public KmsApi api() { + return KmsApi.GOOGLE_CLOUD_KMS; + } + + /** {@inheritDoc} */ + @Override + public KmsClient create(String source, Map properties) { + if (source == null || source.trim().isEmpty()) { + throw new KmsConfigurationException("Google Cloud KMS source cannot be blank"); + } + if (properties == null) { + throw new KmsConfigurationException("Google Cloud KMS properties cannot be null"); + } + for (String property : properties.keySet()) { + if (!PROJECT_ID.equals(property) + && !LOCATION.equals(property) + && !CREDENTIAL_METHOD.equals(property)) { + throw new KmsConfigurationException("Unsupported Google Cloud KMS property: %s", property); + } + } + + String projectId = requiredProperty(properties, PROJECT_ID); + String location = requiredProperty(properties, LOCATION); + String credentialMethod = requiredProperty(properties, CREDENTIAL_METHOD); + if (!"default".equals(credentialMethod)) { + throw new KmsConfigurationException( + "Unsupported Google Cloud KMS credential method: %s", credentialMethod); + } + + GoogleCloudKmsMetadataService metadataService = metadataServiceFactory.create(source); + if (metadataService == null) { + throw new IllegalStateException("Google Cloud KMS metadata service factory returned null"); + } + return new GoogleCloudKmsClient(source, projectId, location, metadataService); + } + + private static String requiredProperty(Map properties, String name) { + String value = properties.get(name); + if (value == null || value.trim().isEmpty()) { + throw new KmsConfigurationException("Google Cloud KMS property %s cannot be blank", name); + } + return value.trim(); + } + + private static GoogleCloudKmsMetadataService createDefaultMetadataService(String source) { + try { + return new GoogleCloudKmsSdkMetadataService(source, KeyManagementServiceClient.create()); + } catch (IOException | RuntimeException e) { + throw new ConnectionFailedException( + e, "Google Cloud KMS client initialization failed for source %s", source); + } + } + + @FunctionalInterface + interface MetadataServiceFactory { + GoogleCloudKmsMetadataService create(String source); + } +} diff --git a/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsKeyMetadata.java b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsKeyMetadata.java new file mode 100644 index 00000000000..4aad2f5cba2 --- /dev/null +++ b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsKeyMetadata.java @@ -0,0 +1,38 @@ +/* + * 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.kms.gcp; + +final class GoogleCloudKmsKeyMetadata { + + private final boolean encryptDecryptPurpose; + private final boolean primaryVersionEnabled; + + GoogleCloudKmsKeyMetadata(boolean encryptDecryptPurpose, boolean primaryVersionEnabled) { + this.encryptDecryptPurpose = encryptDecryptPurpose; + this.primaryVersionEnabled = primaryVersionEnabled; + } + + boolean encryptDecryptPurpose() { + return encryptDecryptPurpose; + } + + boolean primaryVersionEnabled() { + return primaryVersionEnabled; + } +} diff --git a/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsKeyProperties.java b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsKeyProperties.java new file mode 100644 index 00000000000..2cd352a0126 --- /dev/null +++ b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsKeyProperties.java @@ -0,0 +1,78 @@ +/* + * 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.kms.gcp; + +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; + +final class GoogleCloudKmsKeyProperties implements KmsKeyProperties { + + private final KmsReference reference; + private final boolean present; + private final boolean enabled; + private final boolean supportsWrapping; + private final boolean supportsUnwrapping; + + GoogleCloudKmsKeyProperties( + KmsReference reference, + boolean present, + boolean enabled, + boolean supportsWrapping, + boolean supportsUnwrapping) { + this.reference = reference; + this.present = present; + this.enabled = enabled; + this.supportsWrapping = supportsWrapping; + this.supportsUnwrapping = supportsUnwrapping; + } + + /** {@inheritDoc} */ + @Override + public KmsReference reference() { + return reference; + } + + /** {@inheritDoc} */ + @Override + public boolean present() { + return present; + } + + /** {@inheritDoc} */ + @Override + public boolean enabled() { + return enabled; + } + + /** {@inheritDoc} */ + @Override + public boolean supportsWrapping() { + return supportsWrapping; + } + + /** {@inheritDoc} */ + @Override + public boolean supportsUnwrapping() { + return supportsUnwrapping; + } + + static GoogleCloudKmsKeyProperties missing(KmsReference reference) { + return new GoogleCloudKmsKeyProperties(reference, false, false, false, false); + } +} diff --git a/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsMetadataService.java b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsMetadataService.java new file mode 100644 index 00000000000..23c0f374b15 --- /dev/null +++ b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsMetadataService.java @@ -0,0 +1,29 @@ +/* + * 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.kms.gcp; + +import java.util.Optional; + +interface GoogleCloudKmsMetadataService extends AutoCloseable { + + Optional getKey(String resourceName); + + @Override + default void close() {} +} diff --git a/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsSdkMetadataService.java b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsSdkMetadataService.java new file mode 100644 index 00000000000..49164e46ed4 --- /dev/null +++ b/bundles/gcp/src/main/java/org/apache/gravitino/kms/gcp/GoogleCloudKmsSdkMetadataService.java @@ -0,0 +1,85 @@ +/* + * 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.kms.gcp; + +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.NotFoundException; +import com.google.cloud.kms.v1.CryptoKey; +import com.google.cloud.kms.v1.CryptoKey.CryptoKeyPurpose; +import com.google.cloud.kms.v1.CryptoKeyVersion.CryptoKeyVersionState; +import com.google.cloud.kms.v1.KeyManagementServiceClient; +import java.util.Optional; +import org.apache.gravitino.encryption.kms.KmsAuthenticationException; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +final class GoogleCloudKmsSdkMetadataService implements GoogleCloudKmsMetadataService { + + private final String source; + private final KeyManagementServiceClient client; + + GoogleCloudKmsSdkMetadataService(String source, KeyManagementServiceClient client) { + if (client == null) { + throw new IllegalArgumentException("Google Cloud KMS SDK client cannot be null"); + } + this.source = source; + this.client = client; + } + + @Override + public Optional getKey(String resourceName) { + try { + CryptoKey key = client.getCryptoKey(resourceName); + if (key == null) { + throw new ConnectionFailedException( + "Google Cloud KMS returned an empty response for source %s", source); + } + boolean encryptDecryptPurpose = key.getPurpose() == CryptoKeyPurpose.ENCRYPT_DECRYPT; + boolean primaryVersionEnabled = + key.hasPrimary() && key.getPrimary().getState() == CryptoKeyVersionState.ENABLED; + return Optional.of( + new GoogleCloudKmsKeyMetadata(encryptDecryptPurpose, primaryVersionEnabled)); + } catch (NotFoundException e) { + return Optional.empty(); + } catch (ConnectionFailedException e) { + throw e; + } catch (ApiException e) { + switch (e.getStatusCode().getCode()) { + case UNAUTHENTICATED: + case PERMISSION_DENIED: + throw new KmsAuthenticationException( + e, "Google Cloud KMS rejected credentials for source %s", source); + default: + break; + } + throw unavailable(e); + } catch (RuntimeException e) { + throw unavailable(e); + } + } + + @Override + public void close() { + client.close(); + } + + private ConnectionFailedException unavailable(RuntimeException cause) { + return new ConnectionFailedException( + cause, "Google Cloud KMS could not read key properties for source %s", source); + } +} diff --git a/bundles/gcp/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory b/bundles/gcp/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory new file mode 100644 index 00000000000..2a8c4e2052e --- /dev/null +++ b/bundles/gcp/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory @@ -0,0 +1,20 @@ +# +# 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. +# + +org.apache.gravitino.kms.gcp.GoogleCloudKmsClientFactory diff --git a/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsClient.java b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsClient.java new file mode 100644 index 00000000000..cb6d5f91494 --- /dev/null +++ b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsClient.java @@ -0,0 +1,194 @@ +/* + * 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.kms.gcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientContract; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class TestGoogleCloudKmsClient extends TestKmsClientContract { + + private static final String SOURCE = "analytics"; + private static final String PROJECT_ID = "data-project"; + private static final String LOCATION = "us-west1"; + private static final String USABLE_KEY = + "projects/data-project/locations/us-west1/keyRings/tables/cryptoKeys/customer"; + private static final String MISSING_KEY = + "projects/data-project/locations/us-west1/keyRings/tables/cryptoKeys/missing"; + + private final FakeMetadataService metadataService = new FakeMetadataService(); + + private GoogleCloudKmsClient client; + + @BeforeEach + void setUp() { + metadataService.keys.put(USABLE_KEY, new GoogleCloudKmsKeyMetadata(true, true)); + client = new GoogleCloudKmsClient(SOURCE, PROJECT_ID, LOCATION, metadataService); + } + + @Override + protected KmsClient client() { + return client; + } + + @Override + protected KmsReference usableKey() { + return reference(USABLE_KEY); + } + + @Override + protected KmsReference missingKey() { + return reference(MISSING_KEY); + } + + @Test + void readsExactlyOneCryptoKeyResource() { + client.getKeyProperties(usableKey()); + + assertEquals(1, metadataService.readCount.get()); + assertEquals(USABLE_KEY, metadataService.lastResourceName.get()); + } + + @Test + void reportsDisabledPrimaryVersion() { + metadataService.keys.put(USABLE_KEY, new GoogleCloudKmsKeyMetadata(true, false)); + + KmsKeyProperties properties = client.getKeyProperties(usableKey()); + + assertTrue(properties.present()); + assertFalse(properties.enabled()); + assertTrue(properties.supportsWrapping()); + assertTrue(properties.supportsUnwrapping()); + } + + @Test + void reportsNonEncryptionPurpose() { + metadataService.keys.put(USABLE_KEY, new GoogleCloudKmsKeyMetadata(false, true)); + + KmsKeyProperties properties = client.getKeyProperties(usableKey()); + + assertTrue(properties.present()); + assertTrue(properties.enabled()); + assertFalse(properties.supportsWrapping()); + assertFalse(properties.supportsUnwrapping()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "customer", + "projects/p/locations/l/keyRings/r/cryptoKeys", + "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1", + "projects//locations/l/keyRings/r/cryptoKeys/k", + "projects/p/locations//keyRings/r/cryptoKeys/k", + "projects/p/locations/l/keyRings//cryptoKeys/k", + "projects/p/locations/l/keyRings/r/cryptoKeys/", + "https://cloudkms.googleapis.com/v1/projects/p/locations/l/keyRings/r/cryptoKeys/k" + }) + void rejectsInvalidCryptoKeyResourceNames(String keyId) { + assertThrows(IllegalArgumentException.class, () -> client.getKeyProperties(reference(keyId))); + assertEquals(0, metadataService.readCount.get()); + } + + @Test + void preservesConnectionFailures() { + ConnectionFailedException failure = + new ConnectionFailedException("Google Cloud KMS is unavailable"); + metadataService.failure = failure; + + ConnectionFailedException exception = + assertThrows(ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + + assertEquals(failure, exception); + } + + @Test + void rejectsBrokenMetadataServiceContract() { + metadataService.returnNull = true; + + assertThrows(IllegalStateException.class, () -> client.getKeyProperties(usableKey())); + } + + @Test + void closesMetadataServiceOnce() { + client.close(); + client.close(); + + assertEquals(1, metadataService.closeCount.get()); + } + + @Test + void rejectsInvalidConstruction() { + assertThrows( + IllegalArgumentException.class, + () -> new GoogleCloudKmsClient(" ", PROJECT_ID, LOCATION, metadataService)); + assertThrows( + IllegalArgumentException.class, + () -> new GoogleCloudKmsClient(SOURCE, PROJECT_ID, LOCATION, null)); + } + + private KmsReference reference(String keyId) { + return new KmsReference(KmsApi.GOOGLE_CLOUD_KMS, SOURCE, keyId); + } + + private static final class FakeMetadataService implements GoogleCloudKmsMetadataService { + private final Map keys = new HashMap<>(); + private final AtomicInteger readCount = new AtomicInteger(); + private final AtomicInteger closeCount = new AtomicInteger(); + private final AtomicReference lastResourceName = new AtomicReference<>(); + + private ConnectionFailedException failure; + private boolean returnNull; + + @Override + public Optional getKey(String resourceName) { + readCount.incrementAndGet(); + lastResourceName.set(resourceName); + if (failure != null) { + throw failure; + } + if (returnNull) { + return null; + } + return Optional.ofNullable(keys.get(resourceName)); + } + + @Override + public void close() { + closeCount.incrementAndGet(); + } + } +} diff --git a/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsClientFactory.java b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsClientFactory.java new file mode 100644 index 00000000000..3441a0b4183 --- /dev/null +++ b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsClientFactory.java @@ -0,0 +1,142 @@ +/* + * 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.kms.gcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientFactoryContract; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class TestGoogleCloudKmsClientFactory extends TestKmsClientFactoryContract { + + private static final String SOURCE = "analytics"; + private static final String KEY_NAME = + "projects/data-project/locations/us/keyRings/tables/cryptoKeys/customer"; + + private final AtomicReference createdSource = new AtomicReference<>(); + private final GoogleCloudKmsClientFactory factory = + new GoogleCloudKmsClientFactory( + source -> { + createdSource.set(source); + return resourceName -> Optional.of(new GoogleCloudKmsKeyMetadata(true, true)); + }); + + @Override + protected KmsClientFactory factory() { + return factory; + } + + @Override + protected KmsApi expectedApi() { + return KmsApi.GOOGLE_CLOUD_KMS; + } + + @Test + void createsWorkingClientWithDefaultCredentials() { + KmsClient client = factory.create(SOURCE, validProperties()); + KmsReference reference = new KmsReference(KmsApi.GOOGLE_CLOUD_KMS, SOURCE, KEY_NAME); + + KmsKeyProperties properties = client.getKeyProperties(reference); + + assertEquals(reference, properties.reference()); + assertTrue(properties.present()); + assertEquals(SOURCE, createdSource.get()); + } + + @Test + void rejectsMissingOrUnknownConfiguration() { + assertThrows(IllegalArgumentException.class, () -> factory.create(SOURCE, null)); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> factory.create(SOURCE, Map.of("credentialsFile", "/tmp/credentials.json"))); + assertTrue(exception.getMessage().contains("credentialsFile")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t"}) + void rejectsBlankSource(String source) { + assertThrows(IllegalArgumentException.class, () -> factory.create(source, validProperties())); + } + + @Test + void preservesInitializationFailure() { + ConnectionFailedException failure = + new ConnectionFailedException("Application Default Credentials are unavailable"); + GoogleCloudKmsClientFactory failingFactory = + new GoogleCloudKmsClientFactory( + source -> { + throw failure; + }); + + ConnectionFailedException exception = + assertThrows( + ConnectionFailedException.class, + () -> failingFactory.create(SOURCE, validProperties())); + + assertEquals(failure, exception); + } + + @Test + void rejectsBrokenMetadataServiceFactoryContract() { + GoogleCloudKmsClientFactory brokenFactory = new GoogleCloudKmsClientFactory(source -> null); + + assertThrows( + IllegalStateException.class, () -> brokenFactory.create(SOURCE, validProperties())); + assertThrows(IllegalArgumentException.class, () -> new GoogleCloudKmsClientFactory(null)); + } + + @Test + void serviceLoaderDiscoversFactory() { + KmsClientFactory discovered = null; + for (KmsClientFactory candidate : ServiceLoader.load(KmsClientFactory.class)) { + if (candidate.api() == KmsApi.GOOGLE_CLOUD_KMS) { + discovered = candidate; + break; + } + } + + assertInstanceOf(GoogleCloudKmsClientFactory.class, discovered); + } + + private static Map validProperties() { + Map properties = new HashMap<>(); + properties.put(GoogleCloudKmsClientFactory.PROJECT_ID, "data-project"); + properties.put(GoogleCloudKmsClientFactory.LOCATION, "us"); + properties.put(GoogleCloudKmsClientFactory.CREDENTIAL_METHOD, "default"); + return properties; + } +} diff --git a/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsSdkMetadataService.java b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsSdkMetadataService.java new file mode 100644 index 00000000000..4b5ec82e5da --- /dev/null +++ b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/TestGoogleCloudKmsSdkMetadataService.java @@ -0,0 +1,197 @@ +/* + * 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.kms.gcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.ApiExceptionFactory; +import com.google.api.gax.rpc.StatusCode; +import com.google.cloud.kms.v1.CryptoKey; +import com.google.cloud.kms.v1.CryptoKey.CryptoKeyPurpose; +import com.google.cloud.kms.v1.CryptoKeyVersion; +import com.google.cloud.kms.v1.CryptoKeyVersion.CryptoKeyVersionState; +import com.google.cloud.kms.v1.KeyManagementServiceClient; +import java.util.Optional; +import org.apache.gravitino.encryption.kms.KmsAuthenticationException; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class TestGoogleCloudKmsSdkMetadataService { + + private static final String SOURCE = "analytics"; + private static final String KEY_NAME = + "projects/data-project/locations/us/keyRings/tables/cryptoKeys/customer"; + + private KeyManagementServiceClient sdkClient; + private GoogleCloudKmsSdkMetadataService metadataService; + + @BeforeEach + void setUp() { + sdkClient = mock(KeyManagementServiceClient.class); + metadataService = new GoogleCloudKmsSdkMetadataService(SOURCE, sdkClient); + } + + @Test + void mapsEncryptDecryptKeyWithEnabledPrimary() { + when(sdkClient.getCryptoKey(KEY_NAME)) + .thenReturn(key(CryptoKeyPurpose.ENCRYPT_DECRYPT, CryptoKeyVersionState.ENABLED)); + + GoogleCloudKmsKeyMetadata metadata = + metadataService.getKey(KEY_NAME).orElseThrow(IllegalStateException::new); + + assertTrue(metadata.encryptDecryptPurpose()); + assertTrue(metadata.primaryVersionEnabled()); + verify(sdkClient).getCryptoKey(KEY_NAME); + } + + @Test + void mapsKeyWithoutPrimaryVersionAsDisabled() { + when(sdkClient.getCryptoKey(KEY_NAME)) + .thenReturn(CryptoKey.newBuilder().setPurpose(CryptoKeyPurpose.ENCRYPT_DECRYPT).build()); + + GoogleCloudKmsKeyMetadata metadata = + metadataService.getKey(KEY_NAME).orElseThrow(IllegalStateException::new); + + assertTrue(metadata.encryptDecryptPurpose()); + assertFalse(metadata.primaryVersionEnabled()); + } + + @ParameterizedTest + @EnumSource( + value = CryptoKeyVersionState.class, + mode = EnumSource.Mode.EXCLUDE, + names = {"ENABLED", "UNRECOGNIZED"}) + void mapsNonEnabledPrimaryVersionsAsDisabled(CryptoKeyVersionState state) { + when(sdkClient.getCryptoKey(KEY_NAME)).thenReturn(key(CryptoKeyPurpose.ENCRYPT_DECRYPT, state)); + + assertFalse( + metadataService + .getKey(KEY_NAME) + .orElseThrow(IllegalStateException::new) + .primaryVersionEnabled()); + } + + @ParameterizedTest + @EnumSource( + value = CryptoKeyPurpose.class, + mode = EnumSource.Mode.EXCLUDE, + names = {"ENCRYPT_DECRYPT", "UNRECOGNIZED"}) + void mapsOtherPurposesAsNotSupportingEnvelopeEncryption(CryptoKeyPurpose purpose) { + when(sdkClient.getCryptoKey(KEY_NAME)).thenReturn(key(purpose, CryptoKeyVersionState.ENABLED)); + + assertFalse( + metadataService + .getKey(KEY_NAME) + .orElseThrow(IllegalStateException::new) + .encryptDecryptPurpose()); + } + + @Test + void mapsUnrecognizedPurposeAndPrimaryStateAsUnsupportedAndDisabled() { + CryptoKey key = + CryptoKey.newBuilder() + .setPurposeValue(Integer.MAX_VALUE) + .setPrimary(CryptoKeyVersion.newBuilder().setStateValue(Integer.MAX_VALUE).build()) + .build(); + when(sdkClient.getCryptoKey(KEY_NAME)).thenReturn(key); + + GoogleCloudKmsKeyMetadata metadata = + metadataService.getKey(KEY_NAME).orElseThrow(IllegalStateException::new); + + assertFalse(metadata.encryptDecryptPurpose()); + assertFalse(metadata.primaryVersionEnabled()); + } + + @Test + void mapsNotFoundToEmptyMetadata() { + ApiException failure = apiException(StatusCode.Code.NOT_FOUND); + when(sdkClient.getCryptoKey(KEY_NAME)).thenThrow(failure); + + assertEquals(Optional.empty(), metadataService.getKey(KEY_NAME)); + } + + @ParameterizedTest + @EnumSource( + value = StatusCode.Code.class, + mode = EnumSource.Mode.EXCLUDE, + names = {"OK", "NOT_FOUND", "UNAUTHENTICATED", "PERMISSION_DENIED"}) + void mapsProviderFailuresToConnectionFailures(StatusCode.Code code) { + ApiException failure = apiException(code); + when(sdkClient.getCryptoKey(KEY_NAME)).thenThrow(failure); + + assertThrows(ConnectionFailedException.class, () -> metadataService.getKey(KEY_NAME)); + } + + @ParameterizedTest + @EnumSource( + value = StatusCode.Code.class, + names = {"UNAUTHENTICATED", "PERMISSION_DENIED"}) + void mapsRejectedCredentialsToAuthenticationFailures(StatusCode.Code code) { + ApiException failure = apiException(code); + when(sdkClient.getCryptoKey(KEY_NAME)).thenThrow(failure); + + assertThrows(KmsAuthenticationException.class, () -> metadataService.getKey(KEY_NAME)); + } + + @Test + void mapsEmptyAndUnexpectedResponsesToConnectionFailures() { + when(sdkClient.getCryptoKey(KEY_NAME)).thenReturn(null); + assertThrows(ConnectionFailedException.class, () -> metadataService.getKey(KEY_NAME)); + + when(sdkClient.getCryptoKey(KEY_NAME)).thenThrow(new IllegalStateException("closed transport")); + assertThrows(ConnectionFailedException.class, () -> metadataService.getKey(KEY_NAME)); + } + + @Test + void closesSdkClient() { + metadataService.close(); + + verify(sdkClient).close(); + } + + @Test + void rejectsNullSdkClient() { + assertThrows( + IllegalArgumentException.class, () -> new GoogleCloudKmsSdkMetadataService(SOURCE, null)); + } + + private static CryptoKey key(CryptoKeyPurpose purpose, CryptoKeyVersionState state) { + return CryptoKey.newBuilder() + .setPurpose(purpose) + .setPrimary(CryptoKeyVersion.newBuilder().setState(state).build()) + .build(); + } + + private static ApiException apiException(StatusCode.Code code) { + StatusCode statusCode = mock(StatusCode.class); + when(statusCode.getCode()).thenReturn(code); + return ApiExceptionFactory.createException( + new RuntimeException(code.name()), statusCode, false); + } +} diff --git a/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/integration/test/TestGoogleCloudKmsClientIT.java b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/integration/test/TestGoogleCloudKmsClientIT.java new file mode 100644 index 00000000000..bf4d9c9f412 --- /dev/null +++ b/bundles/gcp/src/test/java/org/apache/gravitino/kms/gcp/integration/test/TestGoogleCloudKmsClientIT.java @@ -0,0 +1,180 @@ +/* + * 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.kms.gcp.integration.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.kms.gcp.GoogleCloudKmsClientFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Read-only live tests for Google Cloud KMS metadata inspection through ADC. */ +class TestGoogleCloudKmsClientIT { + + private static final Logger LOG = LoggerFactory.getLogger(TestGoogleCloudKmsClientIT.class); + + private static final String SOURCE = "gcp-kms-live-it"; + private static final String ENV_OPT_IN = "GRAVITINO_GCP_KMS_IT"; + private static final String ENV_ENABLED_KEY = "GRAVITINO_GCP_KMS_KEY_ID"; + private static final String ENV_MISSING_KEY = "GRAVITINO_GCP_KMS_MISSING_KEY_ID"; + private static final String ENV_DISABLED_KEY = "GRAVITINO_GCP_KMS_DISABLED_KEY_ID"; + private static final String ENV_NON_ENCRYPTION_KEY = "GRAVITINO_GCP_KMS_NON_ENCRYPTION_KEY_ID"; + private static final List FIXTURE_VARIABLES = + Arrays.asList(ENV_ENABLED_KEY, ENV_MISSING_KEY, ENV_DISABLED_KEY, ENV_NON_ENCRYPTION_KEY); + private static final Pattern CRYPTO_KEY_NAME = + Pattern.compile("projects/[^/]+/locations/[^/]+/keyRings/[^/]+/cryptoKeys/[^/]+"); + + private static KmsClient client; + private static String enabledKey; + private static String missingKey; + private static String disabledKey; + private static String nonEncryptionKey; + + @BeforeAll + static void createClientWhenExplicitlyEnabled() { + List missingVariables = + FIXTURE_VARIABLES.stream() + .filter(variable -> isBlank(System.getenv(variable))) + .collect(Collectors.toList()); + String optIn = System.getenv(ENV_OPT_IN); + boolean enabled = optIn != null && "true".equalsIgnoreCase(optIn.trim()); + + if (!enabled) { + LOG.warn( + "Skipping Google Cloud KMS live integration tests because {} is not true. " + + "Missing fixture variables: {}", + ENV_OPT_IN, + missingVariables.isEmpty() ? "(none)" : String.join(", ", missingVariables)); + assumeTrue(false, "Google Cloud KMS live integration tests require explicit opt-in"); + } + + assertTrue( + missingVariables.isEmpty(), + () -> + "Google Cloud KMS live integration tests are enabled but required fixture variables " + + "are missing: " + + String.join(", ", missingVariables)); + + enabledKey = System.getenv(ENV_ENABLED_KEY); + missingKey = System.getenv(ENV_MISSING_KEY); + disabledKey = System.getenv(ENV_DISABLED_KEY); + nonEncryptionKey = System.getenv(ENV_NON_ENCRYPTION_KEY); + + Stream.of(enabledKey, missingKey, disabledKey, nonEncryptionKey) + .forEach( + keyId -> + assertTrue( + CRYPTO_KEY_NAME.matcher(keyId).matches(), + () -> + "Fixture is not a full Google Cloud KMS CryptoKey resource name: " + + keyId)); + assertEquals( + FIXTURE_VARIABLES.size(), + Stream.of(enabledKey, missingKey, disabledKey, nonEncryptionKey).distinct().count(), + "Google Cloud KMS live integration fixtures must use distinct CryptoKeys"); + + String[] keySegments = enabledKey.split("/"); + client = + new GoogleCloudKmsClientFactory() + .create( + SOURCE, + Map.of( + GoogleCloudKmsClientFactory.PROJECT_ID, + keySegments[1], + GoogleCloudKmsClientFactory.LOCATION, + keySegments[3], + GoogleCloudKmsClientFactory.CREDENTIAL_METHOD, + "default")); + } + + @AfterAll + static void closeClient() { + if (client != null) { + client.close(); + } + } + + @Test + void readsEnabledEncryptionKey() { + KmsKeyProperties properties = client.getKeyProperties(reference(enabledKey)); + + assertEquals(reference(enabledKey), properties.reference()); + assertTrue(properties.present()); + assertTrue(properties.enabled()); + assertTrue(properties.supportsWrapping()); + assertTrue(properties.supportsUnwrapping()); + } + + @Test + void reportsMissingKey() { + KmsKeyProperties properties = client.getKeyProperties(reference(missingKey)); + + assertEquals(reference(missingKey), properties.reference()); + assertFalse(properties.present()); + assertFalse(properties.enabled()); + assertFalse(properties.supportsWrapping()); + assertFalse(properties.supportsUnwrapping()); + } + + @Test + void readsDisabledEncryptionKey() { + KmsKeyProperties properties = client.getKeyProperties(reference(disabledKey)); + + assertEquals(reference(disabledKey), properties.reference()); + assertTrue(properties.present()); + assertFalse(properties.enabled()); + assertTrue(properties.supportsWrapping()); + assertTrue(properties.supportsUnwrapping()); + } + + @Test + void readsEnabledNonEncryptionKey() { + KmsKeyProperties properties = client.getKeyProperties(reference(nonEncryptionKey)); + + assertEquals(reference(nonEncryptionKey), properties.reference()); + assertTrue(properties.present()); + assertTrue(properties.enabled()); + assertFalse(properties.supportsWrapping()); + assertFalse(properties.supportsUnwrapping()); + } + + private static KmsReference reference(String keyId) { + return new KmsReference(KmsApi.GOOGLE_CLOUD_KMS, SOURCE, keyId); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/bundles/transit/README.md b/bundles/transit/README.md new file mode 100644 index 00000000000..93298f795af --- /dev/null +++ b/bundles/transit/README.md @@ -0,0 +1,74 @@ + + +# Transit KMS provider testing + +The Transit bundle contains separate OpenBao and HashiCorp Vault implementations of the common KMS +key-inspection API. The clients only read key metadata; they never encrypt, decrypt, wrap, or unwrap +data. + +## Unit tests + +Unit tests use an in-process HTTP server and need no Docker daemon or external credentials. + +```shell +./gradlew :bundles:transit:check -PskipITs +``` + +## Docker integration tests + +The integration tests start disposable development-mode servers, enable Transit, and create an +encryption key and a signing-only key. They verify the full factory-to-HTTP metadata path, including +a missing key. The containers and all key state are removed after the test. + +```shell +./gradlew :bundles:transit:test \ + -PskipTests \ + -PskipDockerTests=false +``` + +The default immutable image tags are: + +| Provider | Image | Optional override | +| --- | --- | --- | +| OpenBao | `openbao/openbao:2.6.0` | `GRAVITINO_OPENBAO_DOCKER_IMAGE` | +| Vault | `hashicorp/vault:2.0.3` | `GRAVITINO_VAULT_DOCKER_IMAGE` | + +Pin updates should be made in the test and this README together, then verified on the CI runner +architecture. The tests use insecure development-mode root tokens only inside disposable +containers; production credentials are never accepted or required. + +## CI configuration + +The CI runner needs: + +- a supported Docker daemon reachable by Testcontainers; +- permission to pull the two pinned images; and +- `-PskipDockerTests=false` on the dedicated Docker-test job. + +No CI secrets are required. Ordinary unit-test jobs use `-PskipITs`, so the integration package is +excluded even if Docker is available. + +OpenBao is the primary open-source Transit reference fixture and is published under the Mozilla +Public License 2.0. Gravitino does not bundle the OpenBao server or its source; the test pulls its +official image at runtime. + +Vault is an interoperability target. Its image may be pulled as an external test fixture, but no +Vault binary, image, or source is bundled, redistributed, or shipped with Gravitino. Any CI use of +the image must comply with the image's current HashiCorp terms. diff --git a/bundles/transit/build.gradle.kts b/bundles/transit/build.gradle.kts new file mode 100644 index 00000000000..a4664f5ad06 --- /dev/null +++ b/bundles/transit/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +plugins { + `maven-publish` + id("java") +} + +dependencies { + implementation(project(":api")) { + exclude("*") + } + implementation(libs.httpclient5) + implementation(libs.jackson.databind) + + testImplementation(testFixtures(project(":api"))) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.testcontainers) + testRuntimeOnly(libs.junit.jupiter.engine) +} + +tasks.test { + if (project.hasProperty("skipITs")) { + exclude("**/integration/test/**") + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/FileTokenSupplier.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/FileTokenSupplier.java new file mode 100644 index 00000000000..1adb7ef69e4 --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/FileTokenSupplier.java @@ -0,0 +1,80 @@ +/* + * 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.kms.transit; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +final class FileTokenSupplier { + + private static final int MAX_TOKEN_BYTES = 16 * 1024; + + private final String providerName; + private final Path tokenFile; + private volatile String cachedToken; + + FileTokenSupplier(String providerName, Path tokenFile) { + this.providerName = providerName; + this.tokenFile = tokenFile; + } + + String token() { + String token = cachedToken; + if (token != null) { + return token; + } + synchronized (this) { + if (cachedToken == null) { + cachedToken = readToken(); + } + return cachedToken; + } + } + + synchronized String reload() { + cachedToken = readToken(); + return cachedToken; + } + + private String readToken() { + byte[] tokenBytes; + try { + if (Files.size(tokenFile) > MAX_TOKEN_BYTES) { + throw new ConnectionFailedException("%s token file exceeds the allowed size", providerName); + } + tokenBytes = Files.readAllBytes(tokenFile); + } catch (ConnectionFailedException e) { + throw e; + } catch (IOException | RuntimeException e) { + throw new ConnectionFailedException(e, "Failed to read the %s token file", providerName); + } + + if (tokenBytes.length > MAX_TOKEN_BYTES) { + throw new ConnectionFailedException("%s token file exceeds the allowed size", providerName); + } + String token = new String(tokenBytes, StandardCharsets.UTF_8).trim(); + if (token.isEmpty() || token.indexOf('\r') >= 0 || token.indexOf('\n') >= 0) { + throw new ConnectionFailedException("%s token file is empty or malformed", providerName); + } + return token; + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsClient.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsClient.java new file mode 100644 index 00000000000..06b60090065 --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsClient.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.kms.transit; + +import java.net.URI; +import java.nio.file.Path; +import java.util.Optional; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +final class OpenBaoTransitKmsClient implements KmsClient { + + private final String source; + private final TransitApiClient apiClient; + + OpenBaoTransitKmsClient(String source, URI serviceAddress, String transitMount, Path tokenFile) { + this.source = source; + this.apiClient = + new TransitApiClient("OpenBao Transit", serviceAddress, transitMount, tokenFile); + } + + /** {@inheritDoc} */ + @Override + public KmsKeyProperties getKeyProperties(KmsReference reference) { + validateReference(reference); + + Optional response = + apiClient.readKey(new TransitReadKeyRequest(reference.keyId())); + if (!response.isPresent()) { + return missingProperties(reference); + } + + TransitKeyData data = response.get().data(); + if (data == null || data.supportsEncryption() == null || data.supportsDecryption() == null) { + throw malformedResponse(); + } + if (Boolean.TRUE.equals(data.softDeleted())) { + return missingProperties(reference); + } + + return new OpenBaoTransitKmsKeyProperties( + reference, true, true, data.supportsEncryption(), data.supportsDecryption()); + } + + /** {@inheritDoc} */ + @Override + public void close() { + apiClient.close(); + } + + private void validateReference(KmsReference reference) { + if (reference == null) { + throw new IllegalArgumentException("OpenBao Transit key reference cannot be null"); + } + if (reference.api() != KmsApi.OPENBAO_TRANSIT) { + throw new IllegalArgumentException( + String.format("KMS API %s does not match OpenBao Transit", reference.api())); + } + if (!source.equals(reference.source())) { + throw new IllegalArgumentException( + String.format( + "OpenBao Transit source %s does not match configured source %s", + reference.source(), source)); + } + validateKeyId(reference.keyId()); + } + + private static void validateKeyId(String keyId) { + if (keyId == null || keyId.trim().isEmpty()) { + throw new IllegalArgumentException("OpenBao Transit key ID cannot be blank"); + } + if (keyId.contains("/") || keyId.contains("\\") || ".".equals(keyId) || "..".equals(keyId)) { + throw new IllegalArgumentException( + String.format("Invalid OpenBao Transit key ID: %s", keyId)); + } + } + + private static OpenBaoTransitKmsKeyProperties missingProperties(KmsReference reference) { + return new OpenBaoTransitKmsKeyProperties(reference, false, false, false, false); + } + + private static ConnectionFailedException malformedResponse() { + return new ConnectionFailedException("OpenBao Transit returned a malformed response"); + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsClientFactory.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsClientFactory.java new file mode 100644 index 00000000000..171b0c62082 --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsClientFactory.java @@ -0,0 +1,157 @@ +/* + * 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.kms.transit; + +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.apache.gravitino.annotation.DeveloperApi; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsConfigurationException; + +/** Creates clients for the OpenBao Transit secrets engine. */ +@DeveloperApi +public final class OpenBaoTransitKmsClientFactory implements KmsClientFactory { + + /** Property containing the OpenBao HTTP(S) server base address. */ + public static final String SERVICE_ADDRESS = "endpoint.address"; + + /** Property containing the OpenBao Transit mount path. */ + public static final String TRANSIT_MOUNT = "endpoint.transitMount"; + + /** Property selecting token-file authentication. */ + public static final String CREDENTIAL_METHOD = "credential.method"; + + /** Property containing an absolute path to an OpenBao token file. */ + public static final String TOKEN_FILE = "credential.path"; + + /** Default OpenBao Transit mount path. */ + public static final String DEFAULT_TRANSIT_MOUNT = "transit"; + + private static final Set SUPPORTED_PROPERTIES = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList(SERVICE_ADDRESS, TRANSIT_MOUNT, CREDENTIAL_METHOD, TOKEN_FILE))); + + /** {@inheritDoc} */ + @Override + public KmsApi api() { + return KmsApi.OPENBAO_TRANSIT; + } + + /** {@inheritDoc} */ + @Override + public KmsClient create(String source, Map properties) { + validateSource(source); + if (properties == null) { + throw new KmsConfigurationException("OpenBao Transit properties cannot be null"); + } + for (String property : properties.keySet()) { + if (!SUPPORTED_PROPERTIES.contains(property)) { + throw new KmsConfigurationException("Unsupported OpenBao Transit property: %s", property); + } + } + + URI serviceAddress = parseServiceAddress(requireProperty(properties, SERVICE_ADDRESS)); + String credentialMethod = requireProperty(properties, CREDENTIAL_METHOD); + if (!"token_file".equals(credentialMethod)) { + throw new KmsConfigurationException( + "Unsupported OpenBao Transit credential method: %s", credentialMethod); + } + String transitMount = properties.get(TRANSIT_MOUNT); + if (transitMount == null) { + transitMount = DEFAULT_TRANSIT_MOUNT; + } else { + transitMount = transitMount.trim(); + } + validateTransitMount(transitMount); + Path tokenFile = parseTokenFile(requireProperty(properties, TOKEN_FILE)); + + return new OpenBaoTransitKmsClient(source, serviceAddress, transitMount, tokenFile); + } + + private static String requireProperty(Map properties, String name) { + String value = properties.get(name); + if (value == null || value.trim().isEmpty()) { + throw new KmsConfigurationException("Missing required OpenBao Transit property: %s", name); + } + return value.trim(); + } + + private static URI parseServiceAddress(String value) { + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException e) { + throw new KmsConfigurationException(e, "Invalid OpenBao Transit endpoint address"); + } + + String path = uri.getPath(); + if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getQuery() != null + || uri.getFragment() != null + || !(path == null || path.isEmpty() || "/".equals(path))) { + throw new KmsConfigurationException( + "OpenBao Transit endpoint address must be an HTTP(S) server base address"); + } + + String address = uri.toString(); + return URI.create(address.endsWith("/") ? address.substring(0, address.length() - 1) : address); + } + + private static Path parseTokenFile(String value) { + Path path; + try { + path = Paths.get(value); + } catch (RuntimeException e) { + throw new KmsConfigurationException(e, "Invalid OpenBao Transit credential path"); + } + if (!path.isAbsolute()) { + throw new KmsConfigurationException( + "OpenBao Transit credential path must be an absolute path"); + } + return path; + } + + private static void validateSource(String source) { + if (source == null || source.trim().isEmpty()) { + throw new KmsConfigurationException("OpenBao Transit source cannot be blank"); + } + } + + private static void validateTransitMount(String mount) { + if (mount.isEmpty() || mount.startsWith("/") || mount.endsWith("/")) { + throw new KmsConfigurationException("Invalid OpenBao Transit mount path"); + } + for (String segment : mount.split("/", -1)) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { + throw new KmsConfigurationException("Invalid OpenBao Transit mount path"); + } + } + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsKeyProperties.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsKeyProperties.java new file mode 100644 index 00000000000..94701bf6105 --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/OpenBaoTransitKmsKeyProperties.java @@ -0,0 +1,74 @@ +/* + * 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.kms.transit; + +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; + +final class OpenBaoTransitKmsKeyProperties implements KmsKeyProperties { + + private final KmsReference reference; + private final boolean present; + private final boolean enabled; + private final boolean supportsWrapping; + private final boolean supportsUnwrapping; + + OpenBaoTransitKmsKeyProperties( + KmsReference reference, + boolean present, + boolean enabled, + boolean supportsWrapping, + boolean supportsUnwrapping) { + this.reference = reference; + this.present = present; + this.enabled = enabled; + this.supportsWrapping = supportsWrapping; + this.supportsUnwrapping = supportsUnwrapping; + } + + /** {@inheritDoc} */ + @Override + public KmsReference reference() { + return reference; + } + + /** {@inheritDoc} */ + @Override + public boolean present() { + return present; + } + + /** {@inheritDoc} */ + @Override + public boolean enabled() { + return enabled; + } + + /** {@inheritDoc} */ + @Override + public boolean supportsWrapping() { + return supportsWrapping; + } + + /** {@inheritDoc} */ + @Override + public boolean supportsUnwrapping() { + return supportsUnwrapping; + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitApiClient.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitApiClient.java new file mode 100644 index 00000000000..342b5ed16af --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitApiClient.java @@ -0,0 +1,216 @@ +/* + * 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.kms.transit; + +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import org.apache.gravitino.encryption.kms.KmsAuthenticationException; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.util.Timeout; + +final class TransitApiClient { + + private static final String TOKEN_HEADER = "X-Vault-Token"; + private static final int MAX_RESPONSE_BYTES = 1024 * 1024; + private static final int CONNECT_TIMEOUT_MILLIS = 5_000; + private static final int REQUEST_TIMEOUT_MILLIS = 10_000; + private static final ObjectMapper OBJECT_MAPPER = createObjectMapper(); + + private final String providerName; + private final URI serviceAddress; + private final String transitMount; + private final FileTokenSupplier tokenSupplier; + private final CloseableHttpClient httpClient; + + TransitApiClient(String providerName, URI serviceAddress, String transitMount, Path tokenFile) { + this( + providerName, + serviceAddress, + transitMount, + new FileTokenSupplier(providerName, tokenFile), + createHttpClient()); + } + + TransitApiClient( + String providerName, + URI serviceAddress, + String transitMount, + FileTokenSupplier tokenSupplier, + CloseableHttpClient httpClient) { + this.providerName = providerName; + this.serviceAddress = serviceAddress; + this.transitMount = transitMount; + this.tokenSupplier = tokenSupplier; + this.httpClient = httpClient; + } + + Optional readKey(TransitReadKeyRequest request) { + HttpResult result = execute(request, tokenSupplier.token()); + if (isAuthenticationFailure(result.statusCode)) { + result = execute(request, tokenSupplier.reload()); + } + + if (result.statusCode == 404) { + return Optional.empty(); + } + if (isAuthenticationFailure(result.statusCode)) { + throw new KmsAuthenticationException( + "%s rejected its configured credentials (HTTP %s)", providerName, result.statusCode); + } + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new ConnectionFailedException( + "%s could not inspect key %s (HTTP %s)", + providerName, request.keyId(), result.statusCode); + } + + try { + return Optional.of(OBJECT_MAPPER.readValue(result.body, TransitReadKeyResponse.class)); + } catch (IOException | RuntimeException e) { + throw new ConnectionFailedException(e, "%s returned a malformed response", providerName); + } + } + + void close() { + try { + httpClient.close(); + } catch (IOException e) { + throw new ConnectionFailedException(e, "Failed to close the %s HTTP client", providerName); + } + } + + private HttpResult execute(TransitReadKeyRequest request, String token) { + HttpGet httpRequest = new HttpGet(keyMetadataUri(request.keyId())); + httpRequest.setHeader("Accept", "application/json"); + httpRequest.setHeader(TOKEN_HEADER, token); + try { + return httpClient.execute( + httpRequest, + response -> new HttpResult(response.getCode(), readResponse(response.getEntity()))); + } catch (ConnectionFailedException e) { + throw e; + } catch (IOException | RuntimeException e) { + throw new ConnectionFailedException( + e, "%s is unavailable while inspecting key %s", providerName, request.keyId()); + } + } + + private URI keyMetadataUri(String keyId) { + String encodedMount = + Arrays.stream(transitMount.split("/")) + .map(TransitApiClient::encodePathSegment) + .collect(Collectors.joining("/")); + return URI.create( + String.format("%s/v1/%s/keys/%s", serviceAddress, encodedMount, encodePathSegment(keyId))); + } + + private static byte[] readResponse(HttpEntity entity) throws IOException { + if (entity == null) { + return new byte[0]; + } + if (entity.getContentLength() > MAX_RESPONSE_BYTES) { + throw new ConnectionFailedException("Transit response exceeds the allowed size"); + } + + try (InputStream responseBody = entity.getContent(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int remaining = MAX_RESPONSE_BYTES + 1; + while (remaining > 0) { + int read = responseBody.read(buffer, 0, Math.min(buffer.length, remaining)); + if (read < 0) { + break; + } + output.write(buffer, 0, read); + remaining -= read; + } + if (output.size() > MAX_RESPONSE_BYTES) { + throw new ConnectionFailedException("Transit response exceeds the allowed size"); + } + return output.toByteArray(); + } + } + + private static boolean isAuthenticationFailure(int statusCode) { + return statusCode == 401 || statusCode == 403; + } + + private static String encodePathSegment(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (IOException e) { + throw new ConnectionFailedException(e, "Failed to encode a Transit key reference"); + } + } + + private static ObjectMapper createObjectMapper() { + return JsonMapper.builder().disable(MapperFeature.ALLOW_COERCION_OF_SCALARS).build(); + } + + private static CloseableHttpClient createHttpClient() { + Timeout connectTimeout = Timeout.ofMilliseconds(CONNECT_TIMEOUT_MILLIS); + Timeout requestTimeout = Timeout.ofMilliseconds(REQUEST_TIMEOUT_MILLIS); + ConnectionConfig connectionConfig = + ConnectionConfig.custom() + .setConnectTimeout(connectTimeout) + .setSocketTimeout(requestTimeout) + .build(); + RequestConfig requestConfig = + RequestConfig.custom() + .setConnectionRequestTimeout(requestTimeout) + .setResponseTimeout(requestTimeout) + .build(); + return HttpClients.custom() + .setConnectionManager( + PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(connectionConfig) + .build()) + .setDefaultRequestConfig(requestConfig) + .disableAutomaticRetries() + .disableRedirectHandling() + .build(); + } + + private static final class HttpResult { + private final int statusCode; + private final byte[] body; + + private HttpResult(int statusCode, byte[] body) { + this.statusCode = statusCode; + this.body = body; + } + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitKeyData.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitKeyData.java new file mode 100644 index 00000000000..334222adf4a --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitKeyData.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.kms.transit; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +final class TransitKeyData { + + private final Boolean supportsEncryption; + private final Boolean supportsDecryption; + private final Boolean softDeleted; + + @JsonCreator + TransitKeyData( + @JsonProperty("supports_encryption") Boolean supportsEncryption, + @JsonProperty("supports_decryption") Boolean supportsDecryption, + @JsonProperty("soft_deleted") Boolean softDeleted) { + this.supportsEncryption = supportsEncryption; + this.supportsDecryption = supportsDecryption; + this.softDeleted = softDeleted; + } + + Boolean supportsEncryption() { + return supportsEncryption; + } + + Boolean supportsDecryption() { + return supportsDecryption; + } + + Boolean softDeleted() { + return softDeleted; + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitReadKeyRequest.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitReadKeyRequest.java new file mode 100644 index 00000000000..3b909a1c2ad --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitReadKeyRequest.java @@ -0,0 +1,35 @@ +/* + * 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.kms.transit; + +final class TransitReadKeyRequest { + + private final String keyId; + + TransitReadKeyRequest(String keyId) { + if (keyId == null || keyId.isEmpty()) { + throw new IllegalArgumentException("Transit key ID cannot be empty"); + } + this.keyId = keyId; + } + + String keyId() { + return keyId; + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitReadKeyResponse.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitReadKeyResponse.java new file mode 100644 index 00000000000..eed7bd7a337 --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/TransitReadKeyResponse.java @@ -0,0 +1,38 @@ +/* + * 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.kms.transit; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +final class TransitReadKeyResponse { + + private final TransitKeyData data; + + @JsonCreator + TransitReadKeyResponse(@JsonProperty("data") TransitKeyData data) { + this.data = data; + } + + TransitKeyData data() { + return data; + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsClient.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsClient.java new file mode 100644 index 00000000000..8f504f7cb69 --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsClient.java @@ -0,0 +1,99 @@ +/* + * 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.kms.transit; + +import java.net.URI; +import java.nio.file.Path; +import java.util.Optional; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.exceptions.ConnectionFailedException; + +final class VaultTransitKmsClient implements KmsClient { + + private final String source; + private final TransitApiClient apiClient; + + VaultTransitKmsClient(String source, URI serviceAddress, String transitMount, Path tokenFile) { + this.source = source; + this.apiClient = new TransitApiClient("Vault Transit", serviceAddress, transitMount, tokenFile); + } + + /** {@inheritDoc} */ + @Override + public KmsKeyProperties getKeyProperties(KmsReference reference) { + validateReference(reference); + + Optional response = + apiClient.readKey(new TransitReadKeyRequest(reference.keyId())); + if (!response.isPresent()) { + return missingProperties(reference); + } + + TransitKeyData data = response.get().data(); + if (data == null || data.supportsEncryption() == null || data.supportsDecryption() == null) { + throw malformedResponse(); + } + + return new VaultTransitKmsKeyProperties( + reference, true, true, data.supportsEncryption(), data.supportsDecryption()); + } + + /** {@inheritDoc} */ + @Override + public void close() { + apiClient.close(); + } + + private void validateReference(KmsReference reference) { + if (reference == null) { + throw new IllegalArgumentException("Vault Transit key reference cannot be null"); + } + if (reference.api() != KmsApi.VAULT_TRANSIT) { + throw new IllegalArgumentException( + String.format("KMS API %s does not match Vault Transit", reference.api())); + } + if (!source.equals(reference.source())) { + throw new IllegalArgumentException( + String.format( + "Vault Transit source %s does not match configured source %s", + reference.source(), source)); + } + validateKeyId(reference.keyId()); + } + + private static void validateKeyId(String keyId) { + if (keyId == null || keyId.trim().isEmpty()) { + throw new IllegalArgumentException("Vault Transit key ID cannot be blank"); + } + if (keyId.contains("/") || keyId.contains("\\") || ".".equals(keyId) || "..".equals(keyId)) { + throw new IllegalArgumentException(String.format("Invalid Vault Transit key ID: %s", keyId)); + } + } + + private static VaultTransitKmsKeyProperties missingProperties(KmsReference reference) { + return new VaultTransitKmsKeyProperties(reference, false, false, false, false); + } + + private static ConnectionFailedException malformedResponse() { + return new ConnectionFailedException("Vault Transit returned a malformed response"); + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsClientFactory.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsClientFactory.java new file mode 100644 index 00000000000..d65a9c7d4df --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsClientFactory.java @@ -0,0 +1,156 @@ +/* + * 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.kms.transit; + +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.apache.gravitino.annotation.DeveloperApi; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsConfigurationException; + +/** Creates clients for the HashiCorp Vault Transit secrets engine. */ +@DeveloperApi +public final class VaultTransitKmsClientFactory implements KmsClientFactory { + + /** Property containing the Vault HTTP(S) server base address. */ + public static final String SERVICE_ADDRESS = "endpoint.address"; + + /** Property containing the Vault Transit mount path. */ + public static final String TRANSIT_MOUNT = "endpoint.transitMount"; + + /** Property selecting token-file authentication. */ + public static final String CREDENTIAL_METHOD = "credential.method"; + + /** Property containing an absolute path to a Vault token file. */ + public static final String TOKEN_FILE = "credential.path"; + + /** Default Vault Transit mount path. */ + public static final String DEFAULT_TRANSIT_MOUNT = "transit"; + + private static final Set SUPPORTED_PROPERTIES = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList(SERVICE_ADDRESS, TRANSIT_MOUNT, CREDENTIAL_METHOD, TOKEN_FILE))); + + /** {@inheritDoc} */ + @Override + public KmsApi api() { + return KmsApi.VAULT_TRANSIT; + } + + /** {@inheritDoc} */ + @Override + public KmsClient create(String source, Map properties) { + validateSource(source); + if (properties == null) { + throw new KmsConfigurationException("Vault Transit properties cannot be null"); + } + for (String property : properties.keySet()) { + if (!SUPPORTED_PROPERTIES.contains(property)) { + throw new KmsConfigurationException("Unsupported Vault Transit property: %s", property); + } + } + + URI serviceAddress = parseServiceAddress(requireProperty(properties, SERVICE_ADDRESS)); + String credentialMethod = requireProperty(properties, CREDENTIAL_METHOD); + if (!"token_file".equals(credentialMethod)) { + throw new KmsConfigurationException( + "Unsupported Vault Transit credential method: %s", credentialMethod); + } + String transitMount = properties.get(TRANSIT_MOUNT); + if (transitMount == null) { + transitMount = DEFAULT_TRANSIT_MOUNT; + } else { + transitMount = transitMount.trim(); + } + validateTransitMount(transitMount); + Path tokenFile = parseTokenFile(requireProperty(properties, TOKEN_FILE)); + + return new VaultTransitKmsClient(source, serviceAddress, transitMount, tokenFile); + } + + private static String requireProperty(Map properties, String name) { + String value = properties.get(name); + if (value == null || value.trim().isEmpty()) { + throw new KmsConfigurationException("Missing required Vault Transit property: %s", name); + } + return value.trim(); + } + + private static URI parseServiceAddress(String value) { + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException e) { + throw new KmsConfigurationException(e, "Invalid Vault Transit endpoint address"); + } + + String path = uri.getPath(); + if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getQuery() != null + || uri.getFragment() != null + || !(path == null || path.isEmpty() || "/".equals(path))) { + throw new KmsConfigurationException( + "Vault Transit endpoint address must be an HTTP(S) server base address"); + } + + String address = uri.toString(); + return URI.create(address.endsWith("/") ? address.substring(0, address.length() - 1) : address); + } + + private static Path parseTokenFile(String value) { + Path path; + try { + path = Paths.get(value); + } catch (RuntimeException e) { + throw new KmsConfigurationException(e, "Invalid Vault Transit credential path"); + } + if (!path.isAbsolute()) { + throw new KmsConfigurationException("Vault Transit credential path must be an absolute path"); + } + return path; + } + + private static void validateSource(String source) { + if (source == null || source.trim().isEmpty()) { + throw new KmsConfigurationException("Vault Transit source cannot be blank"); + } + } + + private static void validateTransitMount(String mount) { + if (mount.isEmpty() || mount.startsWith("/") || mount.endsWith("/")) { + throw new KmsConfigurationException("Invalid Vault Transit mount path"); + } + for (String segment : mount.split("/", -1)) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { + throw new KmsConfigurationException("Invalid Vault Transit mount path"); + } + } + } +} diff --git a/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsKeyProperties.java b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsKeyProperties.java new file mode 100644 index 00000000000..d8ac48cbc62 --- /dev/null +++ b/bundles/transit/src/main/java/org/apache/gravitino/kms/transit/VaultTransitKmsKeyProperties.java @@ -0,0 +1,74 @@ +/* + * 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.kms.transit; + +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; + +final class VaultTransitKmsKeyProperties implements KmsKeyProperties { + + private final KmsReference reference; + private final boolean present; + private final boolean enabled; + private final boolean supportsWrapping; + private final boolean supportsUnwrapping; + + VaultTransitKmsKeyProperties( + KmsReference reference, + boolean present, + boolean enabled, + boolean supportsWrapping, + boolean supportsUnwrapping) { + this.reference = reference; + this.present = present; + this.enabled = enabled; + this.supportsWrapping = supportsWrapping; + this.supportsUnwrapping = supportsUnwrapping; + } + + /** {@inheritDoc} */ + @Override + public KmsReference reference() { + return reference; + } + + /** {@inheritDoc} */ + @Override + public boolean present() { + return present; + } + + /** {@inheritDoc} */ + @Override + public boolean enabled() { + return enabled; + } + + /** {@inheritDoc} */ + @Override + public boolean supportsWrapping() { + return supportsWrapping; + } + + /** {@inheritDoc} */ + @Override + public boolean supportsUnwrapping() { + return supportsUnwrapping; + } +} diff --git a/bundles/transit/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory b/bundles/transit/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory new file mode 100644 index 00000000000..c28f35c1025 --- /dev/null +++ b/bundles/transit/src/main/resources/META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory @@ -0,0 +1,21 @@ +# +# 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. +# + +org.apache.gravitino.kms.transit.OpenBaoTransitKmsClientFactory +org.apache.gravitino.kms.transit.VaultTransitKmsClientFactory diff --git a/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestOpenBaoTransitKmsClient.java b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestOpenBaoTransitKmsClient.java new file mode 100644 index 00000000000..17cbca85519 --- /dev/null +++ b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestOpenBaoTransitKmsClient.java @@ -0,0 +1,286 @@ +/* + * 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.kms.transit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsAuthenticationException; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientContract; +import org.apache.gravitino.exceptions.ConnectionFailedException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class TestOpenBaoTransitKmsClient extends TestKmsClientContract { + + private static final String SOURCE = "primary"; + private static final String USABLE_KEY = "customer key"; + private static final String MISSING_KEY = "missing-key"; + private static final String USABLE_RESPONSE = + "{\"data\":{\"type\":\"aes256-gcm96\",\"supports_encryption\":true," + + "\"supports_decryption\":true,\"soft_deleted\":false}}"; + + @TempDir private Path tempDir; + + private final AtomicInteger responseStatus = new AtomicInteger(200); + private final AtomicReference responseBody = new AtomicReference<>(USABLE_RESPONSE); + private final AtomicReference acceptedToken = new AtomicReference<>(); + private final List requests = new CopyOnWriteArrayList<>(); + + private HttpServer server; + private Path tokenFile; + private OpenBaoTransitKmsClient client; + + @BeforeEach + void startServer() throws IOException { + tokenFile = tempDir.resolve("openbao-read-only-token"); + Files.write(tokenFile, "read-only-token\n".getBytes(StandardCharsets.UTF_8)); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::respond); + server.start(); + client = createClient("custom/transit", tokenFile); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Override + protected KmsClient client() { + return client; + } + + @Override + protected KmsReference usableKey() { + return reference(USABLE_KEY); + } + + @Override + protected KmsReference missingKey() { + return reference(MISSING_KEY); + } + + @Test + void readsKeyPropertiesUsingMetadataOnlyGet() { + client.getKeyProperties(usableKey()); + + assertEquals(1, requests.size()); + RecordedRequest request = requests.get(0); + assertEquals("GET", request.method); + assertEquals("/v1/custom/transit/keys/customer%20key", request.rawPath); + assertEquals("read-only-token", request.token); + } + + @Test + void reportsSoftDeletedKeyAsMissing() { + responseBody.set( + "{\"data\":{\"supports_encryption\":true,\"supports_decryption\":true," + + "\"soft_deleted\":true}}"); + + KmsKeyProperties properties = client.getKeyProperties(usableKey()); + + assertFalse(properties.present()); + assertFalse(properties.enabled()); + assertFalse(properties.supportsWrapping()); + assertFalse(properties.supportsUnwrapping()); + } + + @Test + void treatsMissingSoftDeletedFieldAsEnabledForOlderServers() { + responseBody.set("{\"data\":{\"supports_encryption\":true,\"supports_decryption\":true}}"); + + assertTrue(client.getKeyProperties(usableKey()).enabled()); + } + + @Test + void mapsTransitOperationSupport() { + responseBody.set( + "{\"data\":{\"supports_encryption\":false,\"supports_decryption\":true," + + "\"soft_deleted\":false}}"); + + KmsKeyProperties properties = client.getKeyProperties(usableKey()); + + assertFalse(properties.supportsWrapping()); + assertTrue(properties.supportsUnwrapping()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 405, 429, 500, 502, 503}) + void mapsProviderErrorsToConnectionFailures(int statusCode) { + responseStatus.set(statusCode); + + assertThrows(ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + } + + @ParameterizedTest + @ValueSource(ints = {401, 403}) + void mapsRejectedCredentialsToAuthenticationFailures(int statusCode) { + responseStatus.set(statusCode); + + assertThrows(KmsAuthenticationException.class, () -> client.getKeyProperties(usableKey())); + } + + @ParameterizedTest + @ValueSource( + strings = { + "not-json", + "{}", + "{\"data\":{}}", + "{\"data\":{\"supports_encryption\":\"true\",\"supports_decryption\":true}}", + "{\"data\":{\"supports_encryption\":true,\"supports_decryption\":\"true\"}}", + "{\"data\":{\"supports_encryption\":true,\"supports_decryption\":true," + + "\"soft_deleted\":\"false\"}}" + }) + void mapsMalformedResponsesToConnectionFailures(String body) { + responseBody.set(body); + + assertThrows(ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + } + + @Test + void cachesTokenAcrossSuccessfulRequests() throws IOException { + client.getKeyProperties(usableKey()); + Files.write(tokenFile, "rotated-token".getBytes(StandardCharsets.UTF_8)); + client.getKeyProperties(usableKey()); + + assertEquals("read-only-token", requests.get(0).token); + assertEquals("read-only-token", requests.get(1).token); + } + + @Test + void reloadsTokenAndRetriesOnceAfterAuthenticationFailure() throws IOException { + client.getKeyProperties(usableKey()); + Files.write(tokenFile, "rotated-token".getBytes(StandardCharsets.UTF_8)); + acceptedToken.set("rotated-token"); + + assertTrue(client.getKeyProperties(usableKey()).present()); + + assertEquals(3, requests.size()); + assertEquals("read-only-token", requests.get(1).token); + assertEquals("rotated-token", requests.get(2).token); + } + + @Test + void loadsTokenLazilyOnFirstRequest() throws IOException { + Files.write(tokenFile, "before-first-request".getBytes(StandardCharsets.UTF_8)); + + client.getKeyProperties(usableKey()); + + assertEquals("before-first-request", requests.get(0).token); + } + + @Test + void closesPooledHttpClient() { + client.close(); + + assertThrows(ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + } + + @Test + void rejectsInvalidKeyIdWithoutCallingOpenBao() { + assertThrows( + IllegalArgumentException.class, () -> client.getKeyProperties(reference("nested/key"))); + assertTrue(requests.isEmpty()); + } + + @Test + void mapsUnreadableTokenAndUnavailableServiceToConnectionFailures() { + OpenBaoTransitKmsClient missingTokenClient = + createClient("transit", tempDir.resolve("missing-token")); + assertThrows( + ConnectionFailedException.class, () -> missingTokenClient.getKeyProperties(usableKey())); + + server.stop(0); + server = null; + assertThrows(ConnectionFailedException.class, () -> client.getKeyProperties(usableKey())); + } + + private OpenBaoTransitKmsClient createClient(String transitMount, Path clientTokenFile) { + return new OpenBaoTransitKmsClient( + SOURCE, + URI.create(String.format("http://127.0.0.1:%s", server.getAddress().getPort())), + transitMount, + clientTokenFile); + } + + private KmsReference reference(String keyId) { + return new KmsReference(KmsApi.OPENBAO_TRANSIT, SOURCE, keyId); + } + + private void respond(HttpExchange exchange) throws IOException { + requests.add( + new RecordedRequest( + exchange.getRequestMethod(), + exchange.getRequestURI().getRawPath(), + exchange.getRequestHeaders().getFirst("X-Vault-Token"))); + + String requestToken = exchange.getRequestHeaders().getFirst("X-Vault-Token"); + int status; + if (exchange.getRequestURI().getRawPath().endsWith("/" + MISSING_KEY)) { + status = 404; + } else if (acceptedToken.get() != null && !acceptedToken.get().equals(requestToken)) { + status = 403; + } else { + status = responseStatus.get(); + } + byte[] response = responseBody.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } + + private static final class RecordedRequest { + private final String method; + private final String rawPath; + private final String token; + + private RecordedRequest(String method, String rawPath, String token) { + this.method = method; + this.rawPath = rawPath; + this.token = token; + } + } +} diff --git a/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestOpenBaoTransitKmsClientFactory.java b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestOpenBaoTransitKmsClientFactory.java new file mode 100644 index 00000000000..d298220b9ba --- /dev/null +++ b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestOpenBaoTransitKmsClientFactory.java @@ -0,0 +1,173 @@ +/* + * 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.kms.transit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientFactoryContract; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestOpenBaoTransitKmsClientFactory extends TestKmsClientFactoryContract { + + private static final String SOURCE = "primary"; + + @TempDir private Path tempDir; + + private final AtomicReference requestedPath = new AtomicReference<>(); + + private HttpServer server; + private Path tokenFile; + + @BeforeEach + void startServer() throws IOException { + tokenFile = tempDir.resolve("openbao-token"); + Files.write(tokenFile, "read-only-token".getBytes(StandardCharsets.UTF_8)); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::respond); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Override + protected KmsClientFactory factory() { + return new OpenBaoTransitKmsClientFactory(); + } + + @Override + protected KmsApi expectedApi() { + return KmsApi.OPENBAO_TRANSIT; + } + + @Test + void createsWorkingClientWithDefaultMount() { + KmsClient client = factory().create(SOURCE, properties()); + + client.getKeyProperties(new KmsReference(KmsApi.OPENBAO_TRANSIT, SOURCE, "customer-key")); + + assertEquals("/v1/transit/keys/customer-key", requestedPath.get()); + } + + @Test + void createsWorkingClientWithCustomMount() { + Map properties = properties(); + properties.put(OpenBaoTransitKmsClientFactory.TRANSIT_MOUNT, "team/transit"); + KmsClient client = factory().create(SOURCE, properties); + + client.getKeyProperties(new KmsReference(KmsApi.OPENBAO_TRANSIT, SOURCE, "customer-key")); + + assertEquals("/v1/team/transit/keys/customer-key", requestedPath.get()); + } + + @Test + void rejectsMissingRequiredConfiguration() { + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, null)); + + Map missingAddress = properties(); + missingAddress.remove(OpenBaoTransitKmsClientFactory.SERVICE_ADDRESS); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, missingAddress)); + + Map missingToken = properties(); + missingToken.remove(OpenBaoTransitKmsClientFactory.TOKEN_FILE); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, missingToken)); + } + + @Test + void rejectsInvalidSourceAndUnknownConfiguration() { + assertThrows(IllegalArgumentException.class, () -> factory().create(" ", properties())); + + Map properties = properties(); + properties.put("token", "secret"); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + @Test + void rejectsInvalidServiceAddress() { + assertInvalidServiceAddress("file:///tmp/openbao"); + assertInvalidServiceAddress("http://user@localhost"); + assertInvalidServiceAddress("http://localhost/openbao"); + assertInvalidServiceAddress("http://localhost?namespace=team"); + assertInvalidServiceAddress("http://localhost#fragment"); + } + + @Test + void rejectsInvalidMountAndTokenFile() { + for (String mount : new String[] {"", "/transit", "transit/", "team//transit", ".", ".."}) { + Map properties = properties(); + properties.put(OpenBaoTransitKmsClientFactory.TRANSIT_MOUNT, mount); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + Map properties = properties(); + properties.put(OpenBaoTransitKmsClientFactory.TOKEN_FILE, "relative-token"); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + private Map properties() { + Map properties = new HashMap<>(); + properties.put( + OpenBaoTransitKmsClientFactory.SERVICE_ADDRESS, + String.format("http://127.0.0.1:%s", server.getAddress().getPort())); + properties.put(OpenBaoTransitKmsClientFactory.TOKEN_FILE, tokenFile.toString()); + properties.put(OpenBaoTransitKmsClientFactory.CREDENTIAL_METHOD, "token_file"); + return properties; + } + + private void assertInvalidServiceAddress(String address) { + Map properties = properties(); + properties.put(OpenBaoTransitKmsClientFactory.SERVICE_ADDRESS, address); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + private void respond(HttpExchange exchange) throws IOException { + requestedPath.set(exchange.getRequestURI().getRawPath()); + byte[] response = + ("{\"data\":{\"supports_encryption\":true,\"supports_decryption\":true," + + "\"soft_deleted\":false}}") + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } +} diff --git a/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestVaultTransitKmsClient.java b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestVaultTransitKmsClient.java new file mode 100644 index 00000000000..0c9ba82bfcd --- /dev/null +++ b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestVaultTransitKmsClient.java @@ -0,0 +1,119 @@ +/* + * 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.kms.transit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientContract; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestVaultTransitKmsClient extends TestKmsClientContract { + + private static final String SOURCE = "primary"; + private static final String USABLE_KEY = "customer-key"; + private static final String MISSING_KEY = "missing-key"; + + @TempDir private Path tempDir; + + private final AtomicReference requestedPath = new AtomicReference<>(); + private final AtomicReference requestedToken = new AtomicReference<>(); + + private HttpServer server; + private VaultTransitKmsClient client; + + @BeforeEach + void startServer() throws IOException { + Path tokenFile = tempDir.resolve("vault-token"); + Files.write(tokenFile, "read-only-token".getBytes(StandardCharsets.UTF_8)); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::respond); + server.start(); + client = + new VaultTransitKmsClient( + SOURCE, + URI.create(String.format("http://127.0.0.1:%s", server.getAddress().getPort())), + "transit", + tokenFile); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Override + protected KmsClient client() { + return client; + } + + @Override + protected KmsReference usableKey() { + return reference(USABLE_KEY); + } + + @Override + protected KmsReference missingKey() { + return reference(MISSING_KEY); + } + + @Test + void readsVaultTransitKeyMetadata() { + client.getKeyProperties(usableKey()); + + assertEquals("/v1/transit/keys/customer-key", requestedPath.get()); + assertEquals("read-only-token", requestedToken.get()); + } + + private KmsReference reference(String keyId) { + return new KmsReference(KmsApi.VAULT_TRANSIT, SOURCE, keyId); + } + + private void respond(HttpExchange exchange) throws IOException { + requestedPath.set(exchange.getRequestURI().getRawPath()); + requestedToken.set(exchange.getRequestHeaders().getFirst("X-Vault-Token")); + + boolean missing = exchange.getRequestURI().getRawPath().endsWith("/" + MISSING_KEY); + byte[] response = + ("{\"data\":{\"type\":\"aes256-gcm96\",\"supports_encryption\":true," + + "\"supports_decryption\":true}}") + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(missing ? 404 : 200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } +} diff --git a/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestVaultTransitKmsClientFactory.java b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestVaultTransitKmsClientFactory.java new file mode 100644 index 00000000000..c563da7844c --- /dev/null +++ b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/TestVaultTransitKmsClientFactory.java @@ -0,0 +1,185 @@ +/* + * 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.kms.transit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.apache.gravitino.encryption.kms.TestKmsClientFactoryContract; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestVaultTransitKmsClientFactory extends TestKmsClientFactoryContract { + + private static final String SOURCE = "primary"; + + @TempDir private Path tempDir; + + private final AtomicReference requestedPath = new AtomicReference<>(); + + private HttpServer server; + private Path tokenFile; + + @BeforeEach + void startServer() throws IOException { + tokenFile = tempDir.resolve("vault-token"); + Files.write(tokenFile, "read-only-token".getBytes(StandardCharsets.UTF_8)); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::respond); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + @Override + protected KmsClientFactory factory() { + return new VaultTransitKmsClientFactory(); + } + + @Override + protected KmsApi expectedApi() { + return KmsApi.VAULT_TRANSIT; + } + + @Test + void createsWorkingClientWithDefaultMount() { + KmsClient client = factory().create(SOURCE, properties()); + + client.getKeyProperties(new KmsReference(KmsApi.VAULT_TRANSIT, SOURCE, "customer-key")); + + assertEquals("/v1/transit/keys/customer-key", requestedPath.get()); + } + + @Test + void createsWorkingClientWithCustomMount() { + Map properties = properties(); + properties.put(VaultTransitKmsClientFactory.TRANSIT_MOUNT, "team/transit"); + KmsClient client = factory().create(SOURCE, properties); + + client.getKeyProperties(new KmsReference(KmsApi.VAULT_TRANSIT, SOURCE, "customer-key")); + + assertEquals("/v1/team/transit/keys/customer-key", requestedPath.get()); + } + + @Test + void serviceLoaderDiscoversTransitFactories() { + Map> factoryClasses = new EnumMap<>(KmsApi.class); + for (KmsClientFactory factory : ServiceLoader.load(KmsClientFactory.class)) { + factoryClasses.put(factory.api(), factory.getClass()); + } + + assertEquals(OpenBaoTransitKmsClientFactory.class, factoryClasses.get(KmsApi.OPENBAO_TRANSIT)); + assertEquals(VaultTransitKmsClientFactory.class, factoryClasses.get(KmsApi.VAULT_TRANSIT)); + } + + @Test + void rejectsMissingRequiredConfiguration() { + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, null)); + + Map missingAddress = properties(); + missingAddress.remove(VaultTransitKmsClientFactory.SERVICE_ADDRESS); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, missingAddress)); + + Map missingToken = properties(); + missingToken.remove(VaultTransitKmsClientFactory.TOKEN_FILE); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, missingToken)); + } + + @Test + void rejectsInvalidSourceAndUnknownConfiguration() { + assertThrows(IllegalArgumentException.class, () -> factory().create(" ", properties())); + + Map properties = properties(); + properties.put("token", "secret"); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + @Test + void rejectsInvalidServiceAddress() { + assertInvalidServiceAddress("file:///tmp/vault"); + assertInvalidServiceAddress("http://user@localhost"); + assertInvalidServiceAddress("http://localhost/vault"); + assertInvalidServiceAddress("http://localhost?namespace=team"); + assertInvalidServiceAddress("http://localhost#fragment"); + } + + @Test + void rejectsInvalidMountAndTokenFile() { + for (String mount : new String[] {"", "/transit", "transit/", "team//transit", ".", ".."}) { + Map properties = properties(); + properties.put(VaultTransitKmsClientFactory.TRANSIT_MOUNT, mount); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + Map properties = properties(); + properties.put(VaultTransitKmsClientFactory.TOKEN_FILE, "relative-token"); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + private Map properties() { + Map properties = new HashMap<>(); + properties.put( + VaultTransitKmsClientFactory.SERVICE_ADDRESS, + String.format("http://127.0.0.1:%s", server.getAddress().getPort())); + properties.put(VaultTransitKmsClientFactory.TOKEN_FILE, tokenFile.toString()); + properties.put(VaultTransitKmsClientFactory.CREDENTIAL_METHOD, "token_file"); + return properties; + } + + private void assertInvalidServiceAddress(String address) { + Map properties = properties(); + properties.put(VaultTransitKmsClientFactory.SERVICE_ADDRESS, address); + assertThrows(IllegalArgumentException.class, () -> factory().create(SOURCE, properties)); + } + + private void respond(HttpExchange exchange) throws IOException { + requestedPath.set(exchange.getRequestURI().getRawPath()); + byte[] response = + ("{\"data\":{\"supports_encryption\":true,\"supports_decryption\":true}}") + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } +} diff --git a/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/AbstractTransitKmsClientIT.java b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/AbstractTransitKmsClientIT.java new file mode 100644 index 00000000000..f5db65d6f42 --- /dev/null +++ b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/AbstractTransitKmsClientIT.java @@ -0,0 +1,154 @@ +/* + * 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.kms.transit.integration.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClient; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.encryption.kms.KmsKeyProperties; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +abstract class AbstractTransitKmsClientIT { + + private static final int API_PORT = 8200; + private static final String ROOT_TOKEN = "gravitino-kms-test-root-token"; + private static final String SOURCE = "test"; + private static final String USABLE_KEY = "usable-key"; + private static final String SIGNING_KEY = "signing-key"; + + private GenericContainer container; + private Path tokenFile; + + @BeforeAll + void startBackend() throws Exception { + tokenFile = Files.createTempFile("gravitino-transit-token-", ".txt"); + Files.write(tokenFile, ROOT_TOKEN.getBytes(StandardCharsets.UTF_8)); + + container = + new GenericContainer<>(DockerImageName.parse(image())) + .withCommand( + "server", + "-dev", + String.format("-dev-root-token-id=%s", ROOT_TOKEN), + "-dev-listen-address=0.0.0.0:8200") + .withEnv(addressEnvironmentVariable(), "http://127.0.0.1:8200") + .withEnv(tokenEnvironmentVariable(), ROOT_TOKEN) + .withExposedPorts(API_PORT) + .waitingFor( + Wait.forHttp("/v1/sys/health") + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(2))); + container.start(); + + runCommand("secrets", "enable", "transit"); + runCommand("write", "-f", String.format("transit/keys/%s", USABLE_KEY)); + runCommand("write", "-f", String.format("transit/keys/%s", SIGNING_KEY), "type=ecdsa-p256"); + } + + @AfterAll + void stopBackend() throws Exception { + if (container != null) { + container.stop(); + } + if (tokenFile != null) { + Files.deleteIfExists(tokenFile); + } + } + + @Test + void inspectsRealTransitKeyMetadata() { + try (KmsClient client = factory().create(SOURCE, properties())) { + KmsReference usableReference = new KmsReference(api(), SOURCE, USABLE_KEY); + KmsKeyProperties usable = client.getKeyProperties(usableReference); + + assertEquals(usableReference, usable.reference()); + assertTrue(usable.present()); + assertTrue(usable.enabled()); + assertTrue(usable.supportsWrapping()); + assertTrue(usable.supportsUnwrapping()); + + KmsKeyProperties signing = + client.getKeyProperties(new KmsReference(api(), SOURCE, SIGNING_KEY)); + assertTrue(signing.present()); + assertTrue(signing.enabled()); + assertFalse(signing.supportsWrapping()); + assertFalse(signing.supportsUnwrapping()); + + KmsKeyProperties missing = + client.getKeyProperties(new KmsReference(api(), SOURCE, "missing-key")); + assertFalse(missing.present()); + assertFalse(missing.enabled()); + assertFalse(missing.supportsWrapping()); + assertFalse(missing.supportsUnwrapping()); + } + } + + protected abstract String image(); + + protected abstract String executable(); + + protected abstract String addressEnvironmentVariable(); + + protected abstract String tokenEnvironmentVariable(); + + protected abstract KmsApi api(); + + protected abstract KmsClientFactory factory(); + + private Map properties() { + Map properties = new HashMap<>(); + properties.put( + "endpoint.address", + String.format("http://%s:%s", container.getHost(), container.getMappedPort(API_PORT))); + properties.put("credential.method", "token_file"); + properties.put("credential.path", tokenFile.toString()); + return properties; + } + + private void runCommand(String... arguments) throws Exception { + String[] command = new String[arguments.length + 1]; + command[0] = executable(); + System.arraycopy(arguments, 0, command, 1, arguments.length); + Container.ExecResult result = container.execInContainer(command); + assertEquals( + 0, + result.getExitCode(), + () -> + String.format( + "Transit fixture command failed: stdout=%s, stderr=%s", + result.getStdout(), result.getStderr())); + } +} diff --git a/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/OpenBaoTransitKmsClientIT.java b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/OpenBaoTransitKmsClientIT.java new file mode 100644 index 00000000000..06c1871175d --- /dev/null +++ b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/OpenBaoTransitKmsClientIT.java @@ -0,0 +1,66 @@ +/* + * 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.kms.transit.integration.test; + +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.kms.transit.OpenBaoTransitKmsClientFactory; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.TestInstance; + +@Tag("gravitino-docker-test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class OpenBaoTransitKmsClientIT extends AbstractTransitKmsClientIT { + + private static final String DEFAULT_IMAGE = "openbao/openbao:2.6.0"; + private static final String IMAGE_ENVIRONMENT_VARIABLE = "GRAVITINO_OPENBAO_DOCKER_IMAGE"; + + @Override + protected String image() { + String configuredImage = System.getenv(IMAGE_ENVIRONMENT_VARIABLE); + return configuredImage == null || configuredImage.trim().isEmpty() + ? DEFAULT_IMAGE + : configuredImage.trim(); + } + + @Override + protected String executable() { + return "bao"; + } + + @Override + protected String addressEnvironmentVariable() { + return "BAO_ADDR"; + } + + @Override + protected String tokenEnvironmentVariable() { + return "BAO_TOKEN"; + } + + @Override + protected KmsApi api() { + return KmsApi.OPENBAO_TRANSIT; + } + + @Override + protected KmsClientFactory factory() { + return new OpenBaoTransitKmsClientFactory(); + } +} diff --git a/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/VaultTransitKmsClientIT.java b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/VaultTransitKmsClientIT.java new file mode 100644 index 00000000000..1e2addac6e3 --- /dev/null +++ b/bundles/transit/src/test/java/org/apache/gravitino/kms/transit/integration/test/VaultTransitKmsClientIT.java @@ -0,0 +1,66 @@ +/* + * 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.kms.transit.integration.test; + +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClientFactory; +import org.apache.gravitino.kms.transit.VaultTransitKmsClientFactory; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.TestInstance; + +@Tag("gravitino-docker-test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class VaultTransitKmsClientIT extends AbstractTransitKmsClientIT { + + private static final String DEFAULT_IMAGE = "hashicorp/vault:2.0.3"; + private static final String IMAGE_ENVIRONMENT_VARIABLE = "GRAVITINO_VAULT_DOCKER_IMAGE"; + + @Override + protected String image() { + String configuredImage = System.getenv(IMAGE_ENVIRONMENT_VARIABLE); + return configuredImage == null || configuredImage.trim().isEmpty() + ? DEFAULT_IMAGE + : configuredImage.trim(); + } + + @Override + protected String executable() { + return "vault"; + } + + @Override + protected String addressEnvironmentVariable() { + return "VAULT_ADDR"; + } + + @Override + protected String tokenEnvironmentVariable() { + return "VAULT_TOKEN"; + } + + @Override + protected KmsApi api() { + return KmsApi.VAULT_TRANSIT; + } + + @Override + protected KmsClientFactory factory() { + return new VaultTransitKmsClientFactory(); + } +} diff --git a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java index 11c12911293..56a3cdfcf2c 100644 --- a/core/src/main/java/org/apache/gravitino/GravitinoEnv.java +++ b/core/src/main/java/org/apache/gravitino/GravitinoEnv.java @@ -56,6 +56,7 @@ import org.apache.gravitino.catalog.ViewNormalizeDispatcher; import org.apache.gravitino.catalog.ViewOperationDispatcher; import org.apache.gravitino.credential.CredentialOperationDispatcher; +import org.apache.gravitino.encryption.kms.KmsClientRegistry; import org.apache.gravitino.hook.AccessControlHookDispatcher; import org.apache.gravitino.hook.CatalogHookDispatcher; import org.apache.gravitino.hook.FilesetHookDispatcher; @@ -154,6 +155,8 @@ public class GravitinoEnv { private CredentialOperationDispatcher credentialOperationDispatcher; + private KmsClientRegistry kmsClientRegistry; + private TagDispatcher tagDispatcher; private PolicyDispatcher policyDispatcher; @@ -222,7 +225,13 @@ public void initializeFullComponents(Config config) { FileFetcher.get().initialize(config.get(Configs.BLOCK_UNSAFE_REMOTE_URI)); this.manageFullComponents = true; initBaseComponents(); - initGravitinoServerComponents(); + try { + this.kmsClientRegistry = new KmsClientRegistry(config); + initGravitinoServerComponents(); + } catch (RuntimeException e) { + closeKmsClientRegistry(); + throw e; + } LOG.info("Gravitino full environment is initialized."); } @@ -418,6 +427,18 @@ public CredentialOperationDispatcher credentialOperationDispatcher() { return credentialOperationDispatcher; } + /** + * Get the metadata-only KMS client registry associated with the full Gravitino environment. + * + * @return The KMS client registry instance. + * @throws IllegalStateException if full components have not been initialized + */ + public KmsClientRegistry kmsClientRegistry() { + Preconditions.checkState( + kmsClientRegistry != null, "GravitinoEnv full components are not initialized."); + return kmsClientRegistry; + } + /** * Get the IdGenerator associated with the Gravitino environment. * @@ -586,6 +607,8 @@ public void start() { public void shutdown() { LOG.info("Shutting down Gravitino Environment..."); + closeKmsClientRegistry(); + if (entityStore != null) { try { entityStore.close(); @@ -638,6 +661,18 @@ public void shutdown() { LOG.info("Gravitino Environment is shut down."); } + private void closeKmsClientRegistry() { + KmsClientRegistry registry = kmsClientRegistry; + kmsClientRegistry = null; + if (registry != null) { + try { + registry.close(); + } catch (RuntimeException e) { + LOG.warn("Failed to close KmsClientRegistry.", e); + } + } + } + private void initBaseComponents() { this.metricsSystem = new MetricsSystem(); metricsSystem.register(new JVMMetricsSource()); diff --git a/core/src/main/java/org/apache/gravitino/encryption/kms/KmsClientRegistry.java b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsClientRegistry.java new file mode 100644 index 00000000000..aa121241c5f --- /dev/null +++ b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsClientRegistry.java @@ -0,0 +1,207 @@ +/* + * 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.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import org.apache.gravitino.Config; + +/** Creates server-private KMS clients and dispatches key inspection by configured source. */ +public final class KmsClientRegistry implements KmsClient { + + private final ReadWriteLock lifecycleLock = new ReentrantReadWriteLock(); + private final Map clients; + private boolean closed; + + /** + * Loads available {@link KmsClientFactory} implementations and creates configured clients. + * + * @param config Gravitino server configuration + * @throws IllegalArgumentException if configuration or factory discovery is invalid + */ + public KmsClientRegistry(Config config) { + this(config, loadFactories()); + } + + KmsClientRegistry(Config config, Iterable factories) { + if (factories == null) { + throw new IllegalArgumentException("KMS client factories cannot be null"); + } + + KmsConfig kmsConfig = new KmsConfig(config); + Map factoriesByApi = indexFactories(factories); + this.clients = createClients(kmsConfig.sources(), factoriesByApi); + } + + /** + * Dispatches the request to the client configured for the reference source. + * + * @param reference key to inspect + * @return normalized key properties + * @throws IllegalArgumentException if the source is unknown or configured for another API + * @throws IllegalStateException if the registry is closed or a provider violates its contract + */ + @Override + public KmsKeyProperties getKeyProperties(KmsReference reference) { + Lock readLock = lifecycleLock.readLock(); + readLock.lock(); + try { + if (closed) { + throw new IllegalStateException("KMS client registry is closed"); + } + if (reference == null) { + throw new IllegalArgumentException("KMS reference cannot be null"); + } + + ConfiguredClient configuredClient = clients.get(reference.source()); + if (configuredClient == null) { + throw new IllegalArgumentException( + String.format("Unknown KMS source '%s'", reference.source())); + } + if (configuredClient.api != reference.api()) { + throw new IllegalArgumentException( + String.format( + "KMS source '%s' uses API '%s', not '%s'", + reference.source(), configuredClient.api.wireValue(), reference.api().wireValue())); + } + + KmsKeyProperties properties = configuredClient.client.getKeyProperties(reference); + if (properties == null || !reference.equals(properties.reference())) { + throw new IllegalStateException( + String.format( + "KMS client for source '%s' returned properties for a different reference", + reference.source())); + } + return properties; + } finally { + readLock.unlock(); + } + } + + /** Closes all configured clients. This operation is idempotent. */ + @Override + public void close() { + Lock writeLock = lifecycleLock.writeLock(); + writeLock.lock(); + try { + if (closed) { + return; + } + closed = true; + RuntimeException failure = closeClients(new ArrayList<>(clients.values())); + if (failure != null) { + throw failure; + } + } finally { + writeLock.unlock(); + } + } + + private static Map indexFactories( + Iterable factories) { + Map factoriesByApi = new EnumMap<>(KmsApi.class); + for (KmsClientFactory factory : factories) { + if (factory == null || factory.api() == null) { + throw new IllegalArgumentException("KMS client factory and its API cannot be null"); + } + KmsClientFactory existing = factoriesByApi.putIfAbsent(factory.api(), factory); + if (existing != null) { + throw new IllegalArgumentException( + String.format( + "Multiple KMS client factories support API '%s'", factory.api().wireValue())); + } + } + return factoriesByApi; + } + + private static Iterable loadFactories() { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = KmsClientRegistry.class.getClassLoader(); + } + return ServiceLoader.load(KmsClientFactory.class, classLoader); + } + + private static Map createClients( + Map sourceConfigs, + Map factoriesByApi) { + Map clients = new LinkedHashMap<>(); + try { + sourceConfigs.forEach( + (source, sourceConfig) -> { + KmsClientFactory factory = factoriesByApi.get(sourceConfig.api()); + if (factory == null) { + throw new IllegalArgumentException( + String.format( + "No KMS client factory supports API '%s' for source '%s'", + sourceConfig.api().wireValue(), source)); + } + KmsClient client = factory.create(source, sourceConfig.properties()); + if (client == null) { + throw new IllegalStateException( + String.format( + "KMS client factory for API '%s' returned null", + sourceConfig.api().wireValue())); + } + clients.put(source, new ConfiguredClient(sourceConfig.api(), client)); + }); + return Collections.unmodifiableMap(clients); + } catch (RuntimeException | Error e) { + RuntimeException closeFailure = closeClients(new ArrayList<>(clients.values())); + if (closeFailure != null) { + e.addSuppressed(closeFailure); + } + throw e; + } + } + + private static RuntimeException closeClients(List clients) { + RuntimeException failure = null; + for (int index = clients.size() - 1; index >= 0; index--) { + try { + clients.get(index).client.close(); + } catch (RuntimeException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + return failure; + } + + private static final class ConfiguredClient { + private final KmsApi api; + private final KmsClient client; + + private ConfiguredClient(KmsApi api, KmsClient client) { + this.api = api; + this.client = client; + } + } +} diff --git a/core/src/main/java/org/apache/gravitino/encryption/kms/KmsConfig.java b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsConfig.java new file mode 100644 index 00000000000..ebc9baa55a0 --- /dev/null +++ b/core/src/main/java/org/apache/gravitino/encryption/kms/KmsConfig.java @@ -0,0 +1,152 @@ +/* + * 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.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.gravitino.Config; + +final class KmsConfig { + + static final String KMS_CONFIG_PREFIX = "gravitino.kms."; + static final String KMS_SOURCES = KMS_CONFIG_PREFIX + "sources"; + + private static final String SOURCES = "sources"; + private static final String SOURCE_PREFIX = "source."; + private static final String API = "api"; + private static final Pattern SOURCE_NAME_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]*"); + private static final Pattern SOURCE_PROPERTY_PATTERN = + Pattern.compile("source\\.([A-Za-z0-9][A-Za-z0-9_-]*)\\.(.+)"); + + private final Map sources; + + KmsConfig(Config config) { + if (config == null) { + throw new KmsConfigurationException("Gravitino configuration cannot be null"); + } + + Map values = config.getConfigsWithPrefix(KMS_CONFIG_PREFIX); + List configuredSources = parseSources(values.get(SOURCES)); + validateKeys(values, configuredSources); + this.sources = parseSourceConfigs(values, configuredSources); + } + + Map sources() { + return sources; + } + + private static List parseSources(String value) { + if (value == null || value.trim().isEmpty()) { + return Collections.emptyList(); + } + + List sources = new ArrayList<>(); + Set uniqueSources = new LinkedHashSet<>(); + for (String item : value.split(",", -1)) { + String source = item.trim(); + if (!SOURCE_NAME_PATTERN.matcher(source).matches()) { + throw new KmsConfigurationException( + "Invalid KMS source name '%s' in %s", source, KMS_SOURCES); + } + if (!uniqueSources.add(source)) { + throw new KmsConfigurationException("Duplicate KMS source '%s' in %s", source, KMS_SOURCES); + } + sources.add(source); + } + return Collections.unmodifiableList(sources); + } + + private static void validateKeys(Map values, List configuredSources) { + Set sourceSet = new LinkedHashSet<>(configuredSources); + for (String key : values.keySet()) { + if (SOURCES.equals(key)) { + continue; + } + + Matcher matcher = SOURCE_PROPERTY_PATTERN.matcher(key); + if (!matcher.matches()) { + throw new KmsConfigurationException( + "Invalid KMS configuration key '%s%s'", KMS_CONFIG_PREFIX, key); + } + if (!sourceSet.contains(matcher.group(1))) { + throw new KmsConfigurationException( + "KMS configuration references unlisted source '%s'", matcher.group(1)); + } + } + } + + private static Map parseSourceConfigs( + Map values, List configuredSources) { + Map sourceConfigs = new LinkedHashMap<>(); + + for (String source : configuredSources) { + String propertyPrefix = SOURCE_PREFIX + source + "."; + String apiKey = propertyPrefix + API; + String apiValue = values.get(apiKey); + if (apiValue == null || apiValue.trim().isEmpty()) { + throw new KmsConfigurationException( + "KMS API property '%s%s' cannot be blank", KMS_CONFIG_PREFIX, apiKey); + } + KmsApi api; + try { + api = KmsApi.fromWireValue(apiValue); + } catch (IllegalArgumentException e) { + throw new KmsConfigurationException( + e, "Invalid KMS API property '%s%s': %s", KMS_CONFIG_PREFIX, apiKey, e.getMessage()); + } + + Map properties = new LinkedHashMap<>(); + values.forEach( + (key, value) -> { + if (key.startsWith(propertyPrefix) && !apiKey.equals(key)) { + String sourceProperty = key.substring(propertyPrefix.length()); + properties.put(sourceProperty, value); + } + }); + sourceConfigs.put(source, new SourceConfig(api, properties)); + } + + return Collections.unmodifiableMap(sourceConfigs); + } + + static final class SourceConfig { + private final KmsApi api; + private final Map properties; + + private SourceConfig(KmsApi api, Map properties) { + this.api = api; + this.properties = Collections.unmodifiableMap(new LinkedHashMap<>(properties)); + } + + KmsApi api() { + return api; + } + + Map properties() { + return properties; + } + } +} diff --git a/core/src/test/java/org/apache/gravitino/TestGravitinoEnvKmsClientRegistry.java b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvKmsClientRegistry.java new file mode 100644 index 00000000000..538abdea957 --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/TestGravitinoEnvKmsClientRegistry.java @@ -0,0 +1,51 @@ +/* + * 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; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.gravitino.encryption.kms.KmsApi; +import org.apache.gravitino.encryption.kms.KmsClientRegistry; +import org.apache.gravitino.encryption.kms.KmsReference; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TestGravitinoEnvKmsClientRegistry { + + @Test + void testEmptyRegistryIsOptionalAndClosedWithEnvironment() throws IllegalAccessException { + TestGravitinoEnv env = new TestGravitinoEnv(); + Assertions.assertThrows(IllegalStateException.class, env::kmsClientRegistry); + + KmsClientRegistry registry = new KmsClientRegistry(new Config(false) {}); + FieldUtils.writeField(env, "kmsClientRegistry", registry, true); + + Assertions.assertSame(registry, env.kmsClientRegistry()); + KmsReference reference = new KmsReference(KmsApi.AWS_KMS, "missing", "key"); + Assertions.assertThrows( + IllegalArgumentException.class, () -> registry.getKeyProperties(reference)); + + env.shutdown(); + + Assertions.assertThrows(IllegalStateException.class, env::kmsClientRegistry); + Assertions.assertThrows( + IllegalStateException.class, () -> registry.getKeyProperties(reference)); + } + + private static final class TestGravitinoEnv extends GravitinoEnv {} +} diff --git a/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClientRegistry.java b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClientRegistry.java new file mode 100644 index 00000000000..67450744eaf --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsClientRegistry.java @@ -0,0 +1,503 @@ +/* + * 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.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.gravitino.Config; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestKmsClientRegistry { + + @Test + void testCreatesAndDispatchesConfiguredClients() { + RecordingFactory awsFactory = new RecordingFactory(KmsApi.AWS_KMS); + RecordingFactory gcpFactory = new RecordingFactory(KmsApi.GOOGLE_CLOUD_KMS); + KmsClientRegistry registry = + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary,analytics", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.primary.endpoint.region", "us-west-2", + "gravitino.kms.source.analytics.api", "google-cloud-kms", + "gravitino.kms.source.analytics.endpoint.project", "data-project"), + List.of(awsFactory, gcpFactory)); + + KmsReference awsReference = new KmsReference(KmsApi.AWS_KMS, "primary", "alias/orders"); + KmsReference gcpReference = + new KmsReference( + KmsApi.GOOGLE_CLOUD_KMS, "analytics", "projects/p/locations/l/keyRings/r/cryptoKeys/k"); + + Assertions.assertEquals(awsReference, registry.getKeyProperties(awsReference).reference()); + Assertions.assertEquals(awsReference, registry.getKeyProperties(awsReference).reference()); + Assertions.assertEquals(gcpReference, registry.getKeyProperties(gcpReference).reference()); + Assertions.assertEquals(Map.of("endpoint.region", "us-west-2"), awsFactory.properties); + Assertions.assertEquals(Map.of("endpoint.project", "data-project"), gcpFactory.properties); + Assertions.assertEquals("alias/orders", awsFactory.providerKeyId); + Assertions.assertEquals( + "projects/p/locations/l/keyRings/r/cryptoKeys/k", gcpFactory.providerKeyId); + Assertions.assertEquals("primary", awsFactory.source); + Assertions.assertEquals("analytics", gcpFactory.source); + Assertions.assertEquals(1, awsFactory.createCount.get()); + Assertions.assertEquals(1, gcpFactory.createCount.get()); + } + + @Test + void testRejectsUnknownSourceAndApiMismatch() { + KmsClientRegistry registry = + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary", + "gravitino.kms.source.primary.api", "aws-kms"), + List.of(new RecordingFactory(KmsApi.AWS_KMS))); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> registry.getKeyProperties(new KmsReference(KmsApi.AWS_KMS, "other", "key"))); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + registry.getKeyProperties(new KmsReference(KmsApi.GOOGLE_CLOUD_KMS, "primary", "key"))); + Assertions.assertThrows(IllegalArgumentException.class, () -> registry.getKeyProperties(null)); + } + + @Test + void testCreatesMultipleSourcesForSameApi() { + RecordingFactory factory = new RecordingFactory(KmsApi.AZURE_KEY_VAULT); + KmsClientRegistry registry = + new KmsClientRegistry( + config( + "gravitino.kms.sources", "azure-eu,azure-us", + "gravitino.kms.source.azure-eu.api", "azure-key-vault", + "gravitino.kms.source.azure-us.api", "azure-key-vault"), + List.of(factory)); + + Assertions.assertEquals( + "azure-eu", + registry + .getKeyProperties(new KmsReference(KmsApi.AZURE_KEY_VAULT, "azure-eu", "primary")) + .reference() + .source()); + Assertions.assertEquals( + "azure-us", + registry + .getKeyProperties(new KmsReference(KmsApi.AZURE_KEY_VAULT, "azure-us", "primary")) + .reference() + .source()); + } + + @Test + void testRejectsMissingDuplicateAndInvalidFactories() { + Config awsConfig = + config( + "gravitino.kms.sources", "primary", + "gravitino.kms.source.primary.api", "aws-kms"); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> new KmsClientRegistry(awsConfig, List.of())); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + new KmsClientRegistry( + awsConfig, + List.of( + new RecordingFactory(KmsApi.AWS_KMS), new RecordingFactory(KmsApi.AWS_KMS)))); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new KmsClientRegistry(awsConfig, java.util.Arrays.asList((KmsClientFactory) null))); + Assertions.assertThrows( + IllegalArgumentException.class, () -> new KmsClientRegistry(awsConfig, null)); + } + + @Test + void testPublicConstructorUsesContextClassLoader(@TempDir Path tempDirectory) throws Exception { + Path serviceFile = + tempDirectory.resolve( + "META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory"); + Files.createDirectories(serviceFile.getParent()); + Files.write(serviceFile, ServiceLoadedFactory.class.getName().getBytes(StandardCharsets.UTF_8)); + + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader serviceClassLoader = + new URLClassLoader(new URL[] {tempDirectory.toUri().toURL()}, originalClassLoader)) { + Thread.currentThread().setContextClassLoader(serviceClassLoader); + try (KmsClientRegistry registry = + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary", + "gravitino.kms.source.primary.api", "aws-kms"))) { + KmsReference reference = new KmsReference(KmsApi.AWS_KMS, "primary", "key"); + Assertions.assertEquals(reference, registry.getKeyProperties(reference).reference()); + } + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + @Test + void testRejectsInvalidProviderResults() { + KmsReference reference = new KmsReference(KmsApi.AWS_KMS, "primary", "key"); + Config awsConfig = + config( + "gravitino.kms.sources", "primary", + "gravitino.kms.source.primary.api", "aws-kms"); + + KmsClientFactory nullResultFactory = + factory(KmsApi.AWS_KMS, (source, properties) -> ignored -> null); + KmsClientRegistry nullResultRegistry = + new KmsClientRegistry(awsConfig, List.of(nullResultFactory)); + Assertions.assertThrows( + IllegalStateException.class, () -> nullResultRegistry.getKeyProperties(reference)); + + KmsReference otherReference = new KmsReference(KmsApi.AWS_KMS, "primary", "other"); + KmsClientFactory wrongReferenceFactory = + factory(KmsApi.AWS_KMS, (source, properties) -> ignored -> new Properties(otherReference)); + KmsClientRegistry wrongReferenceRegistry = + new KmsClientRegistry(awsConfig, List.of(wrongReferenceFactory)); + Assertions.assertThrows( + IllegalStateException.class, () -> wrongReferenceRegistry.getKeyProperties(reference)); + + KmsClientFactory nullClientFactory = factory(KmsApi.AWS_KMS, (source, properties) -> null); + Assertions.assertThrows( + IllegalStateException.class, + () -> new KmsClientRegistry(awsConfig, List.of(nullClientFactory))); + } + + @Test + void testClosesClientsInReverseOrderAndIsIdempotent() { + List closeOrder = new ArrayList<>(); + CloseTrackingFactory awsFactory = + new CloseTrackingFactory(KmsApi.AWS_KMS, "aws", closeOrder, null); + CloseTrackingFactory gcpFactory = + new CloseTrackingFactory(KmsApi.GOOGLE_CLOUD_KMS, "gcp", closeOrder, null); + KmsClientRegistry registry = + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary,analytics", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.analytics.api", "google-cloud-kms"), + List.of(awsFactory, gcpFactory)); + + registry.close(); + registry.close(); + + Assertions.assertEquals(List.of("gcp", "aws"), closeOrder); + Assertions.assertEquals(1, awsFactory.closeCount.get()); + Assertions.assertEquals(1, gcpFactory.closeCount.get()); + Assertions.assertThrows( + IllegalStateException.class, + () -> registry.getKeyProperties(new KmsReference(KmsApi.AWS_KMS, "primary", "key"))); + } + + @Test + void testClosesCreatedClientsAfterPartialInitializationFailure() { + CloseTrackingFactory awsFactory = + new CloseTrackingFactory(KmsApi.AWS_KMS, "aws", new ArrayList<>(), null); + KmsClientFactory failingFactory = + factory( + KmsApi.GOOGLE_CLOUD_KMS, + (source, properties) -> { + throw new IllegalArgumentException("invalid GCP configuration"); + }); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary,analytics", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.analytics.api", "google-cloud-kms"), + List.of(awsFactory, failingFactory))); + Assertions.assertEquals(1, awsFactory.closeCount.get()); + } + + @Test + void testPreservesInitializationFailureWhenCleanupFails() { + RuntimeException closeFailure = new IllegalStateException("close failed"); + CloseTrackingFactory awsFactory = + new CloseTrackingFactory(KmsApi.AWS_KMS, "aws", new ArrayList<>(), closeFailure); + IllegalArgumentException creationFailure = + new IllegalArgumentException("invalid GCP configuration"); + KmsClientFactory failingFactory = + factory( + KmsApi.GOOGLE_CLOUD_KMS, + (source, properties) -> { + throw creationFailure; + }); + + IllegalArgumentException exception = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary,analytics", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.analytics.api", "google-cloud-kms"), + List.of(awsFactory, failingFactory))); + + Assertions.assertSame(creationFailure, exception); + Assertions.assertArrayEquals(new Throwable[] {closeFailure}, exception.getSuppressed()); + } + + @Test + void testDispatchesDifferentSourcesConcurrently() throws Exception { + CountDownLatch entered = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + KmsClientFactory awsFactory = blockingFactory(KmsApi.AWS_KMS, entered, release); + KmsClientFactory gcpFactory = blockingFactory(KmsApi.GOOGLE_CLOUD_KMS, entered, release); + KmsClientRegistry registry = + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary,analytics", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.analytics.api", "google-cloud-kms"), + List.of(awsFactory, gcpFactory)); + ExecutorService executor = Executors.newFixedThreadPool(2); + + try { + Future awsResult = + executor.submit( + () -> registry.getKeyProperties(new KmsReference(KmsApi.AWS_KMS, "primary", "key"))); + Future gcpResult = + executor.submit( + () -> + registry.getKeyProperties( + new KmsReference(KmsApi.GOOGLE_CLOUD_KMS, "analytics", "key"))); + + Assertions.assertTrue(entered.await(5, TimeUnit.SECONDS)); + release.countDown(); + Assertions.assertEquals("primary", awsResult.get(5, TimeUnit.SECONDS).reference().source()); + Assertions.assertEquals("analytics", gcpResult.get(5, TimeUnit.SECONDS).reference().source()); + } finally { + release.countDown(); + executor.shutdownNow(); + registry.close(); + } + } + + @Test + void testAggregatesCloseFailures() { + RuntimeException awsFailure = new IllegalStateException("aws close failed"); + RuntimeException gcpFailure = new IllegalStateException("gcp close failed"); + KmsClientRegistry registry = + new KmsClientRegistry( + config( + "gravitino.kms.sources", "primary,analytics", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.analytics.api", "google-cloud-kms"), + List.of( + new CloseTrackingFactory(KmsApi.AWS_KMS, "aws", new ArrayList<>(), awsFailure), + new CloseTrackingFactory( + KmsApi.GOOGLE_CLOUD_KMS, "gcp", new ArrayList<>(), gcpFailure))); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, registry::close); + Assertions.assertSame(gcpFailure, exception); + Assertions.assertArrayEquals(new Throwable[] {awsFailure}, exception.getSuppressed()); + } + + private static Config config(String... entries) { + Map properties = new HashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + properties.put(entries[index], entries[index + 1]); + } + return new MapConfig(properties); + } + + private static KmsClientFactory factory(KmsApi api, ClientCreator creator) { + return new KmsClientFactory() { + @Override + public KmsApi api() { + return api; + } + + @Override + public KmsClient create(String source, Map properties) { + return creator.create(source, properties); + } + }; + } + + private static KmsClientFactory blockingFactory( + KmsApi api, CountDownLatch entered, CountDownLatch release) { + return factory( + api, + (source, properties) -> + reference -> { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to release KMS request"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while inspecting KMS key", e); + } + return new Properties(reference); + }); + } + + private interface ClientCreator { + KmsClient create(String source, Map properties); + } + + private static final class RecordingFactory implements KmsClientFactory { + private final KmsApi api; + private String source; + private Map properties; + private String providerKeyId; + private final AtomicInteger createCount = new AtomicInteger(); + + private RecordingFactory(KmsApi api) { + this.api = api; + } + + @Override + public KmsApi api() { + return api; + } + + @Override + public KmsClient create(String source, Map properties) { + createCount.incrementAndGet(); + this.source = source; + this.properties = properties; + return reference -> { + this.source = reference.source(); + this.providerKeyId = reference.keyId(); + return new Properties(reference); + }; + } + } + + private static final class CloseTrackingFactory implements KmsClientFactory { + private final KmsApi api; + private final String name; + private final List closeOrder; + private final RuntimeException closeFailure; + private final AtomicInteger closeCount = new AtomicInteger(); + + private CloseTrackingFactory( + KmsApi api, String name, List closeOrder, RuntimeException closeFailure) { + this.api = api; + this.name = name; + this.closeOrder = closeOrder; + this.closeFailure = closeFailure; + } + + @Override + public KmsApi api() { + return api; + } + + @Override + public KmsClient create(String source, Map properties) { + return new KmsClient() { + @Override + public KmsKeyProperties getKeyProperties(KmsReference reference) { + return new Properties(reference); + } + + @Override + public void close() { + closeCount.incrementAndGet(); + closeOrder.add(name); + if (closeFailure != null) { + throw closeFailure; + } + } + }; + } + } + + private static final class Properties implements KmsKeyProperties { + private final KmsReference reference; + + private Properties(KmsReference reference) { + this.reference = reference; + } + + @Override + public KmsReference reference() { + return reference; + } + + @Override + public boolean present() { + return true; + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean supportsWrapping() { + return true; + } + + @Override + public boolean supportsUnwrapping() { + return true; + } + } + + private static final class MapConfig extends Config { + private MapConfig(Map properties) { + super(false); + loadFromMap(properties, key -> true); + } + } + + /** Factory exposed for the context-classloader ServiceLoader test. */ + public static final class ServiceLoadedFactory implements KmsClientFactory { + + /** Creates a test service-loaded factory. */ + public ServiceLoadedFactory() {} + + /** {@inheritDoc} */ + @Override + public KmsApi api() { + return KmsApi.AWS_KMS; + } + + /** {@inheritDoc} */ + @Override + public KmsClient create(String source, Map properties) { + return reference -> new Properties(reference); + } + } +} diff --git a/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsConfig.java b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsConfig.java new file mode 100644 index 00000000000..3aacc64a0dd --- /dev/null +++ b/core/src/test/java/org/apache/gravitino/encryption/kms/TestKmsConfig.java @@ -0,0 +1,134 @@ +/* + * 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 org.apache.gravitino.Config; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TestKmsConfig { + + @Test + void testParsesSourcesAndProviderProperties() { + KmsConfig config = + parse( + Map.of( + "gravitino.kms.sources", "primary, disaster-recovery", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.primary.endpoint.region", "us-west-2", + "gravitino.kms.source.primary.credential.method", "default", + "gravitino.kms.source.disaster-recovery.api", "google-cloud-kms", + "gravitino.kms.source.disaster-recovery.endpoint.projectId", "backup-project", + "gravitino.kms.source.disaster-recovery.credential.method", "default")); + + Assertions.assertEquals(2, config.sources().size()); + Assertions.assertEquals(KmsApi.AWS_KMS, config.sources().get("primary").api()); + Assertions.assertEquals( + Map.of("endpoint.region", "us-west-2", "credential.method", "default"), + config.sources().get("primary").properties()); + Assertions.assertEquals( + KmsApi.GOOGLE_CLOUD_KMS, config.sources().get("disaster-recovery").api()); + Assertions.assertEquals( + Map.of("endpoint.projectId", "backup-project", "credential.method", "default"), + config.sources().get("disaster-recovery").properties()); + Assertions.assertThrows( + UnsupportedOperationException.class, + () -> config.sources().get("primary").properties().put("endpoint.region", "other")); + } + + @Test + void testAllowsNoConfiguredSources() { + Assertions.assertTrue(parse(Map.of()).sources().isEmpty()); + Assertions.assertTrue(parse(Map.of("gravitino.kms.sources", " ")).sources().isEmpty()); + } + + @Test + void testRejectsInvalidOrDuplicateSourceNames() { + assertInvalid(Map.of("gravitino.kms.sources", "primary,"), "Invalid KMS source name"); + assertInvalid(Map.of("gravitino.kms.sources", "bad.name"), "Invalid KMS source name"); + assertInvalid( + Map.of("gravitino.kms.sources", "primary,primary"), "Duplicate KMS source 'primary'"); + } + + @Test + void testRejectsMalformedOrUnlistedSourceProperties() { + assertInvalid(Map.of("gravitino.kms.unexpected", "value"), "Invalid KMS configuration key"); + assertInvalid( + Map.of( + "gravitino.kms.sources", "primary", + "gravitino.kms.source.other.api", "aws-kms"), + "unlisted source 'other'"); + assertInvalid( + Map.of( + "gravitino.kms.sources", "primary", + "gravitino.kms.source.primary.", "value"), + "Invalid KMS configuration key"); + } + + @Test + void testRequiresSupportedApi() { + assertInvalid( + Map.of("gravitino.kms.sources", "primary"), + "gravitino.kms.source.primary.api' cannot be blank"); + assertInvalid( + Map.of( + "gravitino.kms.sources", "primary", + "gravitino.kms.source.primary.api", "custom"), + "Unsupported KMS API 'custom'"); + } + + @Test + void testAllowsMoreThanOneSourceForAnApi() { + KmsConfig config = + parse( + Map.of( + "gravitino.kms.sources", "primary,secondary", + "gravitino.kms.source.primary.api", "aws-kms", + "gravitino.kms.source.secondary.api", "aws-kms")); + + Assertions.assertEquals(KmsApi.AWS_KMS, config.sources().get("primary").api()); + Assertions.assertEquals(KmsApi.AWS_KMS, config.sources().get("secondary").api()); + } + + @Test + void testRejectsNullConfiguration() { + Assertions.assertThrows(KmsConfigurationException.class, () -> new KmsConfig(null)); + } + + private static KmsConfig parse(Map properties) { + return new KmsConfig(new MapConfig(properties)); + } + + private static void assertInvalid(Map properties, String expectedMessage) { + KmsConfigurationException exception = + Assertions.assertThrows(KmsConfigurationException.class, () -> parse(properties)); + Assertions.assertTrue( + exception.getMessage().contains(expectedMessage), + () -> String.format("Expected '%s' in '%s'", expectedMessage, exception.getMessage())); + } + + private static final class MapConfig extends Config { + private MapConfig(Map properties) { + super(false); + loadFromMap(new HashMap<>(properties), key -> true); + } + } +} diff --git a/design-docs/kms-api.md b/design-docs/kms-api.md new file mode 100644 index 00000000000..197bc9beda4 --- /dev/null +++ b/design-docs/kms-api.md @@ -0,0 +1,309 @@ + + +# KMS API + +## Status + +The provider-neutral KMS foundation is implemented. This document defines the general KMS model, +configuration, routing, lifecycle, provider boundary, and error contract. + +The current implementation reads key properties. The API is not limited to inspection. +Cryptographic and administrative operations can be added when their cross-provider semantics are +defined. + +## Goals + +- Represent a key without embedding credentials or key material. +- Support multiple named KMS instances, including multiple instances of the same provider API. +- Route requests to long-lived, server-private provider clients. +- Define provider-neutral operations and results where provider semantics agree. +- Preserve provider-specific key identifiers and validation. +- Provide a reusable foundation for future KMS capabilities. + +## Current scope + +The implementation includes provider configuration, discovery, routing, lifecycle, error handling, and +key-property reads. It does not currently provide: + +- Creating, rotating, enabling, disabling, or deleting keys. +- Wrapping, unwrapping, encrypting, or decrypting data. +- Listing keys or configuring a key allowlist or alias map. +- Vending provider credentials to engines or users. +- Defining which Gravitino users may use which keys. + +Those concerns may use this foundation later without changing the key-reference or named-source model. + +## Core model + +### KMS API + +`KmsApi` identifies the provider API and therefore the syntax and semantics of a key ID. The stable +configuration values are: + +- `aws-kms` +- `google-cloud-kms` +- `azure-key-vault` +- `openbao-transit` +- `vault-transit` + +There is no custom, unknown, or implicit default value. OpenBao Transit and Vault Transit are separate +APIs because their implementations and product lifecycles may diverge, even though the current wire +operation is compatible. + +### Key reference + +`KmsReference` is the portable routing envelope: + +```java +public final class KmsReference { + private final KmsApi api; + private final String source; + private final String keyId; +} +``` + +- `api` selects the provider contract. +- `source` names one configured KMS client instance. +- `keyId` is the opaque, provider-native identifier interpreted by that instance. + +The source is not a key alias. The current implementation intentionally has no configured key list or +alias map. A higher-level policy may constrain which references are accepted without changing KMS +connection configuration. + +Key IDs contain no key material and are not secrets by this contract. They may appear in validation +errors and diagnostics; deployments should still follow their normal identifier-redaction policy. + +Examples: + +```java +new KmsReference(KmsApi.AWS_KMS, "aws-production", "alias/customer-data"); + +new KmsReference( + KmsApi.GOOGLE_CLOUD_KMS, + "gcp-production", + "projects/p/locations/us/keyRings/r/cryptoKeys/customer-data"); + +new KmsReference( + KmsApi.AZURE_KEY_VAULT, + "azure-production", + "https://team-kms.vault.azure.net/keys/customer-data/version"); + +new KmsReference(KmsApi.OPENBAO_TRANSIT, "bao-production", "customer-data"); +``` + +### Key properties + +`KmsClient` owns provider resources and currently exposes `getKeyProperties`: + +```java +public interface KmsClient extends AutoCloseable { + KmsKeyProperties getKeyProperties(KmsReference reference); +} +``` + +The operation returns `KmsKeyProperties`: + +```java +public interface KmsKeyProperties { + KmsReference reference(); + + boolean present(); + + boolean enabled(); + + boolean supportsWrapping(); + + boolean supportsUnwrapping(); +} +``` + +The result preserves the exact requested reference. `enabled()` means currently usable according to +the metadata exposed by that provider. It does not prove that the caller has cryptographic permission, +that an external HSM is reachable, or that a trial cryptographic operation succeeds. + +The KMS API reports facts. Callers define which combination of properties their operation requires. + +## Configuration + +Gravitino configuration defines named client instances. The same `KmsApi` may be used by more than one +source: + +```properties +gravitino.kms.sources=aws-us,aws-eu,bao-production + +gravitino.kms.source.aws-us.api=aws-kms +gravitino.kms.source.aws-us.endpoint.region=us-west-2 +gravitino.kms.source.aws-us.credential.method=default + +gravitino.kms.source.aws-eu.api=aws-kms +gravitino.kms.source.aws-eu.endpoint.region=eu-west-1 +gravitino.kms.source.aws-eu.credential.method=default + +gravitino.kms.source.bao-production.api=openbao-transit +gravitino.kms.source.bao-production.endpoint.address=https://openbao.example.com +gravitino.kms.source.bao-production.endpoint.transitMount=transit +gravitino.kms.source.bao-production.credential.method=token_file +gravitino.kms.source.bao-production.credential.path=/run/secrets/openbao-token +``` + +Each source must be listed exactly once. Unlisted sources, duplicate names, unsupported APIs, unknown +provider properties, missing required properties, and malformed endpoints fail initialization. + +Provider configuration is currently: + +| API | Endpoint properties | Credential properties | +| ---------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| AWS KMS | Required `endpoint.region`; optional `endpoint.serviceAddress` for an HTTP(S) endpoint override | `credential.method=default` | +| Google Cloud KMS | Required `endpoint.projectId` and `endpoint.location` | `credential.method=default` | +| Azure Key Vault | Required `endpoint.vaultUrl` | `credential.method=default`; optional `credential.managedIdentityClientId` | +| OpenBao Transit | Required `endpoint.address`; optional `endpoint.transitMount`, default `transit` | `credential.method=token_file`; required absolute `credential.path` | +| Vault Transit | Required `endpoint.address`; optional `endpoint.transitMount`, default `transit` | `credential.method=token_file`; required absolute `credential.path` | + +Cloud providers use their official SDK credential chains. Credential secrets are not accepted as KMS +source properties. Transit reads a token from the configured file on first use, caches it, and reloads +the file once when the backend returns HTTP 401 or 403 before reporting authentication failure. + +The explicit `credential.method` discriminator makes the configuration extensible without accepting +partially specified or ambiguous credential shapes. Only the methods in the table are implemented. + +## Discovery, routing, and lifecycle + +`KmsClientFactory` creates a client for one API and configured source. Factories are registered through +`META-INF/services/org.apache.gravitino.encryption.kms.KmsClientFactory` and discovered through the +application context classloader. + +`KmsClientRegistry` is the server-side router and lifecycle owner: + +1. Parse every configured source. +2. Index at most one discovered factory for each API and reject duplicate implementations. +3. Require a matching factory and create one client for each configured source during full-server + initialization. +4. Reuse that client, including its SDK or pooled HTTP transport, for every request to the source. +5. Reject an unknown source or an API/source mismatch before calling a provider. +6. Close all clients in reverse creation order during shutdown. + +One factory per API does not mean one source per API. A factory can create any number of named client +instances. + +The registry is created only by `GravitinoEnv.initializeFullComponents`; base-only environments do not +create it. + +Provider artifacts and their dependencies must therefore be visible to the full server's context +classloader. Provider module tests verify their service descriptors directly. A final packaged-server +discovery smoke test is still required before treating distribution wiring as complete. + +## Boundary with credential vending + +Gravitino credential vending and the KMS API are adjacent but distinct: + +- `CredentialProvider` produces serializable credentials for clients or engines to access external + data systems. It is catalog-scoped, user/context-aware, and caches expiring `Credential` values. +- `KmsClient` uses server-private authentication to invoke a configured KMS and returns non-secret key + metadata or operation results. It is server-scoped and routes by `KmsReference`. + +KMS provider credentials must never be returned as `Credential`, a credential DTO, catalog properties, +or key properties. `KmsClient` and `KmsClientFactory` therefore do not extend `CredentialProvider` or +reuse `CatalogCredentialManager`. + +The KMS implementation does follow the repository's `ServiceLoader`, context-classloader, +`AutoCloseable`, shaded-bundle, and contract-test conventions. That mechanical similarity does not +justify merging two APIs with opposite secret-handling boundaries. If a future KMS authentication flow +needs another abstraction, it should compose with a server-private secret resolver or authentication +provider rather than the credential-vending API. + +## Provider normalization + +| Provider | Read operation and accepted key ID | Normalized result | +| ---------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| AWS KMS | SDK `DescribeKey`; key ID, key ARN, alias name, or alias ARN | Present unless AWS returns not found; enabled only for `enabled=true` and `KeyState.ENABLED`; wrap and unwrap require `ENCRYPT_DECRYPT` usage | +| Google Cloud KMS | SDK `GetCryptoKey`; full CryptoKey name within the configured project and location | Present unless GCP returns not found; enabled when an enabled primary version exists; wrap and unwrap require `ENCRYPT_DECRYPT` purpose | +| Azure Key Vault | SDK `KeyClient.getKey`; full key URL for the configured vault, optionally including a version | Present unless Azure returns not found; enabled requires the enabled flag and current `notBefore`/`expiresOn` bounds; capabilities require the exact `wrapKey` and `unwrapKey` operations | +| OpenBao Transit | `GET /v1/{mount}/keys/{name}`; one Transit key-name segment | Present for a valid, non-soft-deleted record; enabled while present; capabilities use `supports_encryption` and `supports_decryption` | +| Vault Transit | `GET /v1/{mount}/keys/{name}`; one Transit key-name segment | Present for a valid record; enabled while present; capabilities use `supports_encryption` and `supports_decryption` | + +AWS, GCP, and Azure use their official Java SDK request and response types. Transit uses a small Apache +HttpClient 5 adapter with typed request and response objects because adding Spring Vault would conflict +with the repository's Java baseline and introduce a substantially larger Spring dependency surface. + +## Error contract + +- `KmsConfigurationException` extends `IllegalArgumentException` and reports invalid startup + configuration. +- Other invalid arguments include malformed key IDs, unknown sources, and API/source mismatches. +- Only a definitive provider not-found result returns `present() == false`. +- `KmsAuthenticationException` extends `ConnectionFailedException` and reports credentials rejected by + the backend. +- Timeouts, throttling, provider failures, malformed successful responses, token-file read failures, + and other inability to query a backend throw `ConnectionFailedException` with the cause when one is + available. +- Provider unavailability is never represented as a key property or normalized to a missing key. + +For Transit, HTTP 404 on the configured key metadata route is treated as missing. A wrong server or +mount may also return 404, so deployment configuration and real-backend integration tests are required +to establish that the route is correct. + +## Test evidence + +Implemented automated coverage includes: + +- API model and shared `KmsClient`/`KmsClientFactory` contract tests; +- registry configuration, dispatch, concurrency, partial-startup cleanup, close, and environment + lifecycle tests; +- provider identifier validation, SDK/HTTP normalization, error mapping, configuration, and + `ServiceLoader` discovery tests; +- deterministic AWS SDK, GCP SDK, Azure in-memory transport, and Transit HTTP tests; +- Docker integration tests against pinned OpenBao and Vault images; +- an opt-in LocalStack KMS test requiring a licensed LocalStack Pro fixture; and +- opt-in, read-only AWS, GCP, and Azure live-provider tests. + +The unit suites and Dockerized OpenBao/Vault tests have been run for this implementation. LocalStack +and live-cloud tests are available but require external fixtures and were not part of the local +verification. + +## Extending the API + +The shared foundation is `KmsApi`, `KmsReference`, named-source configuration, factory discovery, +routing, lifecycle, and the error model. Key-property reads are the operations currently implemented. + +Future work may add cryptographic or administrative capability interfaces and provider-specific result +extensions. The exact Java shape should be decided when the operation semantics are defined rather +than by adding placeholder methods now. New capabilities must continue to: + +- route through the configured source; +- preserve provider-native identifiers; +- keep provider credentials and key material private; +- expose normalized cross-provider contracts only where semantics genuinely agree; and +- permit provider-specific extensions without weakening the common contract. + +## Remaining implementation work + +- Packaged-server provider discovery verification. +- Additional KMS operations as their portable semantics are defined. +- Authorization if KMS operations become directly user-facing. +- A server-private authentication abstraction if provider SDK credential chains and token files no + longer cover the required authentication methods. + +## Applications + +### Iceberg table encryption + +Iceberg table encryption can use `KmsReference` and `getKeyProperties` to validate a configured key. +The encryption policy, key allowlisting, enforcement, audit events, and metadata integrity remain +application concerns outside the KMS API. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9222b53ab3d..e200db9c9e9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ [versions] awssdk = "2.31.73" azure-identity = "1.13.1" +azure-security-keyvault-keys = "4.8.6" azure-storage-file-datalake = "12.20.0" reactor-netty-http = "1.2.1" reactor-netty-core = "1.2.1" @@ -153,6 +154,7 @@ datanucleus-rdbms = "4.1.19" datanucleus-jdo = "3.2.0-m3" hudi = "0.15.0" google-auth = "1.28.0" +google-cloud-kms = "2.97.0" aliyun-credentials = "0.3.12" aliyun-sdk-oss = "3.10.2" openlineage = "1.29.0" @@ -176,6 +178,7 @@ aws-s3 = { group = "software.amazon.awssdk", name = "s3", version.ref = "awssdk" aws-sts = { group = "software.amazon.awssdk", name = "sts", version.ref = "awssdk" } aws-kms = { group = "software.amazon.awssdk", name = "kms", version.ref = "awssdk" } azure-identity = { group = "com.azure", name = "azure-identity", version.ref = "azure-identity"} +azure-security-keyvault-keys = { group = "com.azure", name = "azure-security-keyvault-keys", version.ref = "azure-security-keyvault-keys"} azure-storage-file-datalake = { group = "com.azure", name = "azure-storage-file-datalake", version.ref = "azure-storage-file-datalake"} reactor-netty-http = {group = "io.projectreactor.netty", name = "reactor-netty-http", version.ref = "reactor-netty-http"} reactor-netty-core = {group = "io.projectreactor.netty", name = "reactor-netty-core", version.ref = "reactor-netty-core"} @@ -350,6 +353,7 @@ jettison = { group = "org.codehaus.jettison", name = "jettison", version.ref = " google-auth-http = { group = "com.google.auth", name = "google-auth-library-oauth2-http", version.ref = "google-auth" } google-auth-credentials = { group = "com.google.auth", name = "google-auth-library-credentials", version.ref = "google-auth" } +google-cloud-kms = { group = "com.google.cloud", name = "google-cloud-kms", version.ref = "google-cloud-kms" } aliyun-credentials-sdk = { group='com.aliyun', name='credentials-java', version.ref='aliyun-credentials' } aliyun-sdk-oss = { module = "com.aliyun.oss:aliyun-sdk-oss", version.ref = "aliyun-sdk-oss" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3f9ec2f06a3..8267eb8d0fa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -125,6 +125,7 @@ include(":bundles:gcp", ":bundles:gcp-bundle", ":bundles:iceberg-gcp-bundle") include(":bundles:aliyun", ":bundles:aliyun-bundle", ":bundles:iceberg-aliyun-bundle") include(":bundles:tencent", ":bundles:tencent-bundle") include(":bundles:azure", ":bundles:azure-bundle", ":bundles:iceberg-azure-bundle") +include(":bundles:transit") include(":catalogs:hadoop-common") include(":catalogs:hadoop-auth") include(":lineage")