diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpHsmGeneratedSecret.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpHsmGeneratedSecret.java index 493330ad5561..9a62bc49d3ca 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpHsmGeneratedSecret.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpHsmGeneratedSecret.java @@ -34,7 +34,17 @@ import java.io.IOException; import java.security.GeneralSecurityException; import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions; +import org.checkerframework.checker.nullness.qual.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,7 +53,7 @@ * entropy from a GCP HSM key and stores it in Google Cloud Secret Manager. If the secret already * exists, it will be retrieved. */ -public class GcpHsmGeneratedSecret implements Secret { +public class GcpHsmGeneratedSecret extends Secret { private static final Logger LOG = LoggerFactory.getLogger(GcpHsmGeneratedSecret.class); private final String projectId; private final String locationId; @@ -62,6 +72,38 @@ public GcpHsmGeneratedSecret( this.secretId = "HsmGeneratedSecret_" + jobName; } + /** Initialize GcpHsmGeneratedSecret from a map specification. */ + static GcpHsmGeneratedSecret fromMap(Map specMap) { + Set allowedKeys = + new HashSet<>( + Arrays.asList("project_id", "location_id", "key_ring_id", "key_id", "job_name")); + Set invalid = new HashSet<>(specMap.keySet()); + invalid.removeAll(allowedKeys); + if (!invalid.isEmpty()) { + List sortedInvalid = new ArrayList<>(invalid); + Collections.sort(sortedInvalid); + throw new IllegalArgumentException( + "Invalid secret parameter " + String.join(", ", sortedInvalid)); + } + String locationId = + Preconditions.checkNotNull( + specMap.get("location_id"), + "location_id must contain a valid value for locationId parameter"); + String keyRingId = + Preconditions.checkNotNull( + specMap.get("key_ring_id"), + "key_ring_id must contain a valid value for keyRingId parameter"); + String keyId = + Preconditions.checkNotNull( + specMap.get("key_id"), "key_id must contain a valid value for keyId parameter"); + String jobName = + Preconditions.checkNotNull( + specMap.get("job_name"), "job_name must contain a valid value for jobName parameter"); + String projectId = + GcpSecret.resolveGcpProjectId(specMap.get("project_id"), "job '" + jobName + "'"); + return new GcpHsmGeneratedSecret(projectId, locationId, keyRingId, keyId, jobName); + } + /** * Returns the secret as a byte array. Assumes that the current active service account has * permissions to read the secret. @@ -188,4 +230,25 @@ public String getKeyId() { public String getSecretId() { return secretId; } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof GcpHsmGeneratedSecret)) { + return false; + } + GcpHsmGeneratedSecret other = (GcpHsmGeneratedSecret) obj; + return Objects.equals(this.projectId, other.projectId) + && Objects.equals(this.locationId, other.locationId) + && Objects.equals(this.keyRingId, other.keyRingId) + && Objects.equals(this.keyId, other.keyId) + && Objects.equals(this.secretId, other.secretId); + } + + @Override + public int hashCode() { + return Objects.hash(projectId, locationId, keyRingId, keyId, secretId); + } } diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpSecret.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpSecret.java index 8effae7f61cf..9aaa61bc3e00 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpSecret.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/GcpSecret.java @@ -21,11 +21,25 @@ import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; import com.google.cloud.secretmanager.v1.SecretVersionName; import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A {@link Secret} manager implementation that retrieves secrets from Google Cloud Secret Manager. */ -public class GcpSecret implements Secret { +public class GcpSecret extends Secret { + private static final Logger LOG = LoggerFactory.getLogger(GcpSecret.class); private final String versionName; /** @@ -39,6 +53,76 @@ public GcpSecret(String versionName) { this.versionName = versionName; } + /** Initialize GcpSecret from a map specification. */ + static GcpSecret fromMap(Map specMap) { + Set allowedKeys = + new HashSet<>(Arrays.asList("version_name", "name", "project", "version")); + Set invalidKeys = new HashSet<>(specMap.keySet()); + invalidKeys.removeAll(allowedKeys); + if (!invalidKeys.isEmpty()) { + List sortedInvalid = new ArrayList<>(invalidKeys); + Collections.sort(sortedInvalid); + throw new IllegalArgumentException( + "Invalid secret parameter " + String.join(", ", sortedInvalid)); + } + String versionName = parseVersionName(specMap); + return new GcpSecret(versionName); + } + + /** Parses the version name from a specification dictionary. */ + private static String parseVersionName(Map specMap) { + String versionNameParam = specMap.get("version_name"); + if (!Strings.isNullOrEmpty(versionNameParam)) { + return Preconditions.checkNotNull( + versionNameParam, "version_name must contain a valid value for versionName parameter"); + } + String secretId = specMap.get("name"); + if (Strings.isNullOrEmpty(secretId)) { + throw new IllegalArgumentException("Secret name must be specified in secret spec."); + } + String projectId = resolveGcpProjectId(specMap.get("project"), "secret '" + secretId + "'"); + String versionId = specMap.getOrDefault("version", "latest"); + if (Strings.isNullOrEmpty(versionId)) { + versionId = "latest"; + } + return String.format("projects/%s/secrets/%s/versions/%s", projectId, secretId, versionId); + } + + /** + * Resolves the GCP project ID from the provided value, environment variables, or Application + * Default Credentials. + */ + static String resolveGcpProjectId(@Nullable String projectId, @Nullable String context) { + if (!Strings.isNullOrEmpty(projectId)) { + return Preconditions.checkNotNull(projectId); + } + String envProject = System.getenv("GOOGLE_CLOUD_PROJECT"); + if (!Strings.isNullOrEmpty(envProject)) { + return Preconditions.checkNotNull(envProject); + } + envProject = System.getenv("GCP_PROJECT"); + if (!Strings.isNullOrEmpty(envProject)) { + return Preconditions.checkNotNull(envProject); + } + try { + Class clazz = Class.forName("com.google.cloud.ServiceOptions"); + java.lang.reflect.Method method = clazz.getMethod("getDefaultProjectId"); + @SuppressWarnings("nullness") + Object result = method.invoke(null); + if (result != null && !Strings.isNullOrEmpty(result.toString())) { + return result.toString(); + } + } catch (Throwable e) { + LOG.debug("Could not resolve GCP project via ServiceOptions reflection", e); + } + throw new IllegalArgumentException( + String.format( + "Could not resolve GCP project ID%s. " + + "Please specify 'project' in the secret spec, set GOOGLE_CLOUD_PROJECT environment variable, " + + "or configure Application Default Credentials.", + context != null ? " for " + context : "")); + } + /** * Returns the secret as a byte array. Assumes that the current active service account has * permissions to read the secret. @@ -64,4 +148,21 @@ public byte[] getSecretBytes() { public String getVersionName() { return versionName; } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof GcpSecret)) { + return false; + } + GcpSecret other = (GcpSecret) obj; + return Objects.equals(this.versionName, other.versionName); + } + + @Override + public int hashCode() { + return Objects.hash(versionName); + } } diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/RawSecret.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/RawSecret.java new file mode 100644 index 000000000000..4d0ce93270ee --- /dev/null +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/RawSecret.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.beam.sdk.util; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** A {@link Secret} implementation wrapping a raw secret string or byte array directly. */ +public class RawSecret extends Secret { + private final byte[] secret; + + public RawSecret(byte[] secret) { + this.secret = secret == null ? new byte[0] : secret.clone(); + } + + public RawSecret(String secret) { + this.secret = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8); + } + + @Override + public byte[] getSecretBytes() { + return secret.clone(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof RawSecret)) { + return false; + } + RawSecret other = (RawSecret) obj; + return Arrays.equals(this.secret, other.secret); + } + + @Override + public int hashCode() { + return Arrays.hashCode(secret); + } +} diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/Secret.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/Secret.java index f8efde0dd44c..f5e935460c84 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/Secret.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/Secret.java @@ -17,30 +17,75 @@ */ package org.apache.beam.sdk.util; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.Serializable; -import java.util.Arrays; +import java.nio.charset.StandardCharsets; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * A secret management interface used for handling sensitive data. + * A secret management class used for handling sensitive data. * - *

This interface provides a generic way to handle secrets. Implementations of this interface - * should handle fetching secrets from a secret management system. The underlying secret management - * system should be able to return a valid byte array representing the secret. + *

This class provides a generic way to handle secrets. Implementations of this class should + * handle fetching secrets from a secret management system. The underlying secret management system + * should be able to return a valid byte array representing the secret. */ -public interface Secret extends Serializable { +public abstract class Secret implements Serializable { + private transient byte @Nullable [] cachedSecretBytes = null; + /** * Returns the secret as a byte array. * * @return The secret as a byte array. */ - byte[] getSecretBytes(); + public abstract byte[] getSecretBytes(); + + /** Returns the secret as a byte array, optionally caching the result in memory. */ + public synchronized byte[] getBytes(boolean cacheSecret) { + byte[] localCached = cachedSecretBytes; + if (cacheSecret && localCached != null) { + return localCached; + } + byte[] secretBytes = getSecretBytes(); + if (cacheSecret) { + this.cachedSecretBytes = secretBytes; + } + return secretBytes; + } + + /** Returns the secret as a byte array without caching. */ + public byte[] getBytes() { + return getBytes(false); + } + + /** Returns secret value as UTF-8 string, optionally caching the result in memory. */ + public @Nullable String getString(boolean cacheSecret) { + byte[] secretBytes = getBytes(cacheSecret); + return secretBytes == null ? null : new String(secretBytes, StandardCharsets.UTF_8); + } + + /** Returns secret value as UTF-8 string without caching. */ + public @Nullable String getString() { + return getString(false); + } - static Secret parseSecretOption(String secretOption) { + /** + * Parses a secret string and returns the appropriate secret type. + * + *

The secret string should be formatted like: + * 'type:<secret_type>;<secret_param>:<value>' + * + *

For example, 'type:GcpSecret;version_name:my_secret/versions/latest' would return a + * GcpSecret initialized with 'my_secret/versions/latest'. + */ + public static Secret parseSecretOption(String secretOption) { + if (secretOption == null) { + throw new IllegalArgumentException("Secret option string cannot be null"); + } Map paramMap = new HashMap<>(); for (String param : secretOption.split(";", -1)) { String[] parts = param.split(":", 2); @@ -50,69 +95,108 @@ static Secret parseSecretOption(String secretOption) { } if (!paramMap.containsKey("type")) { - throw new RuntimeException("Secret string must contain a valid type parameter"); + throw new IllegalArgumentException("Secret string must contain a valid type parameter"); } - String secretType = paramMap.get("type"); - paramMap.remove("type"); - - if (secretType == null) { - throw new RuntimeException("Secret string must contain a valid value for type parameter"); + String rawType = paramMap.remove("type"); + if (rawType == null || rawType.isEmpty()) { + throw new IllegalArgumentException("Secret string must contain a valid type parameter"); } - switch (secretType.toLowerCase()) { + String secretType = rawType.toLowerCase(); + String secretManager; + switch (secretType) { case "gcpsecret": - Set gcpSecretParams = new HashSet<>(Arrays.asList("version_name")); - for (String paramName : paramMap.keySet()) { - if (!gcpSecretParams.contains(paramName)) { - throw new RuntimeException( - String.format( - "Invalid secret parameter %s, GcpSecret only supports the following parameters: %s", - paramName, gcpSecretParams)); - } - } - String versionName = - Preconditions.checkNotNull( - paramMap.get("version_name"), - "version_name must contain a valid value for versionName parameter"); - return new GcpSecret(versionName); + secretManager = "GoogleCloudSecretManager"; + break; case "gcphsmgeneratedsecret": - Set gcpHsmGeneratedSecretParams = - new HashSet<>( - Arrays.asList("project_id", "location_id", "key_ring_id", "key_id", "job_name")); - for (String paramName : paramMap.keySet()) { - if (!gcpHsmGeneratedSecretParams.contains(paramName)) { - throw new RuntimeException( - String.format( - "Invalid secret parameter %s, GcpHsmGeneratedSecret only supports the following parameters: %s", - paramName, gcpHsmGeneratedSecretParams)); - } - } - String projectId = - Preconditions.checkNotNull( - paramMap.get("project_id"), - "project_id must contain a valid value for projectId parameter"); - String locationId = - Preconditions.checkNotNull( - paramMap.get("location_id"), - "location_id must contain a valid value for locationId parameter"); - String keyRingId = - Preconditions.checkNotNull( - paramMap.get("key_ring_id"), - "key_ring_id must contain a valid value for keyRingId parameter"); - String keyId = - Preconditions.checkNotNull( - paramMap.get("key_id"), "key_id must contain a valid value for keyId parameter"); - String jobName = - Preconditions.checkNotNull( - paramMap.get("job_name"), - "job_name must contain a valid value for jobName parameter"); - return new GcpHsmGeneratedSecret(projectId, locationId, keyRingId, keyId, jobName); + secretManager = "GoogleCloudHsmGeneratedSecretManager"; + break; default: - throw new RuntimeException( + throw new IllegalArgumentException( String.format( "Invalid secret type %s, currently only GcpSecret and GcpHsmGeneratedSecret are supported", secretType)); } + + try { + ObjectMapper mapper = new ObjectMapper(); + String jsonSpec = mapper.writeValueAsString(paramMap); + return fromJson(jsonSpec, secretManager); + } catch (Exception e) { + if (e instanceof IllegalArgumentException) { + throw (IllegalArgumentException) e; + } + throw new RuntimeException("Failed to parse secret option", e); + } + } + + /** + * Return a Secret instance based on secret_manager provider and secret specification JSON string. + * + * @param spec Secret string (raw secret or JSON specification string). + * @param secretManager Secret manager string (e.g. 'GoogleCloudSecretManager'). + * @return An instance of Secret. + */ + public static Secret fromJson(@Nullable String spec, @Nullable String secretManager) { + Logger logger = LoggerFactory.getLogger(Secret.class); + String smManager = secretManager != null ? secretManager.trim() : null; + if (smManager != null && smManager.isEmpty()) { + smManager = null; + } + + Map specMap = null; + if (spec != null && !spec.isEmpty()) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); + specMap = mapper.readValue(spec, new TypeReference>() {}); + } catch (Exception e) { + logger.debug("Failed to parse secret spec as JSON map", e); + } + } + + if (smManager != null) { + switch (smManager.toLowerCase()) { + case "googlecloudsecretmanager": + case "gcpsecret": + if (specMap != null) { + return GcpSecret.fromMap(specMap); + } else if (spec != null) { + return new GcpSecret(spec); + } else { + throw new IllegalArgumentException("Invalid spec for GcpSecret"); + } + case "googlecloudhsmgeneratedsecretmanager": + case "gcphsmgeneratedsecret": + if (specMap != null) { + return GcpHsmGeneratedSecret.fromMap(specMap); + } else { + throw new IllegalArgumentException("Invalid spec for GcpHsmGeneratedSecret"); + } + default: + throw new IllegalArgumentException( + String.format( + "Unsupported secret manager: '%s'. Currently supported options: 'GoogleCloudSecretManager', 'GoogleCloudHsmGeneratedSecretManager'.", + smManager)); + } + } + + if (specMap != null) { + logger.warn( + "The 'spec' parameter appears to be a JSON specification, but 'secret_manager' is not set. Defaulting to Raw."); + } + + return new RawSecret(spec != null ? spec : ""); + } + + /** + * Return a Secret instance with default raw secret handling. + * + * @param spec Secret string (raw secret or JSON specification string). + * @return An instance of Secret. + */ + public static Secret fromJson(@Nullable String spec) { + return fromJson(spec, null); } } diff --git a/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/GroupByEncryptedKeyTest.java b/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/GroupByEncryptedKeyTest.java index 77195533ace3..a929d62d29d7 100644 --- a/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/GroupByEncryptedKeyTest.java +++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/GroupByEncryptedKeyTest.java @@ -59,7 +59,7 @@ public class GroupByEncryptedKeyTest implements Serializable { @Rule public transient TestPipeline p = TestPipeline.create(); - private static class FakeSecret implements Secret { + private static class FakeSecret extends Secret { private final byte[] secret = "YUt3STJQbXFZRnQycDV0TktDeUJTNXFZV0hoSHNHWmM".getBytes(Charset.defaultCharset()); diff --git a/sdks/java/core/src/test/java/org/apache/beam/sdk/util/SecretTest.java b/sdks/java/core/src/test/java/org/apache/beam/sdk/util/SecretTest.java index 0acfa3963462..9b74e52376f3 100644 --- a/sdks/java/core/src/test/java/org/apache/beam/sdk/util/SecretTest.java +++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/util/SecretTest.java @@ -17,10 +17,17 @@ */ package org.apache.beam.sdk.util; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -35,6 +42,14 @@ public void testParseSecretOptionWithValidGcpSecret() { Secret secret = Secret.parseSecretOption(secretOption); assertTrue(secret instanceof GcpSecret); assertEquals("my_secret/versions/latest", ((GcpSecret) secret).getVersionName()); + assertEquals(new GcpSecret("my_secret/versions/latest"), secret); + + Secret secretFoo = Secret.parseSecretOption("type:GcpSecret;version_name:foo"); + assertEquals(new GcpSecret("foo"), secretFoo); + + Secret secretMixedCase = + Secret.parseSecretOption("type:gcpsecreT;version_name:my_secret/versions/latest"); + assertEquals(new GcpSecret("my_secret/versions/latest"), secretMixedCase); } @Test @@ -49,13 +64,16 @@ public void testParseSecretOptionWithValidGcpHsmGeneratedSecret() { assertEquals("my-key-ring", hsmSecret.getKeyRingId()); assertEquals("my-key", hsmSecret.getKeyId()); assertEquals("HsmGeneratedSecret_my-job", hsmSecret.getSecretId()); + assertEquals( + new GcpHsmGeneratedSecret("my-project", "global", "my-key-ring", "my-key", "my-job"), + secret); } @Test public void testParseSecretOptionWithMissingType() { String secretOption = "version_name:my_secret/versions/latest"; Exception exception = - assertThrows(RuntimeException.class, () -> Secret.parseSecretOption(secretOption)); + assertThrows(IllegalArgumentException.class, () -> Secret.parseSecretOption(secretOption)); assertEquals("Secret string must contain a valid type parameter", exception.getMessage()); } @@ -63,7 +81,7 @@ public void testParseSecretOptionWithMissingType() { public void testParseSecretOptionWithUnsupportedType() { String secretOption = "type:unsupported;version_name:my_secret/versions/latest"; Exception exception = - assertThrows(RuntimeException.class, () -> Secret.parseSecretOption(secretOption)); + assertThrows(IllegalArgumentException.class, () -> Secret.parseSecretOption(secretOption)); assertTrue(exception.getMessage().contains("Invalid secret type unsupported")); } @@ -71,9 +89,160 @@ public void testParseSecretOptionWithUnsupportedType() { public void testParseSecretOptionWithInvalidGcpSecretParameter() { String secretOption = "type:gcpsecret;invalid_param:some_value"; Exception exception = - assertThrows(RuntimeException.class, () -> Secret.parseSecretOption(secretOption)); + assertThrows(IllegalArgumentException.class, () -> Secret.parseSecretOption(secretOption)); + assertTrue(exception.getMessage().contains("Invalid secret parameter invalid_param")); + } + + @Test + public void testParseSecretOptionWithMissingSecretName() { + String secretOption = "type:gcpsecreT"; + Exception exception = + assertThrows(IllegalArgumentException.class, () -> Secret.parseSecretOption(secretOption)); + assertTrue(exception.getMessage().contains("Secret name must be specified in secret spec.")); + } + + @Test + public void testRawSecretStr() { + Secret secret = new RawSecret("STATIC_SECRET_"); + assertEquals("STATIC_SECRET_", secret.getString(true)); + assertEquals("STATIC_SECRET_", secret.getString(false)); + assertEquals("STATIC_SECRET_", secret.getString()); + assertArrayEquals("STATIC_SECRET_".getBytes(StandardCharsets.UTF_8), secret.getBytes(true)); + assertArrayEquals("STATIC_SECRET_".getBytes(StandardCharsets.UTF_8), secret.getBytes()); + } + + @Test + public void testRawSecretBytes() { + byte[] bytes = "STATIC_BYTES_".getBytes(StandardCharsets.UTF_8); + Secret secret = new RawSecret(bytes); + assertEquals("STATIC_BYTES_", secret.getString(true)); + assertEquals("STATIC_BYTES_", secret.getString()); + assertArrayEquals(bytes, secret.getBytes(true)); + assertArrayEquals(bytes, secret.getBytes()); + } + + @Test + public void testSecretFactory() { + String spec = "{\"name\": \"test-secret\", \"project\": \"proj\"}"; + + Secret secretGcp = Secret.fromJson(spec, "GoogleCloudSecretManager"); + assertTrue(secretGcp instanceof GcpSecret); + assertEquals( + "projects/proj/secrets/test-secret/versions/latest", + ((GcpSecret) secretGcp).getVersionName()); + + String singleQuotedSpec = "{'name': 'test-secret', 'project': 'proj'}"; + Secret secretSingleQuoted = Secret.fromJson(singleQuotedSpec, "GoogleCloudSecretManager"); + assertTrue(secretSingleQuoted instanceof GcpSecret); assertEquals( - "Invalid secret parameter invalid_param, GcpSecret only supports the following parameters: [version_name]", - exception.getMessage()); + "projects/proj/secrets/test-secret/versions/latest", + ((GcpSecret) secretSingleQuoted).getVersionName()); + + Secret secretRaw = Secret.fromJson("STATIC_SECRET_", null); + assertTrue(secretRaw instanceof RawSecret); + assertEquals("STATIC_SECRET_", secretRaw.getString()); + + Exception exception = + assertThrows( + IllegalArgumentException.class, () -> Secret.fromJson("spec", "unsupported_provider")); + assertTrue( + exception.getMessage().contains("Unsupported secret manager: 'unsupported_provider'")); + } + + @Test + public void testSecretFactoryHsm() { + String hsmSpec = + "{\"project_id\": \"p\", \"location_id\": \"l\", \"key_ring_id\": \"r\", \"key_id\": \"k\", \"job_name\": \"j\"}"; + Secret secretHsm = Secret.fromJson(hsmSpec, "GoogleCloudHsmGeneratedSecretManager"); + assertTrue(secretHsm instanceof GcpHsmGeneratedSecret); + assertEquals("p", ((GcpHsmGeneratedSecret) secretHsm).getProjectId()); + } + + @Test + public void testJsonSecretWithoutSecretManagerWarning() { + String jsonSpec = "{\"name\": \"my-secret\", \"project\": \"my-proj\"}"; + Secret secret = Secret.fromJson(jsonSpec, null); + assertTrue(secret instanceof RawSecret); + } + + @Test + public void testEquality() { + RawSecret raw1 = new RawSecret("secret_value"); + RawSecret raw2 = new RawSecret("secret_value"); + RawSecret raw3 = new RawSecret("other_value"); + assertEquals(raw1, raw2); + assertEquals(raw1.hashCode(), raw2.hashCode()); + assertNotEquals(raw1, raw3); + assertFalse(raw1.equals("secret_value")); + assertFalse(raw1.equals(null)); + + Map spec1 = new HashMap<>(); + spec1.put("name", "sec"); + spec1.put("project", "proj"); + Map spec2 = new HashMap<>(); + spec2.put("name", "sec"); + spec2.put("project", "proj"); + Map spec3 = new HashMap<>(); + spec3.put("name", "other"); + spec3.put("project", "proj"); + + GcpSecret gcp1 = GcpSecret.fromMap(spec1); + GcpSecret gcp2 = GcpSecret.fromMap(spec2); + GcpSecret gcp3 = GcpSecret.fromMap(spec3); + assertEquals(gcp1, gcp2); + assertEquals(gcp1.hashCode(), gcp2.hashCode()); + assertNotEquals(gcp1, gcp3); + assertFalse(gcp1.equals(raw1)); + assertFalse(gcp1.equals(null)); + + GcpHsmGeneratedSecret hsm1 = new GcpHsmGeneratedSecret("p", "l", "r", "k", "j"); + GcpHsmGeneratedSecret hsm2 = new GcpHsmGeneratedSecret("p", "l", "r", "k", "j"); + GcpHsmGeneratedSecret hsm3 = new GcpHsmGeneratedSecret("p", "l", "r", "k", "other"); + assertEquals(hsm1, hsm2); + assertEquals(hsm1.hashCode(), hsm2.hashCode()); + assertNotEquals(hsm1, hsm3); + assertFalse(hsm1.equals(gcp1)); + assertFalse(hsm1.equals(null)); + } + + @Test + public void testGcpSecretFromMapMissingSecretNameThrows() { + Map spec = Collections.singletonMap("project", "my-project"); + Exception exception = + assertThrows(IllegalArgumentException.class, () -> GcpSecret.fromMap(spec)); + assertTrue(exception.getMessage().contains("Secret name must be specified")); + } + + @Test + public void testGcpHsmGeneratedSecretFromMapMissingParamsThrows() { + Map spec = new HashMap<>(); + spec.put("project_id", "test-proj"); + spec.put("location_id", "global"); + Exception exception = + assertThrows(NullPointerException.class, () -> GcpHsmGeneratedSecret.fromMap(spec)); + assertTrue( + exception + .getMessage() + .contains("key_ring_id must contain a valid value for keyRingId parameter")); + } + + @Test + public void testResolveGcpProjectIdExplicit() { + assertEquals("my-proj", GcpSecret.resolveGcpProjectId("my-proj", "context")); + } + + @Test + public void testSerialization() { + RawSecret raw = new RawSecret("test_secret"); + RawSecret deserializedRaw = SerializableUtils.clone(raw); + assertEquals(raw, deserializedRaw); + + GcpSecret gcp = new GcpSecret("projects/p/secrets/s/versions/1"); + GcpSecret deserializedGcp = SerializableUtils.clone(gcp); + assertEquals(gcp, deserializedGcp); + + GcpHsmGeneratedSecret hsm = new GcpHsmGeneratedSecret("p", "l", "r", "k", "j"); + GcpHsmGeneratedSecret deserializedHsm = SerializableUtils.clone(hsm); + assertEquals(hsm, deserializedHsm); } }