diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index eb5b9cc0869..a1646a197ec 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -103,6 +103,10 @@ dependencies { api(project(":nessie-catalog-service-rest")) api(project(":nessie-catalog-service-impl")) api(project(":nessie-catalog-secrets-api")) + api(project(":nessie-catalog-secrets-cache")) + api(project(":nessie-catalog-secrets-aws")) + api(project(":nessie-catalog-secrets-gcs")) + api(project(":nessie-catalog-secrets-vault")) if (!isIncludedInNesQuEIT()) { api(project(":nessie-spark-antlr-runtime")) diff --git a/catalog/secrets/aws/build.gradle.kts b/catalog/secrets/aws/build.gradle.kts new file mode 100644 index 00000000000..dd2a1b7e5d4 --- /dev/null +++ b/catalog/secrets/aws/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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 { + id("nessie-conventions-server") + id("nessie-jacoco") +} + +extra["maven.name"] = "Nessie - Catalog - Secrets AWS" + +dependencies { + implementation(project(":nessie-catalog-secrets-api")) + implementation(libs.guava) + + implementation(platform(libs.awssdk.bom)) + implementation("software.amazon.awssdk:apache-client") { + exclude("commons-logging", "commons-logging") + } + implementation("software.amazon.awssdk:secretsmanager") + + compileOnly(project(":nessie-immutables")) + annotationProcessor(project(":nessie-immutables", configuration = "processor")) + // javax/jakarta + compileOnly(libs.jakarta.ws.rs.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.jakarta.validation.api) + + compileOnly(libs.errorprone.annotations) + compileOnly(libs.microprofile.openapi) + + testFixturesApi(platform(libs.junit.bom)) + testFixturesApi(libs.bundles.junit.testing) + + intTestCompileOnly(project(":nessie-immutables")) + intTestImplementation(platform(libs.testcontainers.bom)) + intTestImplementation("org.testcontainers:testcontainers") + intTestImplementation("org.testcontainers:localstack") + intTestImplementation("org.testcontainers:junit-jupiter") + intTestImplementation(project(":nessie-container-spec-helper")) + intTestRuntimeOnly(libs.logback.classic) +} diff --git a/catalog/secrets/aws/src/intTest/java/org/projectnessie/catalog/secrets/aws/ITAwsSecretsSupplier.java b/catalog/secrets/aws/src/intTest/java/org/projectnessie/catalog/secrets/aws/ITAwsSecretsSupplier.java new file mode 100644 index 00000000000..ccb0a0d577f --- /dev/null +++ b/catalog/secrets/aws/src/intTest/java/org/projectnessie/catalog/secrets/aws/ITAwsSecretsSupplier.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.aws; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SECRETSMANAGER; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.nessie.testing.containerspec.ContainerSpecHelper; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +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.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.model.CreateSecretRequest; + +@Testcontainers +public class ITAwsSecretsSupplier { + @Container + static LocalStackContainer localstack = + new LocalStackContainer( + ContainerSpecHelper.builder() + .name("localstack") + .containerClass(ITAwsSecretsSupplier.class) + .build() + .dockerImageName(null) + .asCompatibleSubstituteFor("localstack/localstack")) + .withLogConsumer( + new Slf4jLogConsumer(LoggerFactory.getLogger(ITAwsSecretsSupplier.class))) + .withServices(SECRETSMANAGER); + + @Test + public void awsSecretsManager() { + URI secretsManagerEndpoint = localstack.getEndpointOverride(SECRETSMANAGER); + try (SecretsManagerClient client = + SecretsManagerClient.builder() + .endpointOverride(secretsManagerEndpoint) + .region(Region.of(localstack.getRegion())) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + localstack.getAccessKey(), localstack.getSecretKey()))) + .build()) { + client.createSecret( + CreateSecretRequest.builder().name("foo").secretString("secret-foo").build()); + client.createSecret( + CreateSecretRequest.builder() + .name("single") + .secretString("{\"bar\": \"secret-single\"}") + .build()); + client.createSecret( + CreateSecretRequest.builder() + .name("multi") + .secretString("{\"name\": \"the-name\", \"value\": \"the-value\"}") + .build()); + + AwsSecretsSupplier awsSecretsSupplier = new AwsSecretsSupplier(client); + + assertThat(awsSecretsSupplier.resolveSecrets(List.of("foo", "single", "multi"))) + .containsEntry("foo", Map.of("value", "secret-foo")) + .containsEntry("single", Map.of("bar", "secret-single")) + .containsEntry("multi", Map.of("name", "the-name", "value", "the-value")) + .hasSize(3); + } + } +} diff --git a/catalog/secrets/aws/src/intTest/resources/logback-test.xml b/catalog/secrets/aws/src/intTest/resources/logback-test.xml new file mode 100644 index 00000000000..ce140f00774 --- /dev/null +++ b/catalog/secrets/aws/src/intTest/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] [%X{nessie.events.subscription.id}] %-5level %logger{36} - + %msg%n + + + + + + diff --git a/catalog/secrets/aws/src/intTest/resources/org/projectnessie/catalog/secrets/aws/Dockerfile-localstack-version b/catalog/secrets/aws/src/intTest/resources/org/projectnessie/catalog/secrets/aws/Dockerfile-localstack-version new file mode 100644 index 00000000000..814ae8b25c1 --- /dev/null +++ b/catalog/secrets/aws/src/intTest/resources/org/projectnessie/catalog/secrets/aws/Dockerfile-localstack-version @@ -0,0 +1,3 @@ +# Dockerfile to provide the image name and tag to a test. +# Version is managed by Renovate - do not edit. +FROM docker.io/localstack/localstack:3.4.0 diff --git a/catalog/secrets/aws/src/main/java/org/projectnessie/catalog/secrets/aws/AwsSecretsSupplier.java b/catalog/secrets/aws/src/main/java/org/projectnessie/catalog/secrets/aws/AwsSecretsSupplier.java new file mode 100644 index 00000000000..20528daca69 --- /dev/null +++ b/catalog/secrets/aws/src/main/java/org/projectnessie/catalog/secrets/aws/AwsSecretsSupplier.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.aws; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.projectnessie.catalog.secrets.spi.SingleValueSecretsSupplier; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.model.BatchGetSecretValueRequest; +import software.amazon.awssdk.services.secretsmanager.model.SecretValueEntry; + +public class AwsSecretsSupplier extends SingleValueSecretsSupplier { + private final SecretsManagerClient secretsManagerClient; + + public AwsSecretsSupplier(SecretsManagerClient secretsManagerClient) { + this.secretsManagerClient = secretsManagerClient; + } + + @Override + protected Map resolveSingleValueSecrets(Collection names) { + if (names.isEmpty()) { + return Map.of(); + } + + Map secretIdToNameMap = new HashMap<>(); + + for (String name : names) { + String secretId = nameToSecretId(name); + secretIdToNameMap.put(secretId, name); + } + + return secretsManagerClient + .batchGetSecretValue( + BatchGetSecretValueRequest.builder().secretIdList(secretIdToNameMap.keySet()).build()) + .secretValues() + .stream() + .collect( + Collectors.toMap( + sv -> secretIdToNameMap.get(sv.name()), SecretValueEntry::secretString)); + } + + private String nameToSecretId(String name) { + // TODO can either return the name (as known to AWS Secrets Manager) or the fully qualified ARN + return name; + } +} diff --git a/catalog/secrets/cache/build.gradle.kts b/catalog/secrets/cache/build.gradle.kts new file mode 100644 index 00000000000..ae138fba28f --- /dev/null +++ b/catalog/secrets/cache/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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 { + id("nessie-conventions-server") + id("nessie-jacoco") +} + +extra["maven.name"] = "Nessie - Catalog - Secrets Cache" + +dependencies { + implementation(project(":nessie-catalog-secrets-api")) + implementation(libs.guava) + implementation(libs.caffeine) + implementation(libs.micrometer.core) + + implementation(platform(libs.jackson.bom)) + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("com.fasterxml.jackson.core:jackson-annotations") + + compileOnly(project(":nessie-immutables")) + annotationProcessor(project(":nessie-immutables", configuration = "processor")) + // javax/jakarta + compileOnly(libs.jakarta.ws.rs.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.jakarta.validation.api) + + compileOnly(libs.errorprone.annotations) + compileOnly(libs.microprofile.openapi) + + testFixturesApi(platform(libs.junit.bom)) + testFixturesApi(libs.bundles.junit.testing) +} diff --git a/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/CachingSecrets.java b/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/CachingSecrets.java new file mode 100644 index 00000000000..dda9f49f8cb --- /dev/null +++ b/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/CachingSecrets.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.cache; + +import org.projectnessie.catalog.secrets.spi.SecretsSupplier; + +public final class CachingSecrets { + + private final CachingSecretsBackend backend; + + public CachingSecrets(CachingSecretsBackend backend) { + this.backend = backend; + } + + public SecretsSupplier forRepository(String repositoryId, SecretsSupplier secretsSupplier) { + return names -> backend.resolveSecrets(repositoryId, names, secretsSupplier); + } +} diff --git a/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/CachingSecretsBackend.java b/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/CachingSecretsBackend.java new file mode 100644 index 00000000000..1bd0147858d --- /dev/null +++ b/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/CachingSecretsBackend.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.cache; + +import static java.util.Collections.singletonList; +import static java.util.Collections.unmodifiableMap; +import static java.util.stream.Collectors.toMap; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.google.common.annotations.VisibleForTesting; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.binder.cache.CaffeineStatsCounter; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalLong; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; +import org.checkerframework.checker.index.qual.NonNegative; +import org.projectnessie.catalog.secrets.spi.SecretsSupplier; + +public class CachingSecretsBackend { + + private static final Map CACHE_NEGATIVE_SENTINEL = new HashMap<>(); + + public static final String CACHE_NAME = "nessie-secrets"; + private static final long NOT_CACHED = 0L; + + @VisibleForTesting final Cache> cache; + private final long ttlNanos; + private final LongSupplier clock; + + public CachingSecretsBackend(SecretsCacheConfig config) { + OptionalLong ttl = config.ttlMillis(); + this.ttlNanos = ttl.isPresent() ? TimeUnit.MILLISECONDS.toNanos(ttl.getAsLong()) : 0L; + this.clock = config.clockNanos(); + + Caffeine> cacheBuilder = + Caffeine.newBuilder() + .expireAfter( + new Expiry>() { + @Override + public long expireAfterCreate( + CacheKeyValue key, Map value, long currentTimeNanos) { + long expire = key.expiresAtNanosEpoch; + if (expire == NOT_CACHED) { + return 0L; + } + long remaining = expire - currentTimeNanos; + return Math.max(0L, remaining); + } + + @Override + public long expireAfterUpdate( + CacheKeyValue key, + Map value, + long currentTimeNanos, + @NonNegative long currentDurationNanos) { + return expireAfterCreate(key, value, currentTimeNanos); + } + + @Override + public long expireAfterRead( + CacheKeyValue key, + Map value, + long currentTimeNanos, + @NonNegative long currentDurationNanos) { + return currentDurationNanos; + } + }) + .ticker(clock::getAsLong); + config + .capacityMb() + .ifPresent(mb -> cacheBuilder.maximumWeight(mb * 1024L * 1024L).weigher(this::weigher)); + config.maxElements().ifPresent(cacheBuilder::maximumSize); + config + .meterRegistry() + .ifPresent( + meterRegistry -> { + cacheBuilder.recordStats(() -> new CaffeineStatsCounter(meterRegistry, CACHE_NAME)); + meterRegistry.gauge( + "cache_capacity_mb", + singletonList(Tag.of("cache", CACHE_NAME)), + "", + x -> config.capacityMb().orElse(0)); + meterRegistry.gauge( + "cache_max_size", + singletonList(Tag.of("cache", CACHE_NAME)), + "", + x -> config.maxElements().orElse(0)); + meterRegistry.gauge( + "cache_element_ttl", + singletonList(Tag.of("cache", CACHE_NAME)), + "", + x -> config.ttlMillis().orElse(0)); + }); + + this.cache = cacheBuilder.build(); + } + + private int weigher(CacheKeyValue key, Map value) { + int size = key.heapSize(); + if (isPresentValue(value)) { + for (Map.Entry e : value.entrySet()) { + size += STRING_OBJ_OVERHEAD + e.getKey().length(); + size += STRING_OBJ_OVERHEAD + e.getValue().length(); + } + size += MAP_OBJ_OVERHEAD; + } + size += CAFFEINE_OBJ_OVERHEAD; + return size; + } + + private static boolean isPresentValue(Map value) { + // Yes, same instance for + return value != null && value != CACHE_NEGATIVE_SENTINEL; + } + + Map> resolveSecrets( + String repositoryId, Collection names, SecretsSupplier backend) { + long ttl = ttlNanos; + long expires = ttl != 0L ? clock.getAsLong() + ttl : 0L; + Map keys = new HashMap<>(); + for (String name : names) { + keys.put(name, new CacheKeyValue(repositoryId, name, expires)); + } + + Map> result = new HashMap<>(); + + Map> present = cache.getAllPresent(keys.values()); + present.forEach( + (k, v) -> { + String name = k.name; + keys.remove(name); + if (isPresentValue(v)) { + result.put(name, v); + } + }); + + if (!keys.isEmpty()) { + Map> resolved = backend.resolveSecrets(keys.keySet()); + + Map> put = new HashMap<>(); + for (CacheKeyValue key : keys.values()) { + Map value = resolved.getOrDefault(key.name, CACHE_NEGATIVE_SENTINEL); + if (isPresentValue(value)) { + value = + unmodifiableMap( + value.entrySet().stream() + .collect(toMap(Map.Entry::getKey, CachingSecretsBackend::safeStringValue))); + result.put(key.name, value); + } + put.put(key, value); + } + cache.putAll(put); + } + + return result; + } + + private static String safeStringValue(Map.Entry e) { + // The string doesn't necessarily need to be a string, for example if the secrets backend + // returns another object (Vault could do that?). + return e.getValue().toString(); + } + + static final class CacheKeyValue { + final String repositoryId; + final String name; + + // Revisit this field before 2262-04-11T23:47:16.854Z (64-bit signed long overflow) ;) ;) + final long expiresAtNanosEpoch; + + CacheKeyValue(String repositoryId, String name, long expiresAtNanosEpoch) { + this.repositoryId = repositoryId; + this.name = name; + this.expiresAtNanosEpoch = expiresAtNanosEpoch; + } + + int heapSize() { + int size = OBJ_SIZE; + size += STRING_OBJ_OVERHEAD + repositoryId.length(); + size += STRING_OBJ_OVERHEAD + name.length(); + return size; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof CacheKeyValue)) { + return false; + } + CacheKeyValue cacheKey = (CacheKeyValue) o; + return repositoryId.equals(cacheKey.repositoryId) && name.equals(cacheKey.name); + } + + @Override + public int hashCode() { + return repositoryId.hashCode() * 31 + name.hashCode(); + } + + @Override + public String toString() { + return "{" + repositoryId + ", " + name + '}'; + } + } + + /* + CacheKeyValue object internals: + OFF SZ TYPE DESCRIPTION VALUE + 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) + 8 4 (object header: class) 0x010c4800 + 12 4 java.lang.String CacheKeyValue.repositoryId null + 16 8 long CacheKeyValue.expiresAt 0 + 24 4 java.lang.String CacheKeyValue.id null + 28 4 (object alignment gap) + Instance size: 32 bytes + */ + static final int OBJ_SIZE = 32; + // rough estimate, probably good enough + static final int MAP_OBJ_OVERHEAD = OBJ_SIZE * 2; + /* + Array overhead: 16 bytes + */ + static final int ARRAY_OVERHEAD = 16; + /* + java.lang.String object internals: + OFF SZ TYPE DESCRIPTION VALUE + 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) + 8 4 (object header: class) 0x0000e8d8 + 12 4 int String.hash 0 + 16 1 byte String.coder 0 + 17 1 boolean String.hashIsZero false + 18 2 (alignment/padding gap) + 20 4 byte[] String.value [] + Instance size: 24 bytes + */ + static final int STRING_OBJ_OVERHEAD = 24 + ARRAY_OVERHEAD; + /* + Assume an overhead of 2 objects for each entry (java.util.concurrent.ConcurrentHashMap$Node is 32 bytes) in Caffeine. + */ + static final int CAFFEINE_OBJ_OVERHEAD = 2 * 32; +} diff --git a/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/SecretsCacheConfig.java b/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/SecretsCacheConfig.java new file mode 100644 index 00000000000..e3c5649527b --- /dev/null +++ b/catalog/secrets/cache/src/main/java/org/projectnessie/catalog/secrets/cache/SecretsCacheConfig.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.cache; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.function.LongSupplier; +import org.immutables.value.Value; + +@Value.Immutable +public interface SecretsCacheConfig { + + OptionalLong maxElements(); + + OptionalLong capacityMb(); + + OptionalLong ttlMillis(); + + Optional meterRegistry(); + + @Value.Default + default LongSupplier clockNanos() { + return System::nanoTime; + } + + static Builder builder() { + return ImmutableSecretsCacheConfig.builder(); + } + + interface Builder { + @CanIgnoreReturnValue + Builder capacityMb(long capacityMb); + + @CanIgnoreReturnValue + Builder maxElements(long maxElements); + + @CanIgnoreReturnValue + Builder ttlMillis(long ttlMillis); + + @CanIgnoreReturnValue + Builder meterRegistry(MeterRegistry meterRegistry); + + @CanIgnoreReturnValue + Builder clockNanos(LongSupplier clockNanos); + + SecretsCacheConfig build(); + } +} diff --git a/catalog/secrets/cache/src/test/java/org/projectnessie/catalog/secrets/cache/TestCachingSecretsBackend.java b/catalog/secrets/cache/src/test/java/org/projectnessie/catalog/secrets/cache/TestCachingSecretsBackend.java new file mode 100644 index 00000000000..b143bf04f6b --- /dev/null +++ b/catalog/secrets/cache/src/test/java/org/projectnessie/catalog/secrets/cache/TestCachingSecretsBackend.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.cache; + +import static java.util.function.Function.identity; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.projectnessie.catalog.secrets.spi.SecretsSupplier; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestCachingSecretsBackend { + @InjectSoftAssertions protected SoftAssertions soft; + + static final Map SECRET_1_REPO_1 = Map.of("value", "secret1-repo1"); + static final Map SECRET_2_REPO_1 = Map.of("value", "secret2-repo1"); + static final Map SECRET_1_REPO_2 = Map.of("value", "secret1-repo2"); + static final Map SECRET_2_REPO_2 = Map.of("value", "secret2-repo2"); + + AtomicLong clock; + CachingSecretsBackend backend; + CachingSecrets cachingSecrets; + SecretsSupplier secrets1; + SecretsSupplier secrets2; + SecretsSupplier caching1; + SecretsSupplier caching2; + + @BeforeEach + void setup() { + clock = new AtomicLong(); + backend = + new CachingSecretsBackend( + SecretsCacheConfig.builder() + .capacityMb(1) + .ttlMillis(1000) + .clockNanos(clock::get) + .build()); + + cachingSecrets = new CachingSecrets(backend); + + secrets1 = mapSecretsSupplier(Map.of("secret1", SECRET_1_REPO_1, "secret2", SECRET_2_REPO_1)); + secrets2 = mapSecretsSupplier(Map.of("secret1", SECRET_1_REPO_2, "secret2", SECRET_2_REPO_2)); + + caching1 = cachingSecrets.forRepository("repo1", secrets1); + caching2 = cachingSecrets.forRepository("repo2", secrets2); + } + + @Test + public void ttlExpire() { + soft.assertThat(caching1.resolveSecrets(List.of("secret1"))) + .containsExactlyInAnyOrderEntriesOf(Map.of("secret1", SECRET_1_REPO_1)); + soft.assertThat(caching2.resolveSecrets(List.of("secret1"))) + .containsExactlyInAnyOrderEntriesOf(Map.of("secret1", SECRET_1_REPO_2)); + soft.assertThat(caching1.resolveSecrets(List.of("secret1"))) + .containsExactlyInAnyOrderEntriesOf(Map.of("secret1", SECRET_1_REPO_1)); + soft.assertThat(caching2.resolveSecrets(List.of("secret1"))) + .containsExactlyInAnyOrderEntriesOf(Map.of("secret1", SECRET_1_REPO_2)); + + soft.assertThat(backend.cache.asMap()) + .containsKey(new CachingSecretsBackend.CacheKeyValue("repo1", "secret1", 0)) + .containsKey(new CachingSecretsBackend.CacheKeyValue("repo2", "secret1", 0)); + + clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(1000)); + + soft.assertThat(backend.cache.asMap()) + .doesNotContainKey(new CachingSecretsBackend.CacheKeyValue("repo1", "secret1", 0)) + .doesNotContainKey(new CachingSecretsBackend.CacheKeyValue("repo2", "secret1", 0)); + + soft.assertThat(caching1.resolveSecrets(List.of("secret1", "secret2"))) + .containsExactlyInAnyOrderEntriesOf( + Map.of("secret1", SECRET_1_REPO_1, "secret2", SECRET_2_REPO_1)); + soft.assertThat(caching2.resolveSecrets(List.of("secret1", "secret2"))) + .containsExactlyInAnyOrderEntriesOf( + Map.of("secret1", SECRET_1_REPO_2, "secret2", SECRET_2_REPO_2)); + + soft.assertThat(backend.cache.asMap()) + .containsKey(new CachingSecretsBackend.CacheKeyValue("repo1", "secret1", 0)) + .containsKey(new CachingSecretsBackend.CacheKeyValue("repo2", "secret1", 0)) + .containsKey(new CachingSecretsBackend.CacheKeyValue("repo1", "secret2", 0)) + .containsKey(new CachingSecretsBackend.CacheKeyValue("repo2", "secret2", 0)); + } + + @Test + public void nonExisting() { + soft.assertThat(caching1.resolveSecrets(List.of("secret-nope1", "secret-nope2"))).isEmpty(); + soft.assertThat(caching2.resolveSecrets(List.of("secret-nope1", "secret-nope2"))).isEmpty(); + soft.assertThat(caching1.resolveSecrets(List.of("secret-nope1", "secret-nope2", "secret1"))) + .containsExactlyInAnyOrderEntriesOf(Map.of("secret1", SECRET_1_REPO_1)); + soft.assertThat(caching2.resolveSecrets(List.of("secret-nope1", "secret-nope2", "secret1"))) + .containsExactlyInAnyOrderEntriesOf(Map.of("secret1", SECRET_1_REPO_2)); + } + + @Test + public void tooManyEntries() { + SecretsSupplier supplier = + names -> names.stream().collect(Collectors.toMap(identity(), name -> Map.of(name, name))); + SecretsSupplier caching = cachingSecrets.forRepository("repoMany", supplier); + + // Very naive test that checks that expiration happens - our "weigher" does something. + + for (int i = 0; i < 100_000; i++) { + caching.resolveSecrets( + List.of( + "wepofkjeopwkfopwekfopkewfkpoewwepofkjeopwkfopwekfopkewfkpoewwepofkjeopwkfopwekfopkewfkpoewwepofkjeopwkfopwekfopkewfkpoewwepofkjeopwkfopwekfopkewfkpoew-" + + i)); + // In fact, the cache should never have more than 10000 entries, but 'estimatedSize' is really + // just an estimate. + soft.assertThat(backend.cache.estimatedSize()).isLessThan(10_000); + } + } + + static SecretsSupplier mapSecretsSupplier(Map> secretsMap) { + return names -> { + Map> resolved = new HashMap<>(); + for (String name : names) { + Map r = secretsMap.get(name); + if (r != null) { + resolved.put(name, r); + } + } + return resolved; + }; + } +} diff --git a/catalog/secrets/gcs/build.gradle.kts b/catalog/secrets/gcs/build.gradle.kts new file mode 100644 index 00000000000..2641bced3a6 --- /dev/null +++ b/catalog/secrets/gcs/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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 { + id("nessie-conventions-server") + id("nessie-jacoco") +} + +extra["maven.name"] = "Nessie - Catalog - Secrets GCS" + +dependencies { + implementation(project(":nessie-catalog-secrets-api")) + implementation(libs.guava) + + implementation(platform(libs.google.cloud.secretmanager.bom)) + implementation("com.google.cloud:google-cloud-secretmanager") + + compileOnly(project(":nessie-immutables")) + annotationProcessor(project(":nessie-immutables", configuration = "processor")) + // javax/jakarta + compileOnly(libs.jakarta.ws.rs.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.jakarta.validation.api) + + compileOnly(libs.errorprone.annotations) + compileOnly(libs.microprofile.openapi) + + testFixturesApi(platform(libs.junit.bom)) + testFixturesApi(libs.bundles.junit.testing) +} diff --git a/catalog/secrets/gcs/src/main/java/org/projectnessie/catalog/secrets/gcs/GcsSecretsSupplier.java b/catalog/secrets/gcs/src/main/java/org/projectnessie/catalog/secrets/gcs/GcsSecretsSupplier.java new file mode 100644 index 00000000000..ef882633812 --- /dev/null +++ b/catalog/secrets/gcs/src/main/java/org/projectnessie/catalog/secrets/gcs/GcsSecretsSupplier.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.gcs; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.cloud.secretmanager.v1.AccessSecretVersionRequest; +import com.google.cloud.secretmanager.v1.AccessSecretVersionResponse; +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import org.projectnessie.catalog.secrets.spi.SingleValueSecretsSupplier; + +public class GcsSecretsSupplier extends SingleValueSecretsSupplier { + private final SecretManagerServiceClient client; + + public GcsSecretsSupplier(SecretManagerServiceClient client) { + this.client = client; + } + + @Override + protected Map resolveSingleValueSecrets(Collection names) { + if (names.isEmpty()) { + return Map.of(); + } + + if (names.size() == 1) { + String name = names.iterator().next(); + AccessSecretVersionResponse response = client.accessSecretVersion(request(name)); + return response.hasPayload() ? Map.of(name, secret(response)) : Map.of(); + } + + List> futures = + names.stream() + .map(name -> client.accessSecretVersionCallable().futureCall(request(name))) + .collect(Collectors.toList()); + + List results; + try { + results = ApiFutures.successfulAsList(futures).get(1000, TimeUnit.MILLISECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + + return results.stream() + .collect(Collectors.toMap(AccessSecretVersionResponse::getName, this::secret)); + } + + private String secret(AccessSecretVersionResponse response) { + return response.getPayload().getData().toStringUtf8(); + } + + private AccessSecretVersionRequest request(String name) { + return AccessSecretVersionRequest.newBuilder().setName(name).build(); + } +} diff --git a/catalog/secrets/vault/build.gradle.kts b/catalog/secrets/vault/build.gradle.kts new file mode 100644 index 00000000000..69048c5422a --- /dev/null +++ b/catalog/secrets/vault/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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 { + id("nessie-conventions-server") + id("nessie-jacoco") +} + +extra["maven.name"] = "Nessie - Catalog - Secrets Vault" + +dependencies { + implementation(project(":nessie-catalog-secrets-api")) + implementation(libs.guava) + + implementation(enforcedPlatform(libs.quarkus.bom)) + implementation("io.quarkiverse.vault:quarkus-vault") + + compileOnly(project(":nessie-immutables")) + annotationProcessor(project(":nessie-immutables", configuration = "processor")) + // javax/jakarta + compileOnly(libs.jakarta.ws.rs.api) + compileOnly(libs.jakarta.enterprise.cdi.api) + compileOnly(libs.jakarta.validation.api) + + compileOnly(libs.errorprone.annotations) + compileOnly(libs.microprofile.openapi) + + testFixturesApi(platform(libs.junit.bom)) + testFixturesApi(libs.bundles.junit.testing) + + intTestImplementation(platform(libs.testcontainers.bom)) + intTestImplementation("org.testcontainers:testcontainers") + intTestImplementation("org.testcontainers:vault") + intTestImplementation("org.testcontainers:junit-jupiter") + intTestImplementation(project(":nessie-container-spec-helper")) + intTestImplementation(libs.smallrye.config.core) + intTestCompileOnly(libs.immutables.value.annotations) + intTestRuntimeOnly(libs.logback.classic) +} diff --git a/catalog/secrets/vault/src/intTest/java/org/projectnessie/catalog/secrets/vault/ITVaultSecretsSupplier.java b/catalog/secrets/vault/src/intTest/java/org/projectnessie/catalog/secrets/vault/ITVaultSecretsSupplier.java new file mode 100644 index 00000000000..4d7bd3d9004 --- /dev/null +++ b/catalog/secrets/vault/src/intTest/java/org/projectnessie/catalog/secrets/vault/ITVaultSecretsSupplier.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.vault; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.quarkus.runtime.TlsConfig; +import io.quarkus.vault.VaultKVSecretReactiveEngine; +import io.quarkus.vault.client.VaultClient; +import io.quarkus.vault.runtime.VaultConfigHolder; +import io.quarkus.vault.runtime.VaultKvManager; +import io.quarkus.vault.runtime.client.VaultClientProducer; +import io.quarkus.vault.runtime.config.VaultRuntimeConfig; +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfig; +import io.smallrye.config.SmallRyeConfigBuilder; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.nessie.testing.containerspec.ContainerSpecHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.vault.VaultContainer; + +@Testcontainers +public class ITVaultSecretsSupplier { + private static final Logger LOGGER = LoggerFactory.getLogger(ITVaultSecretsSupplier.class); + + public static final String VAULT_TOKEN = "root"; + public static final String NESSIE_SECRETS_PATH = "apps/nessie/secrets"; + + @Container + static VaultContainer vaultContainer = + new VaultContainer<>( + ContainerSpecHelper.builder() + .name("vault") + .containerClass(ITVaultSecretsSupplier.class) + .build() + .dockerImageName(null) + .asCompatibleSubstituteFor("vault")) + .withLogConsumer(c -> LOGGER.info("[VAULT] {}", c.getUtf8StringWithoutLineEnding())) + .withVaultToken(VAULT_TOKEN); + + @Test + public void vault() { + SmallRyeConfig config = + new SmallRyeConfigBuilder() + .setAddDefaultSources(false) + .setAddDiscoveredSources(false) + .withMapping(VaultRuntimeConfig.class) + .withSources( + new PropertiesConfigSource( + Map.of( + "quarkus.vault.url", + vaultContainer.getHttpHostAddress(), + "quarkus.vault.authentication.client-token", + VAULT_TOKEN, + "quarkus.vault.secret-config-kv-path", + NESSIE_SECRETS_PATH), + "configSource", + 100)) + .build(); + + VaultRuntimeConfig runtimeConfig = config.getConfigMapping(VaultRuntimeConfig.class); + + VaultConfigHolder configHolder = new VaultConfigHolder(); + configHolder.setVaultRuntimeConfig(runtimeConfig); + TlsConfig tlsConfig = new TlsConfig(); + tlsConfig.trustAll = true; + VaultClient client = new VaultClientProducer().privateVaultClient(configHolder, tlsConfig); + + VaultKVSecretReactiveEngine vault = new VaultKvManager(client, configHolder); + + vault.writeSecret("foo", Map.of("value", "secret-foo")).await().indefinitely(); + vault.writeSecret("single", Map.of("bar", "secret-single")).await().indefinitely(); + vault + .writeSecret("multi", Map.of("name", "the-name", "value", "the-value")) + .await() + .indefinitely(); + + VaultSecretsSupplier vaultSecretsSupplier = new VaultSecretsSupplier(vault); + + assertThat(vaultSecretsSupplier.resolveSecrets(List.of("foo", "single", "multi"))) + .containsEntry("foo", Map.of("value", "secret-foo")) + .containsEntry("single", Map.of("bar", "secret-single")) + .containsEntry("multi", Map.of("name", "the-name", "value", "the-value")) + .hasSize(3); + } +} diff --git a/catalog/secrets/vault/src/intTest/resources/logback-test.xml b/catalog/secrets/vault/src/intTest/resources/logback-test.xml new file mode 100644 index 00000000000..4f3fda900d7 --- /dev/null +++ b/catalog/secrets/vault/src/intTest/resources/logback-test.xml @@ -0,0 +1,30 @@ + + + + + + + %date{ISO8601} [%thread] [%X{nessie.events.subscription.id}] %-5level %logger{36} - + %msg%n + + + + + + diff --git a/catalog/secrets/vault/src/intTest/resources/org/projectnessie/catalog/secrets/vault/Dockerfile-vault-version b/catalog/secrets/vault/src/intTest/resources/org/projectnessie/catalog/secrets/vault/Dockerfile-vault-version new file mode 100644 index 00000000000..5d45fa761ed --- /dev/null +++ b/catalog/secrets/vault/src/intTest/resources/org/projectnessie/catalog/secrets/vault/Dockerfile-vault-version @@ -0,0 +1,3 @@ +# Dockerfile to provide the image name and tag to a test. +# Version is managed by Renovate - do not edit. +FROM docker.io/hashicorp/vault:1.16.3 diff --git a/catalog/secrets/vault/src/main/java/org/projectnessie/catalog/secrets/vault/VaultSecretsSupplier.java b/catalog/secrets/vault/src/main/java/org/projectnessie/catalog/secrets/vault/VaultSecretsSupplier.java new file mode 100644 index 00000000000..2f9c7a72e73 --- /dev/null +++ b/catalog/secrets/vault/src/main/java/org/projectnessie/catalog/secrets/vault/VaultSecretsSupplier.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 Dremio + * + * Licensed 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.projectnessie.catalog.secrets.vault; + +import io.quarkus.vault.VaultKVSecretReactiveEngine; +import io.smallrye.mutiny.Uni; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; +import org.projectnessie.catalog.secrets.spi.SecretsSupplier; + +public class VaultSecretsSupplier implements SecretsSupplier { + private final VaultKVSecretReactiveEngine secretEngine; + + public VaultSecretsSupplier(VaultKVSecretReactiveEngine secretEngine) { + this.secretEngine = secretEngine; + } + + @Override + public Map> resolveSecrets(Collection names) { + return Uni.join() + .all( + names.stream() + .map(name -> secretEngine.readSecret(path(name)).map(json -> Map.entry(name, json))) + .collect(Collectors.toList())) + .andCollectFailures() + .await() + .atMost(Duration.of(1, ChronoUnit.SECONDS)) + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private String path(String name) { + // TODO - is this good enough to rely on quarkus.vault.secret-config-kv-path ?? + return name; + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f26454469d..912e3b7d956 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,6 +65,7 @@ gatling-charts-highcharts = { module = "io.gatling.highcharts:gatling-charts-hig google-cloud-bigtable-bom = { module = "com.google.cloud:google-cloud-bigtable-bom", version = "2.39.5" } google-cloud-gcs-connector = { module = "com.google.cloud.bigdataoss:gcs-connector", version = "hadoop3-2.2.21" } google-cloud-nio = { module = "com.google.cloud:google-cloud-nio", version = "0.127.19" } +google-cloud-secretmanager-bom = { module = "com.google.cloud:google-cloud-secretmanager-bom", version = "2.45.0" } google-cloud-storage-bom = { module = "com.google.cloud:google-cloud-storage-bom", version = "2.40.0" } google-java-format = { module = "com.google.googlejavaformat:google-java-format", version.ref = "googleJavaFormat" } guava = { module = "com.google.guava:guava", version = "33.2.1-jre" } diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties index 0d0c16ca921..91dd7f1a4f4 100644 --- a/gradle/projects.main.properties +++ b/gradle/projects.main.properties @@ -90,3 +90,7 @@ nessie-catalog-service-common=catalog/service/common nessie-catalog-service-rest=catalog/service/rest nessie-catalog-service-impl=catalog/service/impl nessie-catalog-secrets-api=catalog/secrets/api +nessie-catalog-secrets-cache=catalog/secrets/cache +nessie-catalog-secrets-aws=catalog/secrets/aws +nessie-catalog-secrets-gcs=catalog/secrets/gcs +nessie-catalog-secrets-vault=catalog/secrets/vault