From 28c1dd90954bc081ab502cf604ebbcf0b9b88aaa Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Mon, 8 Jun 2026 01:21:11 +0530
Subject: [PATCH 01/18] Adding all the newly created Secret Manager Plugins.
Currently testing is being done with AWS Secret Manager plugin. For other
plugins, I need to create account at all the providers then will update the
code if required.
---
aws-secrets-provider/build.gradle | 30 ++
.../config/aws-secrets-manager.properties | 53 ++
aws-secrets-provider/ofbiz-component.xml | 30 ++
.../awssecrets/AwsSecretsManagerProvider.java | 207 ++++++++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../AwsSecretsManagerProviderTest.java | 163 +++++++
azure-keyvault-secrets-provider/build.gradle | 29 ++
.../config/azure-keyvault.properties | 39 ++
.../ofbiz-component.xml | 30 ++
.../azurekeyvault/AzureKeyVaultReader.java | 35 ++
.../AzureKeyVaultSecretsProvider.java | 180 +++++++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../AzureKeyVaultSecretsProviderTest.java | 131 +++++
bitwarden-secrets-provider/build.gradle | 27 +
.../config/bitwarden-secrets.properties | 59 +++
.../ofbiz-component.xml | 30 ++
.../ofbiz/bitwarden/BitwardenHttpClient.java | 49 ++
.../bitwarden/BitwardenSecretsProvider.java | 461 ++++++++++++++++++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../BitwardenSecretsProviderTest.java | 220 +++++++++
.../build.gradle | 28 ++
.../config/gcp-secret-manager.properties | 34 ++
.../ofbiz-component.xml | 30 ++
.../GcpSecretManagerSecretsProvider.java | 201 ++++++++
.../gcpsecretmanager/GcpSecretReader.java | 35 ++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../GcpSecretManagerSecretsProviderTest.java | 147 ++++++
hashicorp-vault-secrets-provider/build.gradle | 28 ++
.../config/hashicorp-vault-secrets.properties | 66 +++
.../ofbiz-component.xml | 30 ++
.../hashicorpvault/HashicorpVaultReader.java | 39 ++
.../HashicorpVaultSecretsProvider.java | 226 +++++++++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../HashicorpVaultSecretsProviderTest.java | 156 ++++++
onepassword-secrets-provider/build.gradle | 23 +
.../config/onepassword.properties | 56 +++
.../ofbiz-component.xml | 30 ++
.../onepassword/OnePasswordHttpClient.java | 39 ++
.../OnePasswordSecretsProvider.java | 275 +++++++++++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../OnePasswordSecretsProviderTest.java | 156 ++++++
41 files changed, 3378 insertions(+)
create mode 100644 aws-secrets-provider/build.gradle
create mode 100644 aws-secrets-provider/config/aws-secrets-manager.properties
create mode 100644 aws-secrets-provider/ofbiz-component.xml
create mode 100644 aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
create mode 100644 aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
create mode 100644 azure-keyvault-secrets-provider/build.gradle
create mode 100644 azure-keyvault-secrets-provider/config/azure-keyvault.properties
create mode 100644 azure-keyvault-secrets-provider/ofbiz-component.xml
create mode 100644 azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java
create mode 100644 azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
create mode 100644 azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
create mode 100644 bitwarden-secrets-provider/build.gradle
create mode 100644 bitwarden-secrets-provider/config/bitwarden-secrets.properties
create mode 100644 bitwarden-secrets-provider/ofbiz-component.xml
create mode 100644 bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
create mode 100644 bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
create mode 100644 bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
create mode 100644 gcp-secretmanager-secrets-provider/build.gradle
create mode 100644 gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
create mode 100644 gcp-secretmanager-secrets-provider/ofbiz-component.xml
create mode 100644 gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
create mode 100644 gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java
create mode 100644 gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
create mode 100644 hashicorp-vault-secrets-provider/build.gradle
create mode 100644 hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
create mode 100644 hashicorp-vault-secrets-provider/ofbiz-component.xml
create mode 100644 hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java
create mode 100644 hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
create mode 100644 hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
create mode 100644 onepassword-secrets-provider/build.gradle
create mode 100644 onepassword-secrets-provider/config/onepassword.properties
create mode 100644 onepassword-secrets-provider/ofbiz-component.xml
create mode 100644 onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
create mode 100644 onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
create mode 100644 onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
diff --git a/aws-secrets-provider/build.gradle b/aws-secrets-provider/build.gradle
new file mode 100644
index 000000000..48c5c3dd7
--- /dev/null
+++ b/aws-secrets-provider/build.gradle
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+// AWS SDK for Java v2 — Apache License 2.0
+// https://github.com/aws/aws-sdk-java-v2
+dependencies {
+ pluginLibsCompile 'software.amazon.awssdk:secretsmanager:2.26.31'
+ pluginLibsCompile 'software.amazon.awssdk:url-connection-client:2.26.31'
+}
+
+// AWS SDK v2 bundles several items that conflict with OFBiz's global exclusions
+configurations.all {
+ exclude group: 'commons-logging', module: 'commons-logging'
+}
diff --git a/aws-secrets-provider/config/aws-secrets-manager.properties b/aws-secrets-provider/config/aws-secrets-manager.properties
new file mode 100644
index 000000000..ba521ee71
--- /dev/null
+++ b/aws-secrets-provider/config/aws-secrets-manager.properties
@@ -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.
+###############################################################################
+
+####
+# AWS Secrets Manager — SecretProvider configuration
+#
+# Authentication uses the AWS Default Credential Provider Chain:
+# 1. Environment variables: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
+# 2. Java system properties: aws.accessKeyId / aws.secretAccessKey
+# 3. AWS credential profiles file (~/.aws/credentials)
+# 4. EC2 / ECS instance profile / IAM role (recommended for production)
+# 5. AWS SSO
+#
+# No credentials should ever be stored in this file.
+####
+
+# AWS region for the Secrets Manager endpoint.
+# Leave empty to let the SDK resolve from the environment (recommended for EC2/ECS).
+aws.secretsmanager.region=us-east-1
+
+# How long (in seconds) to cache a resolved secret value in memory before re-fetching.
+# Default: 3600 (1 hour). Set to 0 to disable caching (not recommended in production).
+aws.secretsmanager.cache.ttl.seconds=3600
+
+# Optional prefix prepended to every OFBiz secret key before the AWS lookup.
+# Example: with prefix "myapp/prod/" the key "jdbc-password.ofbiz" becomes
+# "myapp/prod/jdbc-password.ofbiz" in AWS Secrets Manager.
+aws.secretsmanager.secret.name.prefix=
+
+# If the secret in AWS is a JSON object (e.g. {"password":"s3cr3t","username":"dbuser"}),
+# set this to the field name that holds the actual secret value (e.g. "password").
+# Leave empty to use the raw secret string as-is.
+aws.secretsmanager.json.field=
+
+# Optional endpoint override. Useful for local testing with LocalStack.
+# Example: http://localhost:4566
+aws.secretsmanager.endpoint.override=
diff --git a/aws-secrets-provider/ofbiz-component.xml b/aws-secrets-provider/ofbiz-component.xml
new file mode 100644
index 000000000..445cfb3ae
--- /dev/null
+++ b/aws-secrets-provider/ofbiz-component.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
new file mode 100644
index 000000000..83c2fbb83
--- /dev/null
+++ b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.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.ofbiz.awssecrets;
+
+import java.net.URI;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
+import software.amazon.awssdk.services.secretsmanager.SecretsManagerClientBuilder;
+import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
+import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
+import software.amazon.awssdk.services.secretsmanager.model.ResourceNotFoundException;
+import software.amazon.awssdk.services.secretsmanager.model.SecretsManagerException;
+
+/**
+ * {@link SecretProvider} implementation backed by AWS Secrets Manager.
+ *
+ * Authentication is handled by the AWS Default Credential Provider Chain —
+ * no credentials are stored in this file or in properties files. The preferred
+ * approach for EC2/ECS/EKS deployments is an IAM instance role or task role,
+ * which requires zero credential configuration on the server.
+ *
+ * Resolved secret values are cached in memory for the duration configured by
+ * {@code aws.secretsmanager.cache.ttl.seconds} (default 1 hour) to avoid
+ * per-request API calls. Call {@link #invalidateCache()} to force an immediate
+ * re-fetch, for example after a manual secret rotation.
+ *
+ * Configure via {@code plugins/aws-secrets-provider/config/aws-secrets-manager.properties}.
+ */
+@ThreadSafe
+public final class AwsSecretsManagerProvider implements SecretProvider {
+
+ private static final String MODULE = AwsSecretsManagerProvider.class.getName();
+ private static final String CONFIG_RESOURCE = "aws-secrets-manager";
+
+ private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
+
+ private final SecretsManagerClient client;
+ private final long cacheTtlMs;
+ private final String secretNamePrefix;
+ private final String jsonField;
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ private static final class CacheEntry {
+ private final String value;
+ private final long expiresAt;
+
+ CacheEntry(String value, long ttlMs) {
+ this.value = value;
+ this.expiresAt = System.currentTimeMillis() + ttlMs;
+ }
+
+ String getValue() {
+ return value;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public AwsSecretsManagerProvider() {
+ this(buildClient(), readTtlMs(),
+ UtilProperties.getPropertyValue(CONFIG_RESOURCE, "aws.secretsmanager.secret.name.prefix", ""),
+ UtilProperties.getPropertyValue(CONFIG_RESOURCE, "aws.secretsmanager.json.field", ""));
+ }
+
+ /** Package-private constructor used by unit tests to inject a mock client. */
+ AwsSecretsManagerProvider(SecretsManagerClient client, long cacheTtlMs,
+ String secretNamePrefix, String jsonField) {
+ this.client = client;
+ this.cacheTtlMs = cacheTtlMs;
+ this.secretNamePrefix = secretNamePrefix;
+ this.jsonField = jsonField;
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ CacheEntry cached = cache.get(key);
+ if (cached != null && !cached.isExpired()) {
+ return cached.getValue();
+ }
+
+ String secretName = secretNamePrefix + key;
+ String secretValue = fetchFromAws(secretName);
+
+ if (!jsonField.isEmpty()) {
+ secretValue = extractJsonField(secretValue, secretName);
+ }
+
+ if (secretValue == null || secretValue.isEmpty()) {
+ throw new GeneralException("Secret '" + secretName + "' resolved to an empty value");
+ }
+
+ cache.put(key, new CacheEntry(secretValue, cacheTtlMs));
+ return secretValue;
+ }
+
+ /**
+ * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
+ * for each key to re-fetch from AWS Secrets Manager. Useful after a manual
+ * secret rotation to pick up the new value without restarting OFBiz.
+ */
+ public void invalidateCache() {
+ cache.clear();
+ Debug.logInfo("AwsSecretsManagerProvider: secret cache invalidated", MODULE);
+ }
+
+ // -- private helpers --
+
+ private String fetchFromAws(String secretName) throws GeneralException {
+ try {
+ GetSecretValueResponse response = client.getSecretValue(
+ GetSecretValueRequest.builder().secretId(secretName).build());
+ String value = response.secretString();
+ if (value == null) {
+ throw new GeneralException("Secret '" + secretName
+ + "' contains binary data; only string secrets are supported");
+ }
+ return value;
+ } catch (ResourceNotFoundException e) {
+ throw new GeneralException("Secret '" + secretName + "' not found in AWS Secrets Manager", e);
+ } catch (SecretsManagerException e) {
+ throw new GeneralException("AWS Secrets Manager error for '" + secretName + "': " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Extracts a single string field from a flat JSON secret value.
+ * AWS typically stores database credentials as {"username":"u","password":"p"}.
+ */
+ private String extractJsonField(String json, String secretName) throws GeneralException {
+ try {
+ JsonNode root = JSON_MAPPER.readTree(json);
+ JsonNode node = root.get(jsonField);
+ if (node == null || node.isNull()) {
+ throw new GeneralException(
+ "JSON field '" + jsonField + "' not found in secret '" + secretName + "'");
+ }
+ return node.asText();
+ } catch (JsonProcessingException e) {
+ throw new GeneralException("Failed to parse JSON for secret '" + secretName + "': " + e.getMessage(), e);
+ }
+ }
+
+ private static SecretsManagerClient buildClient() {
+ String region = UtilProperties.getPropertyValue(CONFIG_RESOURCE, "aws.secretsmanager.region", "");
+ String endpointOverride = UtilProperties.getPropertyValue(CONFIG_RESOURCE,
+ "aws.secretsmanager.endpoint.override", "");
+
+ SecretsManagerClientBuilder builder = SecretsManagerClient.builder()
+ .httpClient(UrlConnectionHttpClient.builder().build());
+
+ if (!region.isEmpty()) {
+ builder.region(Region.of(region));
+ }
+ if (!endpointOverride.isEmpty()) {
+ builder.endpointOverride(URI.create(endpointOverride));
+ }
+
+ Debug.logInfo("AwsSecretsManagerProvider: initialized"
+ + (region.isEmpty() ? " (region from environment)" : " region=" + region), MODULE);
+ return builder.build();
+ }
+
+ private static long readTtlMs() {
+ String raw = UtilProperties.getPropertyValue(CONFIG_RESOURCE,
+ "aws.secretsmanager.cache.ttl.seconds", "3600");
+ try {
+ return Long.parseLong(raw.trim()) * 1000L;
+ } catch (NumberFormatException e) {
+ Debug.logWarning("Invalid aws.secretsmanager.cache.ttl.seconds '" + raw
+ + "', defaulting to 3600s", MODULE);
+ return 3_600_000L;
+ }
+ }
+}
diff --git a/aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..8d42eba0c
--- /dev/null
+++ b/aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.awssecrets.AwsSecretsManagerProvider
diff --git a/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java b/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
new file mode 100644
index 000000000..fb6f64b69
--- /dev/null
+++ b/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
@@ -0,0 +1,163 @@
+/*******************************************************************************
+ * 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.ofbiz.awssecrets;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
+import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
+import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
+import software.amazon.awssdk.services.secretsmanager.model.ResourceNotFoundException;
+
+public class AwsSecretsManagerProviderTest {
+
+ private static final long ONE_HOUR_MS = 3_600_000L;
+
+ // -- Happy path --
+
+ @Test
+ public void getSecretReturnsSecretString() throws GeneralException {
+ SecretsManagerClient client = clientReturning("jdbc-password.mydb", "s3cr3t");
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "");
+
+ assertEquals("s3cr3t", provider.getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecretCachesPreventsSecondAwsCall() throws GeneralException {
+ SecretsManagerClient client = clientReturning("jdbc-password.mydb", "s3cr3t");
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "");
+
+ provider.getSecret("jdbc-password.mydb");
+ provider.getSecret("jdbc-password.mydb"); // second call — should hit cache
+
+ verify(client, times(1)).getSecretValue(any(GetSecretValueRequest.class));
+ }
+
+ @Test
+ public void getSecretAppliesSecretNamePrefix() throws GeneralException {
+ SecretsManagerClient client = mock(SecretsManagerClient.class);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GetSecretValueRequest.class);
+ when(client.getSecretValue(captor.capture()))
+ .thenReturn(response("val"));
+
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "myapp/prod/", "");
+ provider.getSecret("jdbc-password.ofbiz");
+
+ assertEquals("myapp/prod/jdbc-password.ofbiz", captor.getValue().secretId());
+ }
+
+ @Test
+ public void getSecretExtractsJsonFieldWhenConfigured() throws GeneralException {
+ String json = "{\"username\":\"dbuser\",\"password\":\"dbpass\"}";
+ SecretsManagerClient client = clientReturning("jdbc-password.mydb", json);
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "password");
+
+ assertEquals("dbpass", provider.getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecretUsesRawStringWhenNoJsonFieldConfigured() throws GeneralException {
+ String rawPassword = "plain-text-password";
+ SecretsManagerClient client = clientReturning("jdbc-password.mydb", rawPassword);
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "");
+
+ assertEquals("plain-text-password", provider.getSecret("jdbc-password.mydb"));
+ }
+
+ // -- Cache invalidation --
+
+ @Test
+ public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException {
+ SecretsManagerClient client = clientReturning("jdbc-password.mydb", "val");
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "");
+
+ provider.getSecret("jdbc-password.mydb");
+ provider.invalidateCache();
+ provider.getSecret("jdbc-password.mydb");
+
+ verify(client, times(2)).getSecretValue(any(GetSecretValueRequest.class));
+ }
+
+ @Test
+ public void getSecretExpiredCacheEntryTriggersRefetch() throws GeneralException {
+ SecretsManagerClient client = clientReturning("jdbc-password.mydb", "val");
+ // TTL of -1 ms means the entry expires immediately (expiresAt is in the past)
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, -1L, "", "");
+
+ provider.getSecret("jdbc-password.mydb");
+ provider.getSecret("jdbc-password.mydb"); // cache entry is expired — must re-fetch
+
+ verify(client, times(2)).getSecretValue(any(GetSecretValueRequest.class));
+ }
+
+ // -- Error handling --
+
+ @Test(expected = GeneralException.class)
+ public void getSecretThrowsWhenSecretNotFound() throws GeneralException {
+ SecretsManagerClient client = mock(SecretsManagerClient.class);
+ when(client.getSecretValue(any(GetSecretValueRequest.class)))
+ .thenThrow(ResourceNotFoundException.builder().message("not found").build());
+
+ new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "")
+ .getSecret("jdbc-password.missing");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecretThrowsWhenJsonFieldMissing() throws GeneralException {
+ String json = "{\"username\":\"dbuser\"}"; // no "password" field
+ SecretsManagerClient client = clientReturning("jdbc-password.mydb", json);
+
+ new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "password")
+ .getSecret("jdbc-password.mydb");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecretThrowsWhenSecretStringIsNull() throws GeneralException {
+ SecretsManagerClient client = mock(SecretsManagerClient.class);
+ // secretString() returns null — this is a binary secret
+ when(client.getSecretValue(any(GetSecretValueRequest.class)))
+ .thenReturn(GetSecretValueResponse.builder().build());
+
+ new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "")
+ .getSecret("binary-secret");
+ }
+
+ // -- helpers --
+
+ private static SecretsManagerClient clientReturning(String secretId, String secretValue) {
+ SecretsManagerClient client = mock(SecretsManagerClient.class);
+ when(client.getSecretValue(any(GetSecretValueRequest.class)))
+ .thenReturn(response(secretValue));
+ return client;
+ }
+
+ private static GetSecretValueResponse response(String secretValue) {
+ return GetSecretValueResponse.builder().secretString(secretValue).build();
+ }
+}
diff --git a/azure-keyvault-secrets-provider/build.gradle b/azure-keyvault-secrets-provider/build.gradle
new file mode 100644
index 000000000..9f36ae2e2
--- /dev/null
+++ b/azure-keyvault-secrets-provider/build.gradle
@@ -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.
+ */
+
+// Azure SDK for Java — Key Vault Secrets (MIT License)
+// https://github.com/Azure/azure-sdk-for-java
+dependencies {
+ pluginLibsCompile 'com.azure:azure-security-keyvault-secrets:4.8.0'
+ pluginLibsCompile 'com.azure:azure-identity:1.12.0'
+}
+
+configurations.all {
+ exclude group: 'commons-logging', module: 'commons-logging'
+}
diff --git a/azure-keyvault-secrets-provider/config/azure-keyvault.properties b/azure-keyvault-secrets-provider/config/azure-keyvault.properties
new file mode 100644
index 000000000..fd8f262a6
--- /dev/null
+++ b/azure-keyvault-secrets-provider/config/azure-keyvault.properties
@@ -0,0 +1,39 @@
+###############################################################################
+# Azure Key Vault — SecretProvider configuration
+#
+# To activate this provider:
+# 1. Set enabled="true" in azure-keyvault-secrets-provider/ofbiz-component.xml
+# 2. Set enabled="false" on every other *-secrets-provider plugin
+###############################################################################
+
+# Full URL of your Azure Key Vault (required)
+# Example: https://my-vault.vault.azure.net
+azure.keyvault.url=
+
+# Authentication method:
+# default — Uses DefaultAzureCredential: tries Managed Identity, env vars,
+# Azure CLI, Visual Studio Code, etc. in order. Recommended for
+# Azure-hosted deployments (AKS, App Service, VMs with Managed Identity).
+# client_secret — Authenticates as a service principal using tenant + client credentials.
+# Use for on-premise or non-Azure deployments.
+azure.auth.method=default
+
+# Required only when azure.auth.method=client_secret
+azure.tenant.id=
+azure.client.id=
+azure.client.secret=
+
+# Optional prefix prepended to every secret name before lookup.
+# Example: "prod-" makes key "db" → "prod-db"
+azure.secret.name.prefix=
+
+# Azure Key Vault secret names may only contain letters, digits and hyphens.
+# OFBiz keys like "jdbc-password.mysql-ofbiz" contain a dot, which is invalid.
+# Set this to the replacement character (default: -) so the dot is substituted.
+# Example: "jdbc-password.mysql-ofbiz" → "jdbc-password-mysql-ofbiz"
+# Set to empty to disable replacement (only do this if your keys have no dots).
+azure.secret.name.dot.replacement=-
+
+# In-memory cache TTL in seconds (default: 3600 = 1 hour).
+# Set to 0 to disable caching (fetches from Azure on every call).
+azure.cache.ttl.seconds=3600
diff --git a/azure-keyvault-secrets-provider/ofbiz-component.xml b/azure-keyvault-secrets-provider/ofbiz-component.xml
new file mode 100644
index 000000000..65ed6fdfd
--- /dev/null
+++ b/azure-keyvault-secrets-provider/ofbiz-component.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java
new file mode 100644
index 000000000..a268df676
--- /dev/null
+++ b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.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.ofbiz.azurekeyvault;
+
+/**
+ * Thin seam over Azure Key Vault reads used by {@link AzureKeyVaultSecretsProvider}.
+ * Kept package-private so tests can substitute a lambda without connecting to Azure.
+ */
+@FunctionalInterface
+interface AzureKeyVaultReader {
+ /**
+ * Retrieves the current value of the named secret.
+ *
+ * @param secretName the Key Vault secret name (letters, digits and hyphens only)
+ * @return the plaintext secret value; never {@code null}
+ * @throws Exception if the read fails or the secret does not exist
+ */
+ String read(String secretName) throws Exception;
+}
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
new file mode 100644
index 000000000..b32a8b2d3
--- /dev/null
+++ b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.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.ofbiz.azurekeyvault;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.azure.core.credential.TokenCredential;
+import com.azure.identity.ClientSecretCredentialBuilder;
+import com.azure.identity.DefaultAzureCredentialBuilder;
+import com.azure.security.keyvault.secrets.SecretClient;
+import com.azure.security.keyvault.secrets.SecretClientBuilder;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+/**
+ * {@link SecretProvider} implementation backed by Azure Key Vault.
+ *
+ * Authentication
+ * Controlled by {@code azure.auth.method}:
+ *
+ * - default — Uses {@code DefaultAzureCredential}, which tries
+ * Managed Identity, environment variables, Azure CLI, and more in sequence.
+ * Recommended for Azure-hosted deployments (AKS, App Service, VMs with MI).
+ * - client_secret — Authenticates as a service principal using
+ * {@code azure.tenant.id}, {@code azure.client.id}, and {@code azure.client.secret}.
+ * Suitable for on-premise or multi-cloud deployments.
+ *
+ *
+ * Secret name mapping
+ * Azure Key Vault secret names may only contain letters, digits and hyphens.
+ * OFBiz keys contain dots (e.g. {@code jdbc-password.mysql-ofbiz}), which are invalid.
+ * Set {@code azure.secret.name.dot.replacement=-} (the default) to replace dots with
+ * hyphens, so the key maps to {@code jdbc-password-mysql-ofbiz} in the vault.
+ *
+ * Configure via {@code plugins/azure-keyvault-secrets-provider/config/azure-keyvault.properties}.
+ */
+@ThreadSafe
+public final class AzureKeyVaultSecretsProvider implements SecretProvider {
+
+ private static final String MODULE = AzureKeyVaultSecretsProvider.class.getName();
+ private static final String CONFIG_RESOURCE = "azure-keyvault";
+
+ private final AzureKeyVaultReader vaultReader;
+ private final String secretNamePrefix;
+ private final String dotReplacement;
+ private final long cacheTtlMs;
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ private static final class CacheEntry {
+ final String value;
+ final long expiresAt;
+
+ CacheEntry(String value, long ttlMs) {
+ this.value = value;
+ this.expiresAt = System.currentTimeMillis() + ttlMs;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public AzureKeyVaultSecretsProvider() {
+ this(readerFrom(buildClient()),
+ prop("azure.secret.name.prefix", ""),
+ prop("azure.secret.name.dot.replacement", "-"),
+ readTtlMs());
+ }
+
+ /** Package-private constructor used by unit tests to inject an {@link AzureKeyVaultReader} lambda. */
+ AzureKeyVaultSecretsProvider(AzureKeyVaultReader vaultReader, String secretNamePrefix,
+ String dotReplacement, long cacheTtlMs) {
+ this.vaultReader = vaultReader;
+ this.secretNamePrefix = secretNamePrefix;
+ this.dotReplacement = dotReplacement;
+ this.cacheTtlMs = cacheTtlMs;
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ CacheEntry cached = cache.get(key);
+ if (cached != null && !cached.isExpired()) {
+ return cached.value;
+ }
+
+ String sanitizedKey = dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement);
+ String secretName = secretNamePrefix + sanitizedKey;
+
+ String value;
+ try {
+ value = vaultReader.read(secretName);
+ } catch (Exception e) {
+ throw new GeneralException(
+ "Azure Key Vault read failed for secret '" + secretName + "': " + e.getMessage(), e);
+ }
+
+ if (value == null || value.isEmpty()) {
+ throw new GeneralException(
+ "Azure Key Vault returned empty value for secret '" + secretName + "'");
+ }
+
+ cache.put(key, new CacheEntry(value, cacheTtlMs));
+ return value;
+ }
+
+ /**
+ * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
+ * to re-fetch from Azure Key Vault. Useful after a secret rotation.
+ */
+ public void invalidateCache() {
+ cache.clear();
+ Debug.logInfo("AzureKeyVaultSecretsProvider: secret cache invalidated", MODULE);
+ }
+
+ // -- private helpers --
+
+ private static AzureKeyVaultReader readerFrom(SecretClient client) {
+ return secretName -> client.getSecret(secretName).getValue();
+ }
+
+ private static SecretClient buildClient() {
+ String vaultUrl = prop("azure.keyvault.url", "");
+ String authMethod = prop("azure.auth.method", "default");
+
+ TokenCredential credential;
+ if ("client_secret".equalsIgnoreCase(authMethod)) {
+ credential = new ClientSecretCredentialBuilder()
+ .tenantId(prop("azure.tenant.id", ""))
+ .clientId(prop("azure.client.id", ""))
+ .clientSecret(prop("azure.client.secret", ""))
+ .build();
+ } else {
+ credential = new DefaultAzureCredentialBuilder().build();
+ }
+
+ Debug.logInfo("AzureKeyVaultSecretsProvider: initialized vault=" + vaultUrl
+ + " auth=" + authMethod, MODULE);
+
+ return new SecretClientBuilder()
+ .vaultUrl(vaultUrl)
+ .credential(credential)
+ .buildClient();
+ }
+
+ private static long readTtlMs() {
+ String raw = prop("azure.cache.ttl.seconds", "3600");
+ try {
+ return Long.parseLong(raw.trim()) * 1000L;
+ } catch (NumberFormatException e) {
+ Debug.logWarning("Invalid azure.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
+ return 3_600_000L;
+ }
+ }
+
+ private static String prop(String key, String defaultValue) {
+ return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
+ }
+}
diff --git a/azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..3aac1713e
--- /dev/null
+++ b/azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.azurekeyvault.AzureKeyVaultSecretsProvider
diff --git a/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java b/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
new file mode 100644
index 000000000..9f036f2dc
--- /dev/null
+++ b/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.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.ofbiz.azurekeyvault;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+public class AzureKeyVaultSecretsProviderTest {
+
+ private static final long ONE_HOUR_MS = 3_600_000L;
+
+ // -- Happy path --
+
+ @Test
+ public void getSecret_returnsSecretValue() throws GeneralException {
+ AzureKeyVaultReader reader = fixedReader("mykey", "s3cr3t");
+ assertEquals("s3cr3t", provider(reader, "", "-").getSecret("mykey"));
+ }
+
+ @Test
+ public void getSecret_sanitizesDotInKey() throws GeneralException {
+ // OFBiz key "jdbc-password.mysql-ofbiz" → Azure name "jdbc-password-mysql-ofbiz"
+ AzureKeyVaultReader reader = fixedReader("jdbc-password-mysql-ofbiz", "dbpass");
+ assertEquals("dbpass", provider(reader, "", "-").getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecret_appliesPrefixAfterSanitizing() throws GeneralException {
+ AzureKeyVaultReader reader = fixedReader("prod-jdbc-password-mydb", "prodpass");
+ assertEquals("prodpass", provider(reader, "prod-", "-").getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ AzureKeyVaultReader reader = secretName -> {
+ calls.incrementAndGet();
+ return "val";
+ };
+ AzureKeyVaultSecretsProvider p = provider(reader, "", "-");
+
+ p.getSecret("mykey");
+ p.getSecret("mykey"); // cache hit
+
+ assertEquals(1, calls.get());
+ }
+
+ @Test
+ public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ AzureKeyVaultReader reader = secretName -> {
+ calls.incrementAndGet();
+ return "val";
+ };
+ AzureKeyVaultSecretsProvider p = provider(reader, "", "-");
+
+ p.getSecret("mykey");
+ p.invalidateCache();
+ p.getSecret("mykey");
+
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ public void getSecret_expiredCacheTriggersRefetch() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ AzureKeyVaultReader reader = secretName -> {
+ calls.incrementAndGet();
+ return "val";
+ };
+ // TTL of -1 ms — entries expire immediately
+ AzureKeyVaultSecretsProvider p = new AzureKeyVaultSecretsProvider(
+ reader, "", "-", -1L);
+
+ p.getSecret("mykey");
+ p.getSecret("mykey");
+
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ public void getSecret_dotReplacementDisabled_keepsDotsInName() throws GeneralException {
+ AzureKeyVaultReader reader = fixedReader("my.key", "val");
+ assertEquals("val", provider(reader, "", "").getSecret("my.key"));
+ }
+
+ // -- Error handling --
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsOnReaderException() throws GeneralException {
+ AzureKeyVaultReader reader = secretName -> { throw new RuntimeException("SecretNotFound"); };
+ provider(reader, "", "-").getSecret("missing");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsOnEmptyValue() throws GeneralException {
+ AzureKeyVaultReader reader = secretName -> "";
+ provider(reader, "", "-").getSecret("mykey");
+ }
+
+ // -- helpers --
+
+ private static AzureKeyVaultSecretsProvider provider(AzureKeyVaultReader reader,
+ String prefix, String dotReplacement) {
+ return new AzureKeyVaultSecretsProvider(reader, prefix, dotReplacement, ONE_HOUR_MS);
+ }
+
+ private static AzureKeyVaultReader fixedReader(String expectedName, String value) {
+ return secretName -> expectedName.equals(secretName) ? value : null;
+ }
+}
diff --git a/bitwarden-secrets-provider/build.gradle b/bitwarden-secrets-provider/build.gradle
new file mode 100644
index 000000000..be905aee6
--- /dev/null
+++ b/bitwarden-secrets-provider/build.gradle
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+// No external SDK required.
+// Uses Java's built-in java.net.http.HttpClient (Java 11+), javax.crypto for
+// AES-256-CBC + HMAC-SHA256 decryption, and OFBiz's bundled jackson-databind.
+//
+// Bitwarden Secrets Manager encrypts all data end-to-end. The symmetric key
+// embedded in the machine account access token is used to decrypt secret names
+// and values client-side.
+dependencies {}
diff --git a/bitwarden-secrets-provider/config/bitwarden-secrets.properties b/bitwarden-secrets-provider/config/bitwarden-secrets.properties
new file mode 100644
index 000000000..6552fa9c3
--- /dev/null
+++ b/bitwarden-secrets-provider/config/bitwarden-secrets.properties
@@ -0,0 +1,59 @@
+###############################################################################
+# 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.
+###############################################################################
+
+####
+# Bitwarden Secrets Manager — SecretProvider configuration
+#
+# Requires a Bitwarden Secrets Manager machine account access token.
+# Create one in the Bitwarden SM console under Machine Accounts.
+#
+# The access token embeds an AES-256 symmetric key used to decrypt
+# secret names and values client-side (end-to-end encryption).
+#
+# NOTE: Only ONE SecretProvider plugin may be active at a time.
+# Deploy only the plugin that matches your environment.
+####
+
+# Bitwarden Secrets Manager REST API base URL.
+# Use https://api.bitwarden.eu for EU cloud, or your self-hosted URL.
+bitwarden.api.url=https://api.bitwarden.com
+
+# Bitwarden Identity Service URL (used for machine account OAuth token exchange).
+bitwarden.identity.url=https://identity.bitwarden.com
+
+# Machine account access token.
+# Format: 0..:
+# Inject at deploy time — do not commit a real token.
+bitwarden.access.token=
+
+# UUID of the organization that owns the secrets.
+bitwarden.organization.id=
+
+# Optional prefix prepended to the OFBiz key when matching the Bitwarden secret key (name).
+# Example: with prefix "myapp/" the OFBiz key "jdbc-password.ofbiz" matches a
+# Bitwarden secret whose decrypted key is "myapp/jdbc-password.ofbiz".
+bitwarden.secret.name.prefix=
+
+# How long (in seconds) to cache resolved secret values before re-fetching.
+# Default: 3600 (1 hour). Set to 0 to disable caching.
+bitwarden.cache.ttl.seconds=3600
+
+# HTTP connect and read timeouts in seconds.
+bitwarden.connect.timeout.seconds=5
+bitwarden.read.timeout.seconds=10
diff --git a/bitwarden-secrets-provider/ofbiz-component.xml b/bitwarden-secrets-provider/ofbiz-component.xml
new file mode 100644
index 000000000..c5c3a8aec
--- /dev/null
+++ b/bitwarden-secrets-provider/ofbiz-component.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
new file mode 100644
index 000000000..64260a519
--- /dev/null
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.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.ofbiz.bitwarden;
+
+import java.io.IOException;
+
+/**
+ * Thin seam over HTTP calls used by {@link BitwardenSecretsProvider}.
+ * Kept package-private so tests can substitute a mock without making real
+ * network calls.
+ */
+interface BitwardenHttpClient {
+
+ /**
+ * Performs an authenticated GET request.
+ *
+ * @param url the full request URL
+ * @param bearerToken the OAuth bearer token
+ * @return the response body as a UTF-8 string
+ * @throws IOException if the request fails or the server returns a non-2xx status
+ */
+ String get(String url, String bearerToken) throws IOException;
+
+ /**
+ * Performs a form-encoded POST request (no auth header — used for token exchange).
+ *
+ * @param url the full request URL
+ * @param formBody {@code application/x-www-form-urlencoded} encoded body
+ * @return the response body as a UTF-8 string
+ * @throws IOException if the request fails or the server returns a non-2xx status
+ */
+ String post(String url, String formBody) throws IOException;
+}
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
new file mode 100644
index 000000000..ed94ea7a8
--- /dev/null
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
@@ -0,0 +1,461 @@
+/*******************************************************************************
+ * 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.ofbiz.bitwarden;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+/**
+ * {@link SecretProvider} implementation backed by Bitwarden Secrets Manager.
+ *
+ * Authentication
+ * Uses a machine account access token (created in the Bitwarden SM console).
+ * The token has the format {@code 0..:}.
+ * The provider parses the token to extract OAuth credentials for the identity service
+ * and the 64-byte symmetric key for client-side decryption.
+ *
+ * End-to-end encryption
+ * Bitwarden SM encrypts all data at rest and in transit. Secret keys (names)
+ * and values are returned from the API in encrypted form. This provider decrypts
+ * them using AES-256-CBC with HMAC-SHA256 integrity verification before returning the
+ * plaintext to OFBiz. No plaintext ever leaves the JVM.
+ *
+ * Cipher format
+ * Encrypted strings use Bitwarden's type-2 cipher format:
+ * {@code 2.||}
+ *
+ * - MAC key (bytes 32–63 of the 64-byte symmetric key) verifies integrity via
+ * HMAC-SHA256 over {@code IV || ciphertext}.
+ * - Enc key (bytes 0–31) decrypts with AES-256-CBC after MAC is verified.
+ *
+ *
+ * API flow per lookup
+ *
+ * - POST to identity service to exchange client credentials for a bearer token.
+ * - GET the organization's secret list; decrypt each secret key to find the match.
+ * - GET the matched secret by ID; decrypt and return the value.
+ *
+ *
+ * Configure via {@code plugins/bitwarden-secrets-provider/config/bitwarden-secrets.properties}.
+ */
+@ThreadSafe
+public final class BitwardenSecretsProvider implements SecretProvider {
+
+ private static final String MODULE = BitwardenSecretsProvider.class.getName();
+ private static final String CONFIG_RESOURCE = "bitwarden-secrets";
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ private final BitwardenHttpClient httpClient;
+ private final String apiUrl;
+ private final String identityUrl;
+ private final String organizationId;
+ private final String secretNamePrefix;
+ private final long cacheTtlMs;
+
+ // Parsed from the access token — never stored in config
+ private final String oauthClientId;
+ private final String oauthClientSecret;
+ private final byte[] encKey; // bytes 0-31 of the 64-byte symmetric key
+ private final byte[] macKey; // bytes 32-63
+
+ // Cached OAuth bearer token + its expiry
+ private volatile String bearerToken = null;
+ private volatile long bearerTokenExpiresAt = 0L;
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ private static final class CacheEntry {
+ final String value;
+ final long expiresAt;
+
+ CacheEntry(String value, long ttlMs) {
+ this.value = value;
+ this.expiresAt = System.currentTimeMillis() + ttlMs;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public BitwardenSecretsProvider() throws GeneralException {
+ this(buildHttpClient(),
+ prop("bitwarden.api.url", "https://api.bitwarden.com").replaceAll("/+$", ""),
+ prop("bitwarden.identity.url", "https://identity.bitwarden.com").replaceAll("/+$", ""),
+ prop("bitwarden.organization.id", ""),
+ prop("bitwarden.secret.name.prefix", ""),
+ readTtlMs(),
+ prop("bitwarden.access.token", ""));
+ }
+
+ /**
+ * Package-private constructor used by unit tests to inject mock HTTP client and
+ * pre-parsed key material.
+ */
+ BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl,
+ String organizationId, String secretNamePrefix, long cacheTtlMs,
+ String clientId, String clientSecret, byte[] encKey, byte[] macKey) {
+ this.httpClient = httpClient;
+ this.apiUrl = apiUrl;
+ this.identityUrl = identityUrl;
+ this.organizationId = organizationId;
+ this.secretNamePrefix = secretNamePrefix;
+ this.cacheTtlMs = cacheTtlMs;
+ this.oauthClientId = clientId;
+ this.oauthClientSecret = clientSecret;
+ this.encKey = encKey;
+ this.macKey = macKey;
+ }
+
+ /** Full constructor called by the public no-arg constructor after parsing the token. */
+ private BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl,
+ String organizationId, String secretNamePrefix, long cacheTtlMs,
+ String rawAccessToken) throws GeneralException {
+ this.httpClient = httpClient;
+ this.apiUrl = apiUrl;
+ this.identityUrl = identityUrl;
+ this.organizationId = organizationId;
+ this.secretNamePrefix = secretNamePrefix;
+ this.cacheTtlMs = cacheTtlMs;
+
+ // Parse: "0..:"
+ int colonIdx = rawAccessToken.lastIndexOf(':');
+ if (colonIdx < 0) {
+ throw new GeneralException(
+ "Invalid Bitwarden access token format — missing ':' separator");
+ }
+ String identityPart = rawAccessToken.substring(0, colonIdx);
+ String encKeyBase64 = rawAccessToken.substring(colonIdx + 1);
+
+ String[] dotParts = identityPart.split("\\.");
+ if (dotParts.length != 3 || !"0".equals(dotParts[0])) {
+ throw new GeneralException(
+ "Invalid Bitwarden access token format — expected '0..:'");
+ }
+ String serviceAccountId = dotParts[1];
+ String clientSecretPart = dotParts[2];
+
+ this.oauthClientId = "service-account." + serviceAccountId;
+ this.oauthClientSecret = clientSecretPart;
+
+ byte[] keyBytes;
+ try {
+ keyBytes = Base64.getDecoder().decode(encKeyBase64);
+ } catch (IllegalArgumentException e) {
+ throw new GeneralException("Invalid Bitwarden access token — enc key is not valid base64", e);
+ }
+ if (keyBytes.length != 64) {
+ throw new GeneralException("Invalid Bitwarden access token — enc key must be 64 bytes, got "
+ + keyBytes.length);
+ }
+ this.encKey = Arrays.copyOfRange(keyBytes, 0, 32);
+ this.macKey = Arrays.copyOfRange(keyBytes, 32, 64);
+
+ Debug.logInfo("BitwardenSecretsProvider: initialized api=" + apiUrl
+ + " org=" + organizationId, MODULE);
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ CacheEntry cached = cache.get(key);
+ if (cached != null && !cached.isExpired()) {
+ return cached.value;
+ }
+
+ String secretName = secretNamePrefix + key;
+ String value = fetchFromBitwarden(secretName);
+
+ cache.put(key, new CacheEntry(value, cacheTtlMs));
+ return value;
+ }
+
+ /**
+ * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
+ * to re-fetch and re-decrypt from Bitwarden SM.
+ */
+ public void invalidateCache() {
+ cache.clear();
+ Debug.logInfo("BitwardenSecretsProvider: secret cache invalidated", MODULE);
+ }
+
+ // -- private helpers --
+
+ private String fetchFromBitwarden(String secretName) throws GeneralException {
+ String bearer = ensureBearerToken();
+ String secretId = findSecretId(secretName, bearer);
+ return fetchAndDecryptValue(secretId, secretName, bearer);
+ }
+
+ /** Returns a valid OAuth bearer token, refreshing it if expired. */
+ private synchronized String ensureBearerToken() throws GeneralException {
+ if (bearerToken != null && System.currentTimeMillis() < bearerTokenExpiresAt) {
+ return bearerToken;
+ }
+
+ String body = "grant_type=client_credentials"
+ + "&scope=api.secrets"
+ + "&client_id=" + oauthClientId
+ + "&client_secret=" + oauthClientSecret;
+
+ String responseJson;
+ try {
+ responseJson = httpClient.post(identityUrl + "/connect/token", body);
+ } catch (IOException e) {
+ throw new GeneralException("Bitwarden identity token request failed: " + e.getMessage(), e);
+ }
+
+ try {
+ JsonNode root = JSON.readTree(responseJson);
+ JsonNode tokenNode = root.get("access_token");
+ JsonNode expiresNode = root.get("expires_in");
+ if (tokenNode == null || tokenNode.isNull()) {
+ throw new GeneralException("Bitwarden identity response missing 'access_token'");
+ }
+ bearerToken = tokenNode.asText();
+ long expiresIn = expiresNode != null ? expiresNode.asLong(3600L) : 3600L;
+ // Subtract 60 s to renew slightly before expiry
+ bearerTokenExpiresAt = System.currentTimeMillis() + (expiresIn - 60L) * 1000L;
+ return bearerToken;
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException("Failed to parse Bitwarden identity response: " + e.getMessage(), e);
+ }
+ }
+
+ /** Lists the organization's secrets and returns the ID of the one matching secretName. */
+ private String findSecretId(String secretName, String bearer) throws GeneralException {
+ String listUrl = apiUrl + "/organizations/" + organizationId + "/secrets";
+ String responseJson;
+ try {
+ responseJson = httpClient.get(listUrl, bearer);
+ } catch (IOException e) {
+ throw new GeneralException("Bitwarden secrets list request failed: " + e.getMessage(), e);
+ }
+
+ try {
+ JsonNode root = JSON.readTree(responseJson);
+ JsonNode data = root.get("data");
+ if (data == null || !data.isArray()) {
+ throw new GeneralException("Bitwarden secrets list response missing 'data' array");
+ }
+
+ for (JsonNode item : data) {
+ JsonNode encKey = item.get("key");
+ if (encKey == null) continue;
+
+ String decryptedKey = decrypt(encKey.asText());
+ if (secretName.equals(decryptedKey)) {
+ JsonNode idNode = item.get("id");
+ if (idNode == null || idNode.isNull()) {
+ throw new GeneralException("Bitwarden secret '" + secretName + "' has no id field");
+ }
+ return idNode.asText();
+ }
+ }
+ throw new GeneralException("No Bitwarden secret found with key '" + secretName + "'");
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException("Failed to parse Bitwarden secrets list: " + e.getMessage(), e);
+ }
+ }
+
+ /** Fetches the secret by ID and decrypts its value. */
+ private String fetchAndDecryptValue(String secretId, String secretName, String bearer)
+ throws GeneralException {
+ String secretUrl = apiUrl + "/secrets/" + secretId;
+ String responseJson;
+ try {
+ responseJson = httpClient.get(secretUrl, bearer);
+ } catch (IOException e) {
+ throw new GeneralException("Bitwarden secret fetch failed for '" + secretName
+ + "': " + e.getMessage(), e);
+ }
+
+ try {
+ JsonNode root = JSON.readTree(responseJson);
+ JsonNode encValueNode = root.get("value");
+ if (encValueNode == null || encValueNode.isNull()) {
+ throw new GeneralException("Bitwarden secret '" + secretName + "' has no value field");
+ }
+ String plaintext = decrypt(encValueNode.asText());
+ if (plaintext.isEmpty()) {
+ throw new GeneralException("Bitwarden secret '" + secretName + "' decrypted to empty string");
+ }
+ return plaintext;
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException("Failed to parse Bitwarden secret response: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Decrypts a Bitwarden type-2 cipher string.
+ *
+ * Format: {@code 2.||}
+ *
+ * - HMAC-SHA256 is verified over {@code IV || ciphertext} using {@code macKey}.
+ * - AES-256-CBC decryption uses {@code encKey} and the extracted IV.
+ *
+ */
+ String decrypt(String cipherString) throws GeneralException {
+ if (!cipherString.startsWith("2.")) {
+ throw new GeneralException(
+ "Unsupported Bitwarden cipher type — expected type 2 (AES-CBC-256-HMAC-SHA256)");
+ }
+ String[] parts = cipherString.substring(2).split("\\|");
+ if (parts.length != 3) {
+ throw new GeneralException(
+ "Invalid Bitwarden cipher string — expected '2.||'");
+ }
+
+ byte[] iv, ciphertext, expectedHmac;
+ try {
+ iv = Base64.getDecoder().decode(parts[0]);
+ ciphertext = Base64.getDecoder().decode(parts[1]);
+ expectedHmac = Base64.getDecoder().decode(parts[2]);
+ } catch (IllegalArgumentException e) {
+ throw new GeneralException("Invalid base64 in Bitwarden cipher string", e);
+ }
+
+ try {
+ // Verify integrity: HMAC-SHA256(iv || ciphertext, macKey)
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
+ mac.update(iv);
+ byte[] computedHmac = mac.doFinal(ciphertext);
+ if (!MessageDigest.isEqual(computedHmac, expectedHmac)) {
+ throw new GeneralException(
+ "Bitwarden HMAC verification failed — wrong key or tampered ciphertext");
+ }
+
+ // Decrypt: AES-256-CBC
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ cipher.init(Cipher.DECRYPT_MODE,
+ new SecretKeySpec(encKey, "AES"),
+ new IvParameterSpec(iv));
+ return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
+
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException("Bitwarden decryption failed: " + e.getMessage(), e);
+ }
+ }
+
+ private static BitwardenHttpClient buildHttpClient() {
+ int connectTimeout = parseSeconds(prop("bitwarden.connect.timeout.seconds", "5"));
+ int readTimeout = parseSeconds(prop("bitwarden.read.timeout.seconds", "10"));
+
+ HttpClient javaClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(connectTimeout))
+ .build();
+
+ return new BitwardenHttpClient() {
+ @Override
+ public String get(String url, String bearerToken) throws IOException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("Authorization", "Bearer " + bearerToken)
+ .header("Accept", "application/json")
+ .timeout(Duration.ofSeconds(readTimeout))
+ .GET()
+ .build();
+ return send(request);
+ }
+
+ @Override
+ public String post(String url, String formBody) throws IOException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .header("Accept", "application/json")
+ .timeout(Duration.ofSeconds(readTimeout))
+ .POST(HttpRequest.BodyPublishers.ofString(formBody, StandardCharsets.UTF_8))
+ .build();
+ return send(request);
+ }
+
+ private String send(HttpRequest request) throws IOException {
+ HttpResponse response;
+ try {
+ response = javaClient.send(request,
+ HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("HTTP request interrupted", e);
+ }
+ int status = response.statusCode();
+ if (status < 200 || status >= 300) {
+ throw new IOException("Bitwarden API returned HTTP " + status
+ + " for " + request.uri());
+ }
+ return response.body();
+ }
+ };
+ }
+
+ private static int parseSeconds(String raw) {
+ try {
+ return Integer.parseInt(raw.trim());
+ } catch (NumberFormatException e) {
+ return 10;
+ }
+ }
+
+ private static long readTtlMs() {
+ String raw = prop("bitwarden.cache.ttl.seconds", "3600");
+ try {
+ return Long.parseLong(raw.trim()) * 1000L;
+ } catch (NumberFormatException e) {
+ Debug.logWarning("Invalid bitwarden.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
+ return 3_600_000L;
+ }
+ }
+
+ private static String prop(String key, String defaultValue) {
+ return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
+ }
+}
diff --git a/bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..8f6a30013
--- /dev/null
+++ b/bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.bitwarden.BitwardenSecretsProvider
diff --git a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
new file mode 100644
index 000000000..70e6e0f7b
--- /dev/null
+++ b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
@@ -0,0 +1,220 @@
+/*******************************************************************************
+ * 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.ofbiz.bitwarden;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Tests for {@link BitwardenSecretsProvider}.
+ *
+ * A stable 64-byte test key is generated once for the test class.
+ * Helper methods produce properly-encrypted cipher strings so the decryption
+ * tests exercise the real AES-256-CBC + HMAC-SHA256 path, not just mocked output.
+ */
+public class BitwardenSecretsProviderTest {
+
+ private static final long ONE_HOUR_MS = 3_600_000L;
+ private static final String API_URL = "https://api.bitwarden.com";
+ private static final String IDENTITY_URL = "https://identity.bitwarden.com";
+ private static final String ORG_ID = "org-abc";
+ private static final String BEARER_TOKEN_RESPONSE =
+ "{\"access_token\":\"test-bearer\",\"expires_in\":3600}";
+
+ private static byte[] TEST_ENC_KEY;
+ private static byte[] TEST_MAC_KEY;
+
+ @BeforeClass
+ public static void generateTestKey() {
+ byte[] keyBytes = new byte[64];
+ new SecureRandom().nextBytes(keyBytes);
+ TEST_ENC_KEY = Arrays.copyOfRange(keyBytes, 0, 32);
+ TEST_MAC_KEY = Arrays.copyOfRange(keyBytes, 32, 64);
+ }
+
+ // -- Decryption unit tests (no HTTP, pure crypto) --
+
+ @Test
+ public void decrypt_roundTrip() throws Exception {
+ String plaintext = "my-secret-password";
+ String cipherString = encrypt(plaintext);
+
+ BitwardenSecretsProvider provider = provider(mockHttpForSecret("mykey", plaintext));
+ assertEquals(plaintext, provider.decrypt(cipherString));
+ }
+
+ @Test(expected = GeneralException.class)
+ public void decrypt_throwsOnWrongCipherType() throws Exception {
+ BitwardenSecretsProvider provider = provider(mock(BitwardenHttpClient.class));
+ provider.decrypt("1.abc|def|ghi"); // type 1, not 2
+ }
+
+ @Test(expected = GeneralException.class)
+ public void decrypt_throwsOnTamperedCiphertext() throws Exception {
+ String cipherString = encrypt("secret");
+ // Flip a byte in the ciphertext part
+ String[] parts = cipherString.substring(2).split("\\|");
+ byte[] ct = Base64.getDecoder().decode(parts[1]);
+ ct[0] ^= 0xFF;
+ String tampered = "2." + parts[0] + "|" + Base64.getEncoder().encodeToString(ct) + "|" + parts[2];
+
+ BitwardenSecretsProvider provider = provider(mock(BitwardenHttpClient.class));
+ provider.decrypt(tampered);
+ }
+
+ // -- Full flow tests (with mock HTTP) --
+
+ @Test
+ public void getSecret_fetchesAndDecryptsSecret() throws Exception {
+ String secretValue = "db-password-123";
+ BitwardenHttpClient client = mockHttpForSecret("jdbc-password.mydb", secretValue);
+ BitwardenSecretsProvider provider = provider(client);
+
+ assertEquals(secretValue, provider.getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecret_appliesSecretNamePrefix() throws Exception {
+ String secretValue = "dbpass";
+ BitwardenHttpClient client = mockHttpForSecret("prod/jdbc-password.mydb", secretValue);
+ BitwardenSecretsProvider provider = providerWithPrefix(client, "prod/");
+
+ assertEquals(secretValue, provider.getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecret_cachePreventsSecondApiCall() throws Exception {
+ BitwardenHttpClient client = mockHttpForSecret("mykey", "val");
+ BitwardenSecretsProvider provider = provider(client);
+
+ provider.getSecret("mykey");
+ provider.getSecret("mykey");
+
+ // Only 1 identity POST + 1 list GET + 1 secret GET = 3 calls for the first fetch
+ verify(client, times(1)).post(anyString(), anyString());
+ verify(client, times(1)).get(contains("/organizations/"), anyString());
+ verify(client, times(1)).get(contains("/secrets/"), anyString());
+ }
+
+ @Test
+ public void invalidateCache_forcesRefetch() throws Exception {
+ BitwardenHttpClient client = mockHttpForSecret("mykey", "val");
+ BitwardenSecretsProvider provider = provider(client);
+
+ provider.getSecret("mykey");
+ provider.invalidateCache();
+ provider.getSecret("mykey");
+
+ // Two full fetches — bearer token is still valid so POST called once
+ verify(client, times(2)).get(contains("/organizations/"), anyString());
+ verify(client, times(2)).get(contains("/secrets/"), anyString());
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsWhenSecretNotFound() throws Exception {
+ BitwardenHttpClient client = mock(BitwardenHttpClient.class);
+ when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE);
+ when(client.get(contains("/organizations/"), anyString()))
+ .thenReturn("{\"data\":[]}"); // empty list
+
+ provider(client).getSecret("missing");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsOnHttpError() throws Exception {
+ BitwardenHttpClient client = mock(BitwardenHttpClient.class);
+ when(client.post(anyString(), anyString())).thenThrow(new IOException("connection refused"));
+
+ provider(client).getSecret("mykey");
+ }
+
+ // -- helpers --
+
+ private BitwardenSecretsProvider provider(BitwardenHttpClient client) throws GeneralException {
+ return providerWithPrefix(client, "");
+ }
+
+ private BitwardenSecretsProvider providerWithPrefix(BitwardenHttpClient client, String prefix)
+ throws GeneralException {
+ return new BitwardenSecretsProvider(client, API_URL, IDENTITY_URL,
+ ORG_ID, prefix, ONE_HOUR_MS,
+ "service-account.test-id", "test-secret", TEST_ENC_KEY, TEST_MAC_KEY);
+ }
+
+ /**
+ * Builds a mock HTTP client that returns a bearer token on POST and serves a
+ * secrets list + secret detail using properly encrypted cipher strings.
+ */
+ private BitwardenHttpClient mockHttpForSecret(String secretKey, String secretValue) throws Exception {
+ String encryptedKey = encrypt(secretKey);
+ String encryptedValue = encrypt(secretValue);
+ String secretId = "secret-id-001";
+
+ BitwardenHttpClient client = mock(BitwardenHttpClient.class);
+ when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE);
+ when(client.get(contains("/organizations/"), eq("test-bearer")))
+ .thenReturn("{\"data\":[{\"id\":\"" + secretId + "\",\"key\":\""
+ + encryptedKey + "\"}]}");
+ when(client.get(contains("/secrets/" + secretId), eq("test-bearer")))
+ .thenReturn("{\"id\":\"" + secretId + "\",\"key\":\"" + encryptedKey
+ + "\",\"value\":\"" + encryptedValue + "\"}");
+ return client;
+ }
+
+ /** Produces a valid Bitwarden type-2 cipher string using the test key material. */
+ private String encrypt(String plaintext) throws Exception {
+ byte[] iv = new byte[16];
+ new SecureRandom().nextBytes(iv);
+
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(TEST_ENC_KEY, "AES"), new IvParameterSpec(iv));
+ byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(TEST_MAC_KEY, "HmacSHA256"));
+ mac.update(iv);
+ byte[] hmac = mac.doFinal(ciphertext);
+
+ Base64.Encoder b64 = Base64.getEncoder();
+ return "2." + b64.encodeToString(iv) + "|"
+ + b64.encodeToString(ciphertext) + "|"
+ + b64.encodeToString(hmac);
+ }
+}
diff --git a/gcp-secretmanager-secrets-provider/build.gradle b/gcp-secretmanager-secrets-provider/build.gradle
new file mode 100644
index 000000000..7980275c3
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/build.gradle
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+// Google Cloud Secret Manager Java client (Apache License 2.0)
+// https://github.com/googleapis/java-secretmanager
+dependencies {
+ pluginLibsCompile 'com.google.cloud:google-cloud-secretmanager:2.36.0'
+}
+
+configurations.all {
+ exclude group: 'commons-logging', module: 'commons-logging'
+}
diff --git a/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties b/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
new file mode 100644
index 000000000..ccff5dd36
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
@@ -0,0 +1,34 @@
+###############################################################################
+# GCP Secret Manager — SecretProvider configuration
+#
+# To activate this provider:
+# 1. Set enabled="true" in gcp-secretmanager-secrets-provider/ofbiz-component.xml
+# 2. Set enabled="false" on every other *-secrets-provider plugin
+###############################################################################
+
+# GCP project ID that owns the secrets (required)
+# Example: my-gcp-project
+gcp.project.id=
+
+# Path to a service account JSON key file.
+# Leave empty to use Application Default Credentials (ADC) — recommended when
+# running on GCP (GKE, GCE, Cloud Run, App Engine) or with gcloud auth set up.
+gcp.credentials.file=
+
+# Optional prefix prepended to every secret name before lookup.
+# Useful for environment namespacing, e.g. "prod/" makes key "db" → "prod/db".
+gcp.secret.name.prefix=
+
+# GCP Secret Manager secret version to access (default: latest)
+gcp.secret.version=latest
+
+# GCP secret names may only contain letters, digits, hyphens and underscores.
+# OFBiz keys like "jdbc-password.mysql-ofbiz" contain a dot, which is invalid.
+# Set this to the replacement character (default: -) so the dot is substituted.
+# Example: "jdbc-password.mysql-ofbiz" → "jdbc-password-mysql-ofbiz"
+# Set to empty to disable replacement (only do this if your keys have no dots).
+gcp.secret.name.dot.replacement=-
+
+# In-memory cache TTL in seconds (default: 3600 = 1 hour).
+# Set to 0 to disable caching (fetches from GCP on every call).
+gcp.cache.ttl.seconds=3600
diff --git a/gcp-secretmanager-secrets-provider/ofbiz-component.xml b/gcp-secretmanager-secrets-provider/ofbiz-component.xml
new file mode 100644
index 000000000..7608b2281
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/ofbiz-component.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
new file mode 100644
index 000000000..5a3e3a368
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
@@ -0,0 +1,201 @@
+/*******************************************************************************
+ * 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.ofbiz.gcpsecretmanager;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.google.api.gax.core.FixedCredentialsProvider;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.cloud.secretmanager.v1.SecretManagerServiceClient;
+import com.google.cloud.secretmanager.v1.SecretManagerServiceSettings;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+/**
+ * {@link SecretProvider} implementation backed by Google Cloud Secret Manager.
+ *
+ * Authentication
+ * Two modes are supported via {@code gcp.credentials.file}:
+ *
+ * - Application Default Credentials (ADC) — leave the property empty.
+ * GCP automatically provides credentials when running on GKE, GCE, Cloud Run, or
+ * after {@code gcloud auth application-default login} locally.
+ * - Service Account key file — set {@code gcp.credentials.file} to the
+ * path of a downloaded JSON key file. Suitable for on-premise deployments.
+ *
+ *
+ * Secret name mapping
+ * GCP secret names may only contain letters, digits, hyphens and underscores.
+ * OFBiz keys contain dots (e.g. {@code jdbc-password.mysql-ofbiz}), which are invalid.
+ * Set {@code gcp.secret.name.dot.replacement=-} (the default) to replace dots with
+ * hyphens, so the key maps to {@code jdbc-password-mysql-ofbiz} in the vault.
+ *
+ * Secret resource name
+ * Built as:
+ * {@code projects/{projectId}/secrets/{prefix}{sanitizedKey}/versions/{version}}
+ *
+ * Configure via {@code plugins/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties}.
+ */
+@ThreadSafe
+public final class GcpSecretManagerSecretsProvider implements SecretProvider {
+
+ private static final String MODULE = GcpSecretManagerSecretsProvider.class.getName();
+ private static final String CONFIG_RESOURCE = "gcp-secret-manager";
+
+ private final GcpSecretReader secretReader;
+ private final String projectId;
+ private final String secretNamePrefix;
+ private final String version;
+ private final String dotReplacement;
+ private final long cacheTtlMs;
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ private static final class CacheEntry {
+ final String value;
+ final long expiresAt;
+
+ CacheEntry(String value, long ttlMs) {
+ this.value = value;
+ this.expiresAt = System.currentTimeMillis() + ttlMs;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public GcpSecretManagerSecretsProvider() throws GeneralException {
+ this(readerFrom(buildClient()),
+ prop("gcp.project.id", ""),
+ prop("gcp.secret.name.prefix", ""),
+ prop("gcp.secret.version", "latest"),
+ prop("gcp.secret.name.dot.replacement", "-"),
+ readTtlMs());
+ }
+
+ /** Package-private constructor used by unit tests to inject a {@link GcpSecretReader} lambda. */
+ GcpSecretManagerSecretsProvider(GcpSecretReader secretReader, String projectId,
+ String secretNamePrefix, String version, String dotReplacement, long cacheTtlMs) {
+ this.secretReader = secretReader;
+ this.projectId = projectId;
+ this.secretNamePrefix = secretNamePrefix;
+ this.version = version;
+ this.dotReplacement = dotReplacement;
+ this.cacheTtlMs = cacheTtlMs;
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ CacheEntry cached = cache.get(key);
+ if (cached != null && !cached.isExpired()) {
+ return cached.value;
+ }
+
+ String sanitizedKey = dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement);
+ String secretName = secretNamePrefix + sanitizedKey;
+ String resourceName = "projects/" + projectId + "/secrets/" + secretName + "/versions/" + version;
+
+ String value;
+ try {
+ value = secretReader.read(resourceName);
+ } catch (Exception e) {
+ throw new GeneralException(
+ "GCP Secret Manager read failed for '" + resourceName + "': " + e.getMessage(), e);
+ }
+
+ if (value == null || value.isEmpty()) {
+ throw new GeneralException(
+ "GCP Secret Manager returned empty value for '" + resourceName + "'");
+ }
+
+ cache.put(key, new CacheEntry(value, cacheTtlMs));
+ return value;
+ }
+
+ /**
+ * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
+ * to re-fetch from GCP. Useful after a secret rotation.
+ */
+ public void invalidateCache() {
+ cache.clear();
+ Debug.logInfo("GcpSecretManagerSecretsProvider: secret cache invalidated", MODULE);
+ }
+
+ // -- private helpers --
+
+ private static GcpSecretReader readerFrom(SecretManagerServiceClient client) {
+ return resourceName -> {
+ try {
+ return client.accessSecretVersion(resourceName)
+ .getPayload()
+ .getData()
+ .toStringUtf8();
+ } catch (Exception e) {
+ throw new IOException(e.getMessage(), e);
+ }
+ };
+ }
+
+ private static SecretManagerServiceClient buildClient() throws GeneralException {
+ String credentialsFile = prop("gcp.credentials.file", "");
+ try {
+ SecretManagerServiceSettings.Builder builder = SecretManagerServiceSettings.newBuilder();
+
+ if (!credentialsFile.isEmpty()) {
+ try (FileInputStream fis = new FileInputStream(credentialsFile)) {
+ GoogleCredentials credentials = GoogleCredentials
+ .fromStream(fis)
+ .createScoped("https://www.googleapis.com/auth/cloud-platform");
+ builder.setCredentialsProvider(FixedCredentialsProvider.create(credentials));
+ }
+ }
+ // else: Application Default Credentials are used automatically
+
+ Debug.logInfo("GcpSecretManagerSecretsProvider: initialized project="
+ + prop("gcp.project.id", "") + " version=" + prop("gcp.secret.version", "latest"), MODULE);
+ return SecretManagerServiceClient.create(builder.build());
+
+ } catch (IOException e) {
+ throw new GeneralException(
+ "GcpSecretManagerSecretsProvider: initialization failed: " + e.getMessage(), e);
+ }
+ }
+
+ private static long readTtlMs() {
+ String raw = prop("gcp.cache.ttl.seconds", "3600");
+ try {
+ return Long.parseLong(raw.trim()) * 1000L;
+ } catch (NumberFormatException e) {
+ Debug.logWarning("Invalid gcp.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
+ return 3_600_000L;
+ }
+ }
+
+ private static String prop(String key, String defaultValue) {
+ return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
+ }
+}
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java
new file mode 100644
index 000000000..411cfaa7f
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.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.ofbiz.gcpsecretmanager;
+
+/**
+ * Thin seam over GCP Secret Manager reads used by {@link GcpSecretManagerSecretsProvider}.
+ * Kept package-private so tests can substitute a lambda without connecting to GCP.
+ */
+@FunctionalInterface
+interface GcpSecretReader {
+ /**
+ * Accesses the secret version at the given fully-qualified resource name.
+ *
+ * @param resourceName e.g. {@code "projects/my-project/secrets/my-secret/versions/latest"}
+ * @return the plaintext secret value; never {@code null}
+ * @throws Exception if the access fails or the secret does not exist
+ */
+ String read(String resourceName) throws Exception;
+}
diff --git a/gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..7421bf9dd
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.gcpsecretmanager.GcpSecretManagerSecretsProvider
diff --git a/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java b/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
new file mode 100644
index 000000000..053fc3ddf
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
@@ -0,0 +1,147 @@
+/*******************************************************************************
+ * 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.ofbiz.gcpsecretmanager;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+public class GcpSecretManagerSecretsProviderTest {
+
+ private static final long ONE_HOUR_MS = 3_600_000L;
+ private static final String PROJECT = "my-project";
+
+ // -- Happy path --
+
+ @Test
+ public void getSecret_returnsSecretValue() throws GeneralException {
+ GcpSecretReader reader = fixedReader(
+ "projects/my-project/secrets/mykey/versions/latest", "s3cr3t");
+ assertEquals("s3cr3t", provider(reader, "", "-").getSecret("mykey"));
+ }
+
+ @Test
+ public void getSecret_sanitizesDotInKey() throws GeneralException {
+ // OFBiz key "jdbc-password.mysql-ofbiz" → GCP name "jdbc-password-mysql-ofbiz"
+ GcpSecretReader reader = fixedReader(
+ "projects/my-project/secrets/jdbc-password-mysql-ofbiz/versions/latest", "dbpass");
+ assertEquals("dbpass", provider(reader, "", "-").getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecret_appliesPrefixAfterSanitizing() throws GeneralException {
+ GcpSecretReader reader = fixedReader(
+ "projects/my-project/secrets/prod-jdbc-password-mydb/versions/latest", "prodpass");
+ assertEquals("prodpass", provider(reader, "prod-", "-").getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecret_usesConfiguredVersion() throws GeneralException {
+ GcpSecretReader reader = fixedReader(
+ "projects/my-project/secrets/mykey/versions/3", "v3val");
+ GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider(
+ reader, PROJECT, "", "3", "-", ONE_HOUR_MS);
+ assertEquals("v3val", p.getSecret("mykey"));
+ }
+
+ @Test
+ public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ GcpSecretReader reader = resourceName -> {
+ calls.incrementAndGet();
+ return "val";
+ };
+ GcpSecretManagerSecretsProvider p = provider(reader, "", "-");
+
+ p.getSecret("mykey");
+ p.getSecret("mykey"); // cache hit
+
+ assertEquals(1, calls.get());
+ }
+
+ @Test
+ public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ GcpSecretReader reader = resourceName -> {
+ calls.incrementAndGet();
+ return "val";
+ };
+ GcpSecretManagerSecretsProvider p = provider(reader, "", "-");
+
+ p.getSecret("mykey");
+ p.invalidateCache();
+ p.getSecret("mykey");
+
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ public void getSecret_expiredCacheTriggersRefetch() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ GcpSecretReader reader = resourceName -> {
+ calls.incrementAndGet();
+ return "val";
+ };
+ // TTL of -1 ms — entries expire immediately
+ GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider(
+ reader, PROJECT, "", "latest", "-", -1L);
+
+ p.getSecret("mykey");
+ p.getSecret("mykey");
+
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ public void getSecret_dotReplacementDisabled_keepsDotsInName() throws GeneralException {
+ // dot.replacement="" → no sanitization
+ GcpSecretReader reader = fixedReader(
+ "projects/my-project/secrets/my.key/versions/latest", "val");
+ assertEquals("val", provider(reader, "", "").getSecret("my.key"));
+ }
+
+ // -- Error handling --
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsOnReaderException() throws GeneralException {
+ GcpSecretReader reader = resourceName -> { throw new Exception("NOT_FOUND"); };
+ provider(reader, "", "-").getSecret("missing");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsOnEmptyValue() throws GeneralException {
+ GcpSecretReader reader = resourceName -> "";
+ provider(reader, "", "-").getSecret("mykey");
+ }
+
+ // -- helpers --
+
+ private static GcpSecretManagerSecretsProvider provider(GcpSecretReader reader,
+ String prefix, String dotReplacement) {
+ return new GcpSecretManagerSecretsProvider(reader, PROJECT, prefix, "latest",
+ dotReplacement, ONE_HOUR_MS);
+ }
+
+ private static GcpSecretReader fixedReader(String expectedResource, String value) {
+ return resourceName -> expectedResource.equals(resourceName) ? value : null;
+ }
+}
diff --git a/hashicorp-vault-secrets-provider/build.gradle b/hashicorp-vault-secrets-provider/build.gradle
new file mode 100644
index 000000000..ae4c90ea9
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/build.gradle
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+// HashiCorp Vault Java Driver — Apache License 2.0
+// https://github.com/jopenlibs/vault-java-driver
+dependencies {
+ pluginLibsCompile 'io.github.jopenlibs:vault-java-driver:5.4.0'
+}
+
+configurations.all {
+ exclude group: 'commons-logging', module: 'commons-logging'
+}
diff --git a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
new file mode 100644
index 000000000..9829048e5
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
@@ -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.
+###############################################################################
+
+####
+# HashiCorp Vault — SecretProvider configuration
+#
+# NOTE: Only ONE SecretProvider plugin may be active at a time.
+# If multiple provider plugins are on the classpath, the first one
+# discovered by java.util.ServiceLoader is used (order is undefined).
+# Deploy only the plugin that matches your environment.
+####
+
+# Vault server address.
+vault.address=http://127.0.0.1:8200
+
+# Authentication method: token | approle
+vault.auth.method=token
+
+# Token auth: set vault.token (leave empty when using AppRole).
+# Never commit real tokens — use an environment variable or a secrets manager
+# to inject this value at deploy time.
+vault.token=
+
+# AppRole auth: role_id is non-sensitive; secret_id should be injected at runtime.
+vault.approle.role_id=
+vault.approle.secret_id=
+
+# KV secrets engine mount path (the path you used when enabling the engine).
+vault.kv.mount=secret
+
+# KV engine version: 1 or 2 (Vault default is 2 for new mounts).
+vault.kv.version=2
+
+# Optional prefix prepended to every OFBiz secret key before the Vault lookup.
+# Example: with prefix "myapp/prod/" the key "jdbc-password.ofbiz" becomes
+# "secret/myapp/prod/jdbc-password.ofbiz" in Vault.
+vault.secret.name.prefix=
+
+# If the secret in Vault holds multiple fields (e.g. {"username":"u","password":"p"}),
+# set this to the field name that holds the actual secret value (e.g. "password").
+# Leave empty only when the secret has exactly one field — its value is returned as-is.
+vault.field=password
+
+# How long (in seconds) to cache a resolved secret value before re-fetching.
+# Default: 3600 (1 hour). Set to 0 to disable caching.
+vault.cache.ttl.seconds=3600
+
+# Whether to verify the Vault server's TLS certificate.
+# Set to false only for local development — never in production.
+vault.ssl.verify=true
diff --git a/hashicorp-vault-secrets-provider/ofbiz-component.xml b/hashicorp-vault-secrets-provider/ofbiz-component.xml
new file mode 100644
index 000000000..37ea052c7
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/ofbiz-component.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java
new file mode 100644
index 000000000..0818488f5
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java
@@ -0,0 +1,39 @@
+/*******************************************************************************
+ * 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.ofbiz.hashicorpvault;
+
+import java.util.Map;
+
+import io.github.jopenlibs.vault.VaultException;
+
+/**
+ * Thin seam over Vault KV reads used by {@link HashicorpVaultSecretsProvider}.
+ * Kept package-private so tests can substitute a lambda without launching a real Vault server.
+ */
+@FunctionalInterface
+interface HashicorpVaultReader {
+ /**
+ * Reads the KV secret at the given path.
+ *
+ * @param path the full KV path (e.g. {@code "secret/myapp/jdbc-password"})
+ * @return the data fields; never {@code null}
+ * @throws VaultException if the read fails or the path does not exist
+ */
+ Map read(String path) throws VaultException;
+}
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
new file mode 100644
index 000000000..ab16ca8e6
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
@@ -0,0 +1,226 @@
+/*******************************************************************************
+ * 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.ofbiz.hashicorpvault;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import java.util.Collections;
+
+import io.github.jopenlibs.vault.SslConfig;
+import io.github.jopenlibs.vault.Vault;
+import io.github.jopenlibs.vault.VaultConfig;
+import io.github.jopenlibs.vault.VaultException;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+/**
+ * {@link SecretProvider} implementation backed by HashiCorp Vault.
+ *
+ * Supports both Token and AppRole authentication methods. The preferred
+ * production approach is AppRole — the role_id is non-sensitive and can live
+ * in config, while the secret_id is injected at deploy time via an environment
+ * variable or a secrets bootstrap mechanism.
+ *
+ * Both KV v1 and KV v2 engines are supported via the {@code vault.kv.version}
+ * property. The driver handles the {@code /data/} path rewrite for KV v2
+ * automatically when {@code engineVersion(2)} is configured.
+ *
+ * Resolved secret values are cached in memory for the TTL configured by
+ * {@code vault.cache.ttl.seconds} (default 1 hour).
+ *
+ * Configure via {@code plugins/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties}.
+ */
+@ThreadSafe
+public final class HashicorpVaultSecretsProvider implements SecretProvider {
+
+ private static final String MODULE = HashicorpVaultSecretsProvider.class.getName();
+ private static final String CONFIG_RESOURCE = "hashicorp-vault-secrets";
+
+ private final HashicorpVaultReader vaultReader;
+ private final String kvMount;
+ private final String secretNamePrefix;
+ private final String field;
+ private final long cacheTtlMs;
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ private static final class CacheEntry {
+ final String value;
+ final long expiresAt;
+
+ CacheEntry(String value, long ttlMs) {
+ this.value = value;
+ this.expiresAt = System.currentTimeMillis() + ttlMs;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public HashicorpVaultSecretsProvider() {
+ this(readerFrom(buildVault()),
+ prop("vault.kv.mount", "secret"),
+ prop("vault.secret.name.prefix", ""),
+ prop("vault.field", "password"),
+ readTtlMs());
+ }
+
+ /** Package-private constructor used by unit tests to inject a {@link HashicorpVaultReader} lambda. */
+ HashicorpVaultSecretsProvider(HashicorpVaultReader vaultReader, String kvMount, String secretNamePrefix,
+ String field, long cacheTtlMs) {
+ this.vaultReader = vaultReader;
+ this.kvMount = kvMount;
+ this.secretNamePrefix = secretNamePrefix;
+ this.field = field;
+ this.cacheTtlMs = cacheTtlMs;
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ CacheEntry cached = cache.get(key);
+ if (cached != null && !cached.isExpired()) {
+ return cached.value;
+ }
+
+ String path = kvMount + "/" + secretNamePrefix + key;
+ String value = readFromVault(path);
+
+ cache.put(key, new CacheEntry(value, cacheTtlMs));
+ return value;
+ }
+
+ /**
+ * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
+ * for each key to re-fetch from Vault. Useful after a secret rotation.
+ */
+ public void invalidateCache() {
+ cache.clear();
+ Debug.logInfo("HashicorpVaultSecretsProvider: secret cache invalidated", MODULE);
+ }
+
+ // -- private helpers --
+
+ private String readFromVault(String path) throws GeneralException {
+ Map data;
+ try {
+ data = vaultReader.read(path);
+ } catch (VaultException e) {
+ throw new GeneralException("Vault error reading path '" + path + "': " + e.getMessage(), e);
+ }
+
+ if (data == null || data.isEmpty()) {
+ throw new GeneralException("No data found at Vault path '" + path + "' — check the path and auth policy");
+ }
+
+ if (!field.isEmpty()) {
+ String value = data.get(field);
+ if (value == null) {
+ throw new GeneralException(
+ "Field '" + field + "' not found in Vault secret at '" + path + "'");
+ }
+ return value;
+ }
+
+ if (data.size() == 1) {
+ return data.values().iterator().next();
+ }
+
+ throw new GeneralException("Secret at '" + path + "' has " + data.size()
+ + " fields — set vault.field to select one");
+ }
+
+ private static HashicorpVaultReader readerFrom(Vault vault) {
+ return path -> {
+ Map data = vault.logical().read(path).getData();
+ return data != null ? data : Collections.emptyMap();
+ };
+ }
+
+ private static Vault buildVault() {
+ String address = prop("vault.address", "http://127.0.0.1:8200");
+ String authMethod = prop("vault.auth.method", "token");
+ int kvVersion = parseKvVersion(prop("vault.kv.version", "2"));
+ boolean sslVerify = Boolean.parseBoolean(prop("vault.ssl.verify", "true"));
+
+ try {
+ SslConfig ssl = new SslConfig().verify(sslVerify).build();
+ String token;
+
+ if ("approle".equalsIgnoreCase(authMethod)) {
+ String roleId = prop("vault.approle.role_id", "");
+ String secretId = prop("vault.approle.secret_id", "");
+ VaultConfig bootConfig = new VaultConfig()
+ .address(address)
+ .engineVersion(kvVersion)
+ .sslConfig(ssl)
+ .build();
+ token = new Vault(bootConfig)
+ .auth()
+ .loginByAppRole(roleId, secretId)
+ .getAuthClientToken();
+ } else {
+ token = prop("vault.token", "");
+ }
+
+ VaultConfig config = new VaultConfig()
+ .address(address)
+ .token(token)
+ .engineVersion(kvVersion)
+ .sslConfig(ssl)
+ .build();
+
+ Debug.logInfo("HashicorpVaultSecretsProvider: initialized address=" + address
+ + " auth=" + authMethod + " kv-version=" + kvVersion, MODULE);
+ return new Vault(config);
+
+ } catch (VaultException e) {
+ throw new RuntimeException("HashicorpVaultSecretsProvider: initialization failed: " + e.getMessage(), e);
+ }
+ }
+
+ private static int parseKvVersion(String raw) {
+ try {
+ int v = Integer.parseInt(raw.trim());
+ if (v == 1 || v == 2) return v;
+ } catch (NumberFormatException ignored) { }
+ Debug.logWarning("Invalid vault.kv.version '" + raw + "', defaulting to 2", MODULE);
+ return 2;
+ }
+
+ private static long readTtlMs() {
+ String raw = prop("vault.cache.ttl.seconds", "3600");
+ try {
+ return Long.parseLong(raw.trim()) * 1000L;
+ } catch (NumberFormatException e) {
+ Debug.logWarning("Invalid vault.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
+ return 3_600_000L;
+ }
+ }
+
+ private static String prop(String key, String defaultValue) {
+ return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
+ }
+}
diff --git a/hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..2e36cff5e
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.hashicorpvault.HashicorpVaultSecretsProvider
diff --git a/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java b/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
new file mode 100644
index 000000000..22afb7cf9
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.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.ofbiz.hashicorpvault;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.github.jopenlibs.vault.VaultException;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+public class HashicorpVaultSecretsProviderTest {
+
+ private static final long ONE_HOUR_MS = 3_600_000L;
+
+ // -- Happy path --
+
+ @Test
+ public void getSecret_returnsSingleFieldSecret() throws GeneralException {
+ HashicorpVaultReader reader = fixedReader("secret/mykey", singleEntry("value", "s3cr3t"));
+ HashicorpVaultSecretsProvider provider = provider(reader, "", "");
+
+ assertEquals("s3cr3t", provider.getSecret("mykey"));
+ }
+
+ @Test
+ public void getSecret_extractsNamedField() throws GeneralException {
+ HashicorpVaultReader reader = fixedReader("secret/mykey", twoEntry("username", "user", "password", "s3cr3t"));
+ HashicorpVaultSecretsProvider provider = provider(reader, "", "password");
+
+ assertEquals("s3cr3t", provider.getSecret("mykey"));
+ }
+
+ @Test
+ public void getSecret_appliesKvMountAndPrefix() throws GeneralException {
+ HashicorpVaultReader reader = fixedReader("kv/prod/jdbc-password.ofbiz", singleEntry("value", "dbpass"));
+ HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "kv", "prod/", "", ONE_HOUR_MS);
+
+ assertEquals("dbpass", provider.getSecret("jdbc-password.ofbiz"));
+ }
+
+ @Test
+ public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ HashicorpVaultReader reader = path -> {
+ calls.incrementAndGet();
+ return singleEntry("value", "s3cr3t");
+ };
+ HashicorpVaultSecretsProvider provider = provider(reader, "", "");
+
+ provider.getSecret("mykey");
+ provider.getSecret("mykey"); // second call — cache hit
+
+ assertEquals(1, calls.get());
+ }
+
+ @Test
+ public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ HashicorpVaultReader reader = path -> {
+ calls.incrementAndGet();
+ return singleEntry("value", "val");
+ };
+ HashicorpVaultSecretsProvider provider = provider(reader, "", "");
+
+ provider.getSecret("mykey");
+ provider.invalidateCache();
+ provider.getSecret("mykey");
+
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ public void getSecret_expiredCacheEntryTriggersRefetch() throws GeneralException {
+ AtomicInteger calls = new AtomicInteger();
+ HashicorpVaultReader reader = path -> {
+ calls.incrementAndGet();
+ return singleEntry("value", "val");
+ };
+ // TTL of -1 ms means entries expire immediately
+ HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "secret", "", "", -1L);
+
+ provider.getSecret("mykey");
+ provider.getSecret("mykey");
+
+ assertEquals(2, calls.get());
+ }
+
+ // -- Error handling --
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsWhenDataIsEmpty() throws GeneralException {
+ HashicorpVaultReader reader = path -> Collections.emptyMap();
+ provider(reader, "", "").getSecret("missing");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsWhenFieldMissing() throws GeneralException {
+ HashicorpVaultReader reader = fixedReader("secret/mykey", singleEntry("username", "dbuser"));
+ provider(reader, "", "password").getSecret("mykey"); // "password" field not present
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsWhenMultiFieldAndNoFieldConfigured() throws GeneralException {
+ HashicorpVaultReader reader = fixedReader("secret/mykey", twoEntry("username", "u", "password", "p"));
+ provider(reader, "", "").getSecret("mykey"); // ambiguous — 2 fields, no field config
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsOnVaultException() throws GeneralException {
+ HashicorpVaultReader reader = path -> { throw new VaultException("connection refused", 503); };
+ provider(reader, "", "").getSecret("mykey");
+ }
+
+ // -- helpers --
+
+ private static HashicorpVaultSecretsProvider provider(HashicorpVaultReader reader, String prefix, String field) {
+ return new HashicorpVaultSecretsProvider(reader, "secret", prefix, field, ONE_HOUR_MS);
+ }
+
+ private static HashicorpVaultReader fixedReader(String expectedPath, Map data) {
+ return path -> expectedPath.equals(path) ? data : Collections.emptyMap();
+ }
+
+ private static Map singleEntry(String k, String v) {
+ return Collections.singletonMap(k, v);
+ }
+
+ private static Map twoEntry(String k1, String v1, String k2, String v2) {
+ Map m = new HashMap<>();
+ m.put(k1, v1);
+ m.put(k2, v2);
+ return m;
+ }
+}
diff --git a/onepassword-secrets-provider/build.gradle b/onepassword-secrets-provider/build.gradle
new file mode 100644
index 000000000..b2ca70694
--- /dev/null
+++ b/onepassword-secrets-provider/build.gradle
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+// No external SDK required.
+// Uses Java's built-in java.net.http.HttpClient (Java 11+) and OFBiz's
+// bundled jackson-databind for JSON parsing.
+dependencies {}
diff --git a/onepassword-secrets-provider/config/onepassword.properties b/onepassword-secrets-provider/config/onepassword.properties
new file mode 100644
index 000000000..6a46799df
--- /dev/null
+++ b/onepassword-secrets-provider/config/onepassword.properties
@@ -0,0 +1,56 @@
+###############################################################################
+# 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.
+###############################################################################
+
+####
+# 1Password Connect Server — SecretProvider configuration
+#
+# Requires a self-hosted 1Password Connect Server.
+# See: https://developer.1password.com/docs/connect/
+#
+# NOTE: Only ONE SecretProvider plugin may be active at a time.
+# Deploy only the plugin that matches your environment.
+####
+
+# URL of your 1Password Connect Server (no trailing slash).
+op.connect.url=http://localhost:8080
+
+# Connect Server access token.
+# Inject at deploy time — do not commit a real token.
+op.connect.token=
+
+# UUID of the vault to search for secrets.
+# Find it in the 1Password app or via: GET /v1/vaults
+op.vault.id=
+
+# The item field label whose value is returned as the secret.
+# Default is "password" — matches the standard Login and Password item templates.
+op.field=password
+
+# Optional prefix prepended to the OFBiz key when searching for the 1Password item title.
+# Example: with prefix "myapp/" the key "jdbc-password.ofbiz" matches an item titled
+# "myapp/jdbc-password.ofbiz" in 1Password.
+op.secret.name.prefix=
+
+# How long (in seconds) to cache a resolved secret value before re-fetching.
+# Default: 3600 (1 hour). Set to 0 to disable caching.
+op.cache.ttl.seconds=3600
+
+# HTTP connect and read timeouts in seconds.
+op.connect.timeout.seconds=5
+op.read.timeout.seconds=10
diff --git a/onepassword-secrets-provider/ofbiz-component.xml b/onepassword-secrets-provider/ofbiz-component.xml
new file mode 100644
index 000000000..10f7cafe5
--- /dev/null
+++ b/onepassword-secrets-provider/ofbiz-component.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
new file mode 100644
index 000000000..fc91c2d9d
--- /dev/null
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
@@ -0,0 +1,39 @@
+/*******************************************************************************
+ * 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.ofbiz.onepassword;
+
+import java.io.IOException;
+
+/**
+ * Thin seam over HTTP GET calls used by {@link OnePasswordSecretsProvider}.
+ * Kept package-private so tests can substitute a mock without launching a real
+ * Connect Server.
+ */
+@FunctionalInterface
+interface OnePasswordHttpClient {
+ /**
+ * Performs an authenticated GET request.
+ *
+ * @param url the full request URL
+ * @param bearerToken the Connect Server access token
+ * @return the response body as a UTF-8 string
+ * @throws IOException if the request fails or the server returns a non-2xx status
+ */
+ String get(String url, String bearerToken) throws IOException;
+}
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
new file mode 100644
index 000000000..88cddd79e
--- /dev/null
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
@@ -0,0 +1,275 @@
+/*******************************************************************************
+ * 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.ofbiz.onepassword;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+
+/**
+ * {@link SecretProvider} implementation backed by a 1Password Connect Server.
+ *
+ * Looks up items in a designated 1Password vault by matching the item title
+ * to the OFBiz secret key (with an optional prefix). The value of the configured
+ * field (default {@code "password"}) is returned as the secret.
+ *
+ * Authentication uses a static Connect Server access token configured in
+ * {@code op.connect.token}. Inject this token at deploy time — do not commit it.
+ *
+ * API flow per lookup:
+ *
+ * - {@code GET /v1/vaults/{vaultId}/items?filter=title eq "{title}"} — find the item UUID
+ * - {@code GET /v1/vaults/{vaultId}/items/{itemId}} — fetch the full item with field values
+ * - Scan {@code fields[]} for the entry whose {@code label} matches {@code op.field}
+ *
+ *
+ * Configure via {@code plugins/onepassword-secrets-provider/config/onepassword.properties}.
+ */
+@ThreadSafe
+public final class OnePasswordSecretsProvider implements SecretProvider {
+
+ private static final String MODULE = OnePasswordSecretsProvider.class.getName();
+ private static final String CONFIG_RESOURCE = "onepassword";
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ private final OnePasswordHttpClient httpClient;
+ private final String connectUrl;
+ private final String token;
+ private final String vaultId;
+ private final String field;
+ private final String secretNamePrefix;
+ private final long cacheTtlMs;
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ private static final class CacheEntry {
+ final String value;
+ final long expiresAt;
+
+ CacheEntry(String value, long ttlMs) {
+ this.value = value;
+ this.expiresAt = System.currentTimeMillis() + ttlMs;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public OnePasswordSecretsProvider() {
+ this(buildHttpClient(),
+ prop("op.connect.url", "http://localhost:8080").replaceAll("/+$", ""),
+ prop("op.connect.token", ""),
+ prop("op.vault.id", ""),
+ prop("op.field", "password"),
+ prop("op.secret.name.prefix", ""),
+ readTtlMs());
+ }
+
+ /** Package-private constructor used by unit tests to inject a mock HTTP client. */
+ OnePasswordSecretsProvider(OnePasswordHttpClient httpClient, String connectUrl, String token,
+ String vaultId, String field, String secretNamePrefix, long cacheTtlMs) {
+ this.httpClient = httpClient;
+ this.connectUrl = connectUrl;
+ this.token = token;
+ this.vaultId = vaultId;
+ this.field = field;
+ this.secretNamePrefix = secretNamePrefix;
+ this.cacheTtlMs = cacheTtlMs;
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ CacheEntry cached = cache.get(key);
+ if (cached != null && !cached.isExpired()) {
+ return cached.value;
+ }
+
+ String title = secretNamePrefix + key;
+ String value = fetchFromConnect(title);
+
+ cache.put(key, new CacheEntry(value, cacheTtlMs));
+ return value;
+ }
+
+ /**
+ * Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
+ * to re-fetch from the Connect Server. Useful after a secret update in 1Password.
+ */
+ public void invalidateCache() {
+ cache.clear();
+ Debug.logInfo("OnePasswordSecretsProvider: secret cache invalidated", MODULE);
+ }
+
+ // -- private helpers --
+
+ private String fetchFromConnect(String title) throws GeneralException {
+ String itemId = findItemId(title);
+ return fetchFieldValue(itemId, title);
+ }
+
+ private String findItemId(String title) throws GeneralException {
+ String filter = "title eq \"" + title + "\"";
+ String encodedFilter;
+ try {
+ encodedFilter = URLEncoder.encode(filter, StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ throw new GeneralException("Failed to encode filter for title '" + title + "'", e);
+ }
+
+ String searchUrl = connectUrl + "/v1/vaults/" + vaultId + "/items?filter=" + encodedFilter;
+ String responseBody;
+ try {
+ responseBody = httpClient.get(searchUrl, token);
+ } catch (IOException e) {
+ throw new GeneralException("1Password Connect request failed: " + e.getMessage(), e);
+ }
+
+ try {
+ JsonNode items = JSON.readTree(responseBody);
+ if (!items.isArray() || items.size() == 0) {
+ throw new GeneralException("No 1Password item found with title '" + title + "'");
+ }
+ JsonNode idNode = items.get(0).get("id");
+ if (idNode == null || idNode.isNull()) {
+ throw new GeneralException("1Password item for '" + title + "' has no id field");
+ }
+ return idNode.asText();
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException("Failed to parse 1Password item search response: " + e.getMessage(), e);
+ }
+ }
+
+ private String fetchFieldValue(String itemId, String title) throws GeneralException {
+ String itemUrl = connectUrl + "/v1/vaults/" + vaultId + "/items/" + itemId;
+ String responseBody;
+ try {
+ responseBody = httpClient.get(itemUrl, token);
+ } catch (IOException e) {
+ throw new GeneralException("1Password Connect request failed for item '" + itemId + "': " + e.getMessage(), e);
+ }
+
+ try {
+ JsonNode item = JSON.readTree(responseBody);
+ JsonNode fields = item.get("fields");
+ if (fields == null || !fields.isArray()) {
+ throw new GeneralException("1Password item '" + title + "' has no fields array");
+ }
+
+ for (JsonNode f : fields) {
+ JsonNode labelNode = f.get("label");
+ if (labelNode != null && field.equalsIgnoreCase(labelNode.asText())) {
+ JsonNode valueNode = f.get("value");
+ if (valueNode == null || valueNode.isNull()) {
+ throw new GeneralException(
+ "Field '" + field + "' in 1Password item '" + title + "' has a null value");
+ }
+ String value = valueNode.asText();
+ if (value.isEmpty()) {
+ throw new GeneralException(
+ "Field '" + field + "' in 1Password item '" + title + "' is empty");
+ }
+ return value;
+ }
+ }
+ throw new GeneralException(
+ "Field '" + field + "' not found in 1Password item '" + title + "'");
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException("Failed to parse 1Password item response: " + e.getMessage(), e);
+ }
+ }
+
+ private static OnePasswordHttpClient buildHttpClient() {
+ int connectTimeout = parseSeconds(prop("op.connect.timeout.seconds", "5"));
+ int readTimeout = parseSeconds(prop("op.read.timeout.seconds", "10"));
+
+ HttpClient javaClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(connectTimeout))
+ .build();
+
+ Debug.logInfo("OnePasswordSecretsProvider: initialized connect-url=" + prop("op.connect.url", ""),
+ MODULE);
+
+ return (url, bearerToken) -> {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("Authorization", "Bearer " + bearerToken)
+ .header("Accept", "application/json")
+ .timeout(Duration.ofSeconds(readTimeout))
+ .GET()
+ .build();
+
+ HttpResponse response;
+ try {
+ response = javaClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("HTTP request interrupted", e);
+ }
+
+ int status = response.statusCode();
+ if (status < 200 || status >= 300) {
+ throw new IOException("1Password Connect returned HTTP " + status + " for " + url);
+ }
+ return response.body();
+ };
+ }
+
+ private static int parseSeconds(String raw) {
+ try {
+ return Integer.parseInt(raw.trim());
+ } catch (NumberFormatException e) {
+ return 10;
+ }
+ }
+
+ private static long readTtlMs() {
+ String raw = prop("op.cache.ttl.seconds", "3600");
+ try {
+ return Long.parseLong(raw.trim()) * 1000L;
+ } catch (NumberFormatException e) {
+ Debug.logWarning("Invalid op.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
+ return 3_600_000L;
+ }
+ }
+
+ private static String prop(String key, String defaultValue) {
+ return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
+ }
+}
diff --git a/onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..128ee9165
--- /dev/null
+++ b/onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.onepassword.OnePasswordSecretsProvider
diff --git a/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java b/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
new file mode 100644
index 000000000..1cb1dc519
--- /dev/null
+++ b/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.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.ofbiz.onepassword;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+public class OnePasswordSecretsProviderTest {
+
+ private static final long ONE_HOUR_MS = 3_600_000L;
+ private static final String BASE_URL = "http://localhost:8080";
+ private static final String TOKEN = "test-token";
+ private static final String VAULT_ID = "vault-abc";
+ private static final String ITEM_ID = "item-1";
+
+ // -- Happy path --
+
+ @Test
+ public void getSecret_returnsFieldValue() throws Exception {
+ OnePasswordHttpClient client = buildMockClient("jdbc-password.mydb", ITEM_ID, "s3cr3t");
+ assertEquals("s3cr3t", provider(client, "password", "").getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecret_appliesSecretNamePrefix() throws Exception {
+ OnePasswordHttpClient client = buildMockClient("prod/jdbc-password.mydb", ITEM_ID, "dbpass");
+ assertEquals("dbpass", provider(client, "password", "prod/").getSecret("jdbc-password.mydb"));
+ }
+
+ @Test
+ public void getSecret_cachePreventsSecondHttpCall() throws Exception {
+ OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val");
+ OnePasswordSecretsProvider p = provider(client, "password", "");
+
+ p.getSecret("mykey");
+ p.getSecret("mykey"); // cache hit — no HTTP calls
+
+ // First getSecret = 2 calls (1 search + 1 fetch); second = 0 (cache)
+ verify(client, times(2)).get(anyString(), anyString());
+ }
+
+ @Test
+ public void invalidateCache_forcesRefetch() throws Exception {
+ OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val");
+ OnePasswordSecretsProvider p = provider(client, "password", "");
+
+ p.getSecret("mykey");
+ p.invalidateCache();
+ p.getSecret("mykey"); // cache cleared — full re-fetch
+
+ // Two full fetches × 2 HTTP calls each = 4 total
+ verify(client, times(4)).get(anyString(), anyString());
+ }
+
+ @Test
+ public void getSecret_expiredCacheEntryTriggersRefetch() throws Exception {
+ OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val");
+ OnePasswordSecretsProvider p = provider(client, "password", "", -1L); // instant expiry
+
+ p.getSecret("mykey");
+ p.getSecret("mykey"); // expired — must re-fetch
+
+ verify(client, times(4)).get(anyString(), anyString());
+ }
+
+ // -- Error handling --
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsWhenItemNotFound() throws Exception {
+ OnePasswordHttpClient client = mock(OnePasswordHttpClient.class);
+ when(client.get(anyString(), anyString())).thenReturn("[]");
+
+ provider(client, "password", "").getSecret("missing");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsWhenFieldNotPresent() throws Exception {
+ // Item exists but has no "password" field — only "username"
+ OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "username", "dbuser", "password");
+ provider(client, "password", "").getSecret("mykey");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecret_throwsOnHttpError() throws Exception {
+ OnePasswordHttpClient client = mock(OnePasswordHttpClient.class);
+ when(client.get(anyString(), anyString())).thenThrow(new IOException("connection refused"));
+
+ provider(client, "password", "").getSecret("mykey");
+ }
+
+ // -- helpers --
+
+ private static OnePasswordSecretsProvider provider(OnePasswordHttpClient client, String field, String prefix) {
+ return provider(client, field, prefix, ONE_HOUR_MS);
+ }
+
+ private static OnePasswordSecretsProvider provider(OnePasswordHttpClient client, String field,
+ String prefix, long ttlMs) {
+ return new OnePasswordSecretsProvider(client, BASE_URL, TOKEN, VAULT_ID, field, prefix, ttlMs);
+ }
+
+ /**
+ * Builds a mock that routes by URL: search requests get the item list,
+ * item-fetch requests get the full item with a "password" field.
+ */
+ private static OnePasswordHttpClient buildMockClient(String title, String itemId, String fieldValue)
+ throws IOException {
+ return buildMockClient(title, itemId, "password", fieldValue, "password");
+ }
+
+ /**
+ * Builds a mock that exposes an item with the given fieldLabel/fieldValue,
+ * regardless of which field the provider is configured to look for.
+ */
+ private static OnePasswordHttpClient buildMockClient(String title, String itemId,
+ String fieldLabel, String fieldValue, String ignoredField) throws IOException {
+ OnePasswordHttpClient client = mock(OnePasswordHttpClient.class);
+ when(client.get(anyString(), eq(TOKEN))).thenAnswer(inv -> {
+ String url = inv.getArgument(0, String.class);
+ if (url.contains("filter=")) {
+ return "[{\"id\":\"" + itemId + "\",\"title\":\"" + title + "\"}]";
+ }
+ // item detail
+ return "{\"id\":\"" + itemId + "\",\"fields\":["
+ + "{\"label\":\"" + fieldLabel + "\",\"value\":\"" + fieldValue + "\"}"
+ + "]}";
+ });
+ return client;
+ }
+}
From eef8b9b63e5a5244d4d85711894415aae7431d79 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Mon, 8 Jun 2026 18:33:13 +0530
Subject: [PATCH 02/18] Let's keep this component disabled by default. We will
enable it based on need. We are keeping it disabled because we want to make
sure that plain text and encrypted password scheme on file system is working
fine.
---
aws-secrets-provider/ofbiz-component.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/aws-secrets-provider/ofbiz-component.xml b/aws-secrets-provider/ofbiz-component.xml
index 445cfb3ae..619c5611d 100644
--- a/aws-secrets-provider/ofbiz-component.xml
+++ b/aws-secrets-provider/ofbiz-component.xml
@@ -18,7 +18,7 @@ specific language governing permissions and limitations
under the License.
-->
-
From 63bda5273a730e4a012627d5d8e467df6307cf2d Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Tue, 9 Jun 2026 15:19:04 +0530
Subject: [PATCH 03/18] Using a method to decrypt a string if we are getting
encrypted value from the secret manager.
Testing with aws secret manager has been completed. I tested it with plain text and encrypted passwords both. Added a method in ConfigCryptoUtil.java and this method will be used by all the secret manager plugins present in plugins folder.
---
.../apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java | 3 +++
.../ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java | 4 ++++
.../org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java | 2 ++
.../gcpsecretmanager/GcpSecretManagerSecretsProvider.java | 4 ++++
.../ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java | 3 +++
.../apache/ofbiz/onepassword/OnePasswordSecretsProvider.java | 2 ++
6 files changed, 18 insertions(+)
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
index 83c2fbb83..78b093032 100644
--- a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
+++ b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
@@ -25,6 +25,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
import org.apache.ofbiz.base.util.Debug;
@@ -122,6 +123,8 @@ public String getSecret(String key) throws GeneralException {
throw new GeneralException("Secret '" + secretName + "' resolved to an empty value");
}
+ secretValue = ConfigCryptoUtil.decryptIfEncrypted(secretValue, secretName);
+
cache.put(key, new CacheEntry(secretValue, cacheTtlMs));
return secretValue;
}
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
index b32a8b2d3..5d6c3c5ab 100644
--- a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
+++ b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
@@ -26,6 +26,8 @@
import com.azure.security.keyvault.secrets.SecretClient;
import com.azure.security.keyvault.secrets.SecretClientBuilder;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
+
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
import org.apache.ofbiz.base.util.Debug;
@@ -121,6 +123,8 @@ public String getSecret(String key) throws GeneralException {
"Azure Key Vault returned empty value for secret '" + secretName + "'");
}
+ value = ConfigCryptoUtil.decryptIfEncrypted(value, secretName);
+
cache.put(key, new CacheEntry(value, cacheTtlMs));
return value;
}
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
index ed94ea7a8..9575d6cf5 100644
--- a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
@@ -38,6 +38,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
import org.apache.ofbiz.base.util.Debug;
@@ -204,6 +205,7 @@ public String getSecret(String key) throws GeneralException {
String secretName = secretNamePrefix + key;
String value = fetchFromBitwarden(secretName);
+ value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
cache.put(key, new CacheEntry(value, cacheTtlMs));
return value;
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
index 5a3e3a368..6ca552294 100644
--- a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
+++ b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
@@ -27,6 +27,8 @@
import com.google.cloud.secretmanager.v1.SecretManagerServiceClient;
import com.google.cloud.secretmanager.v1.SecretManagerServiceSettings;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
+
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
import org.apache.ofbiz.base.util.Debug;
@@ -132,6 +134,8 @@ public String getSecret(String key) throws GeneralException {
"GCP Secret Manager returned empty value for '" + resourceName + "'");
}
+ value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
+
cache.put(key, new CacheEntry(value, cacheTtlMs));
return value;
}
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
index ab16ca8e6..2b4ec549f 100644
--- a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
+++ b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
@@ -28,6 +28,8 @@
import io.github.jopenlibs.vault.VaultConfig;
import io.github.jopenlibs.vault.VaultException;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
+
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
import org.apache.ofbiz.base.util.Debug;
@@ -107,6 +109,7 @@ public String getSecret(String key) throws GeneralException {
String path = kvMount + "/" + secretNamePrefix + key;
String value = readFromVault(path);
+ value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
cache.put(key, new CacheEntry(value, cacheTtlMs));
return value;
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
index 88cddd79e..10fb4d522 100644
--- a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
@@ -31,6 +31,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
import org.apache.ofbiz.base.util.Debug;
@@ -119,6 +120,7 @@ public String getSecret(String key) throws GeneralException {
String title = secretNamePrefix + key;
String value = fetchFromConnect(title);
+ value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
cache.put(key, new CacheEntry(value, cacheTtlMs));
return value;
From e928c93c83d51eb2acd0c403db97c51d7075835a Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Tue, 9 Jun 2026 16:48:09 +0530
Subject: [PATCH 04/18] Fixing console errors in using gRPC in ofbiz. Converted
the all into REST one. Formatted the code following the best practices.
---
.../GcpSecretManagerSecretsProvider.java | 13 ++++++----
.../GcpSecretManagerSecretsProviderTest.java | 24 ++++++++++---------
2 files changed, 22 insertions(+), 15 deletions(-)
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
index 6ca552294..fefccf6cb 100644
--- a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
+++ b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
@@ -76,14 +76,18 @@ public final class GcpSecretManagerSecretsProvider implements SecretProvider {
private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
private static final class CacheEntry {
- final String value;
- final long expiresAt;
+ private final String value;
+ private final long expiresAt;
CacheEntry(String value, long ttlMs) {
this.value = value;
this.expiresAt = System.currentTimeMillis() + ttlMs;
}
+ String getValue() {
+ return value;
+ }
+
boolean isExpired() {
return System.currentTimeMillis() >= expiresAt;
}
@@ -114,7 +118,7 @@ public GcpSecretManagerSecretsProvider() throws GeneralException {
public String getSecret(String key) throws GeneralException {
CacheEntry cached = cache.get(key);
if (cached != null && !cached.isExpired()) {
- return cached.value;
+ return cached.getValue();
}
String sanitizedKey = dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement);
@@ -167,7 +171,8 @@ private static GcpSecretReader readerFrom(SecretManagerServiceClient client) {
private static SecretManagerServiceClient buildClient() throws GeneralException {
String credentialsFile = prop("gcp.credentials.file", "");
try {
- SecretManagerServiceSettings.Builder builder = SecretManagerServiceSettings.newBuilder();
+ // HTTP/JSON transport avoids gRPC/Netty entirely — no --add-opens JVM flags needed on Java 17
+ SecretManagerServiceSettings.Builder builder = SecretManagerServiceSettings.newHttpJsonBuilder();
if (!credentialsFile.isEmpty()) {
try (FileInputStream fis = new FileInputStream(credentialsFile)) {
diff --git a/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java b/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
index 053fc3ddf..a33f17e10 100644
--- a/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
+++ b/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
@@ -33,14 +33,14 @@ public class GcpSecretManagerSecretsProviderTest {
// -- Happy path --
@Test
- public void getSecret_returnsSecretValue() throws GeneralException {
+ public void getSecretReturnsSecretValue() throws GeneralException {
GcpSecretReader reader = fixedReader(
"projects/my-project/secrets/mykey/versions/latest", "s3cr3t");
assertEquals("s3cr3t", provider(reader, "", "-").getSecret("mykey"));
}
@Test
- public void getSecret_sanitizesDotInKey() throws GeneralException {
+ public void getSecretSanitizesDotInKey() throws GeneralException {
// OFBiz key "jdbc-password.mysql-ofbiz" → GCP name "jdbc-password-mysql-ofbiz"
GcpSecretReader reader = fixedReader(
"projects/my-project/secrets/jdbc-password-mysql-ofbiz/versions/latest", "dbpass");
@@ -48,14 +48,14 @@ public void getSecret_sanitizesDotInKey() throws GeneralException {
}
@Test
- public void getSecret_appliesPrefixAfterSanitizing() throws GeneralException {
+ public void getSecretAppliesPrefixAfterSanitizing() throws GeneralException {
GcpSecretReader reader = fixedReader(
"projects/my-project/secrets/prod-jdbc-password-mydb/versions/latest", "prodpass");
assertEquals("prodpass", provider(reader, "prod-", "-").getSecret("jdbc-password.mydb"));
}
@Test
- public void getSecret_usesConfiguredVersion() throws GeneralException {
+ public void getSecretUsesConfiguredVersion() throws GeneralException {
GcpSecretReader reader = fixedReader(
"projects/my-project/secrets/mykey/versions/3", "v3val");
GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider(
@@ -64,7 +64,7 @@ public void getSecret_usesConfiguredVersion() throws GeneralException {
}
@Test
- public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException {
+ public void getSecretCachePreventsDuplicateReaderCall() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
GcpSecretReader reader = resourceName -> {
calls.incrementAndGet();
@@ -79,7 +79,7 @@ public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException
}
@Test
- public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
+ public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
GcpSecretReader reader = resourceName -> {
calls.incrementAndGet();
@@ -95,7 +95,7 @@ public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
}
@Test
- public void getSecret_expiredCacheTriggersRefetch() throws GeneralException {
+ public void getSecretExpiredCacheTriggersRefetch() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
GcpSecretReader reader = resourceName -> {
calls.incrementAndGet();
@@ -112,7 +112,7 @@ public void getSecret_expiredCacheTriggersRefetch() throws GeneralException {
}
@Test
- public void getSecret_dotReplacementDisabled_keepsDotsInName() throws GeneralException {
+ public void getSecretDotReplacementDisabledKeepsDotsInName() throws GeneralException {
// dot.replacement="" → no sanitization
GcpSecretReader reader = fixedReader(
"projects/my-project/secrets/my.key/versions/latest", "val");
@@ -122,13 +122,15 @@ public void getSecret_dotReplacementDisabled_keepsDotsInName() throws GeneralExc
// -- Error handling --
@Test(expected = GeneralException.class)
- public void getSecret_throwsOnReaderException() throws GeneralException {
- GcpSecretReader reader = resourceName -> { throw new Exception("NOT_FOUND"); };
+ public void getSecretThrowsOnReaderException() throws GeneralException {
+ GcpSecretReader reader = resourceName -> {
+ throw new Exception("NOT_FOUND");
+ };
provider(reader, "", "-").getSecret("missing");
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsOnEmptyValue() throws GeneralException {
+ public void getSecretThrowsOnEmptyValue() throws GeneralException {
GcpSecretReader reader = resourceName -> "";
provider(reader, "", "-").getSecret("mykey");
}
From ac691b24e8ab8b73766e17d2b742162591ce836e Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Tue, 9 Jun 2026 20:06:58 +0530
Subject: [PATCH 05/18] =?UTF-8?q?Fixing=20the=20three=20bugs=20in=20the=20?=
=?UTF-8?q?bitwarden=20code=20base:=20-=20Wrong=20oauthClientId=20format?=
=?UTF-8?q?=20("service-account."=20prefix=20shouldn't=20be=20there)=20-?=
=?UTF-8?q?=20Wrong=20sealing=20key=20length=20validation=20(16=20bytes,?=
=?UTF-8?q?=20not=C2=A064)=20-=20Missing=20encrypted=5Fpayload=20decryptio?=
=?UTF-8?q?n=20step=20to=20derive=20the=20org=20key,=20plus=20"secrets"=20?=
=?UTF-8?q?vs=20"data"=20field=20name=20in=20the=20list=20API=20response?=
=?UTF-8?q?=20=20=20-=20Wrong=20oauthClientId=20format=20("service-account?=
=?UTF-8?q?."=20prefix=20shouldn't=20be=20there)=20=20=20-=20Wrong=20seali?=
=?UTF-8?q?ng=20key=20length=20validation=20(16=20bytes,=20not=C2=A064)=20?=
=?UTF-8?q?=20=20-=20Missing=20encrypted=5Fpayload=20decryption=20step=20t?=
=?UTF-8?q?o=20derive=20the=20org=20key,=20plus=20"secrets"=20vs=20"data"?=
=?UTF-8?q?=20field=20name=20in=20the=20list=20API=20response?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../bitwarden/BitwardenSecretsProvider.java | 185 ++++++++++++++----
1 file changed, 145 insertions(+), 40 deletions(-)
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
index 9575d6cf5..a0ea407be 100644
--- a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
@@ -50,31 +50,23 @@
*
* Authentication
* Uses a machine account access token (created in the Bitwarden SM console).
- * The token has the format {@code 0..:}.
+ * The token has the format {@code 0..:}.
* The provider parses the token to extract OAuth credentials for the identity service
- * and the 64-byte symmetric key for client-side decryption.
+ * and a 16-byte sealing key used to derive the org symmetric key.
*
- * End-to-end encryption
- * Bitwarden SM encrypts all data at rest and in transit. Secret keys (names)
- * and values are returned from the API in encrypted form. This provider decrypts
- * them using AES-256-CBC with HMAC-SHA256 integrity verification before returning the
- * plaintext to OFBiz. No plaintext ever leaves the JVM.
- *
- * Cipher format
- * Encrypted strings use Bitwarden's type-2 cipher format:
- * {@code 2.||}
- *
- * - MAC key (bytes 32–63 of the 64-byte symmetric key) verifies integrity via
- * HMAC-SHA256 over {@code IV || ciphertext}.
- * - Enc key (bytes 0–31) decrypts with AES-256-CBC after MAC is verified.
- *
- *
- * API flow per lookup
+ * Key derivation
+ * The 16-byte sealing key from the token is expanded to a 64-byte derived key via:
*
- * - POST to identity service to exchange client credentials for a bearer token.
- * - GET the organization's secret list; decrypt each secret key to find the match.
- * - GET the matched secret by ID; decrypt and return the value.
+ * - {@code PRK = HMAC-SHA256(key="bitwarden-accesstoken", msg=sealingKey)}
+ * - {@code key64 = HKDF-Expand(PRK, info="sm-access-token", length=64)}
*
+ * This derived key decrypts the {@code encrypted_payload} field returned by the OAuth endpoint.
+ * The payload contains a JSON object {@code {"encryptionKey": ""}} whose value is the
+ * 64-byte org symmetric key used for all subsequent secret decryption.
+ *
+ * End-to-end encryption
+ * Secret keys (names) and values are returned from the API in encrypted form.
+ * This provider decrypts them using AES-256-CBC with HMAC-SHA256 integrity verification.
*
* Configure via {@code plugins/bitwarden-secrets-provider/config/bitwarden-secrets.properties}.
*/
@@ -95,8 +87,11 @@ public final class BitwardenSecretsProvider implements SecretProvider {
// Parsed from the access token — never stored in config
private final String oauthClientId;
private final String oauthClientSecret;
- private final byte[] encKey; // bytes 0-31 of the 64-byte symmetric key
- private final byte[] macKey; // bytes 32-63
+ // 16-byte sealing key from token; used to derive the org enc/mac keys after first OAuth call
+ private final byte[] sealingKey;
+ // Org symmetric key halves — null until first successful OAuth + payload decryption
+ private volatile byte[] encKey;
+ private volatile byte[] macKey;
// Cached OAuth bearer token + its expiry
private volatile String bearerToken = null;
@@ -144,6 +139,7 @@ public BitwardenSecretsProvider() throws GeneralException {
this.cacheTtlMs = cacheTtlMs;
this.oauthClientId = clientId;
this.oauthClientSecret = clientSecret;
+ this.sealingKey = null; // not needed when enc/mac keys are injected directly
this.encKey = encKey;
this.macKey = macKey;
}
@@ -159,14 +155,14 @@ private BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl,
this.secretNamePrefix = secretNamePrefix;
this.cacheTtlMs = cacheTtlMs;
- // Parse: "0..:"
+ // Parse: "0..:"
int colonIdx = rawAccessToken.lastIndexOf(':');
if (colonIdx < 0) {
throw new GeneralException(
"Invalid Bitwarden access token format — missing ':' separator");
}
String identityPart = rawAccessToken.substring(0, colonIdx);
- String encKeyBase64 = rawAccessToken.substring(colonIdx + 1);
+ String sealingKeyBase64 = rawAccessToken.substring(colonIdx + 1);
String[] dotParts = identityPart.split("\\.");
if (dotParts.length != 3 || !"0".equals(dotParts[0])) {
@@ -176,21 +172,24 @@ private BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl,
String serviceAccountId = dotParts[1];
String clientSecretPart = dotParts[2];
- this.oauthClientId = "service-account." + serviceAccountId;
+ // client_id is the service account UUID directly (not "service-account.")
+ this.oauthClientId = serviceAccountId;
this.oauthClientSecret = clientSecretPart;
byte[] keyBytes;
try {
- keyBytes = Base64.getDecoder().decode(encKeyBase64);
+ keyBytes = Base64.getDecoder().decode(sealingKeyBase64);
} catch (IllegalArgumentException e) {
- throw new GeneralException("Invalid Bitwarden access token — enc key is not valid base64", e);
+ throw new GeneralException("Invalid Bitwarden access token — sealing key is not valid base64", e);
}
- if (keyBytes.length != 64) {
- throw new GeneralException("Invalid Bitwarden access token — enc key must be 64 bytes, got "
+ if (keyBytes.length != 16) {
+ throw new GeneralException("Invalid Bitwarden access token — sealing key must be 16 bytes, got "
+ keyBytes.length);
}
- this.encKey = Arrays.copyOfRange(keyBytes, 0, 32);
- this.macKey = Arrays.copyOfRange(keyBytes, 32, 64);
+ this.sealingKey = keyBytes;
+ // encKey and macKey are null until derived from the OAuth encrypted_payload
+ this.encKey = null;
+ this.macKey = null;
Debug.logInfo("BitwardenSecretsProvider: initialized api=" + apiUrl
+ " org=" + organizationId, MODULE);
@@ -257,6 +256,15 @@ private synchronized String ensureBearerToken() throws GeneralException {
long expiresIn = expiresNode != null ? expiresNode.asLong(3600L) : 3600L;
// Subtract 60 s to renew slightly before expiry
bearerTokenExpiresAt = System.currentTimeMillis() + (expiresIn - 60L) * 1000L;
+
+ // Derive and store the org enc/mac keys from the encrypted_payload (first call only)
+ if (encKey == null) {
+ JsonNode payloadNode = root.get("encrypted_payload");
+ if (payloadNode != null && !payloadNode.isNull()) {
+ deriveAndStoreOrgKey(payloadNode.asText());
+ }
+ }
+
return bearerToken;
} catch (GeneralException e) {
throw e;
@@ -265,6 +273,51 @@ private synchronized String ensureBearerToken() throws GeneralException {
}
}
+ /**
+ * Derives the org symmetric key from the sealing key and the OAuth encrypted_payload,
+ * then stores the result in {@link #encKey} and {@link #macKey}.
+ *
+ * Algorithm (matches Bitwarden SDK {@code derive_shareable_key}):
+ *
+ * - {@code PRK = HMAC-SHA256(key="bitwarden-accesstoken", msg=sealingKey)}
+ * - {@code key64 = HKDF-Expand(PRK, info="sm-access-token", length=64)}
+ * - Decrypt {@code encrypted_payload} (type-2 cipher) with key64.
+ * - Parse JSON {@code {"encryptionKey":""}} → 64-byte org key.
+ *
+ */
+ private void deriveAndStoreOrgKey(String encryptedPayload) throws GeneralException {
+ // Step 1 & 2: derive 64-byte key from the 16-byte sealing key
+ byte[] prk = hmacSha256("bitwarden-accesstoken".getBytes(StandardCharsets.UTF_8), sealingKey);
+ byte[] key64 = hkdfExpand(prk, "sm-access-token".getBytes(StandardCharsets.UTF_8), 64);
+ byte[] derivedEnc = Arrays.copyOfRange(key64, 0, 32);
+ byte[] derivedMac = Arrays.copyOfRange(key64, 32, 64);
+
+ // Step 3: decrypt the encrypted_payload to get the org key JSON
+ byte[] payloadBytes = decryptCipherBytes(encryptedPayload, derivedEnc, derivedMac);
+
+ // Step 4: parse {"encryptionKey": ""} and extract the 64-byte org key
+ try {
+ JsonNode root = JSON.readTree(payloadBytes);
+ JsonNode encKeyNode = root.get("encryptionKey");
+ if (encKeyNode == null || encKeyNode.isNull()) {
+ throw new GeneralException("Bitwarden encrypted_payload missing 'encryptionKey' field");
+ }
+ byte[] orgKey = Base64.getDecoder().decode(encKeyNode.asText());
+ if (orgKey.length != 64) {
+ throw new GeneralException(
+ "Bitwarden org key must be 64 bytes, got " + orgKey.length);
+ }
+ this.encKey = Arrays.copyOfRange(orgKey, 0, 32);
+ this.macKey = Arrays.copyOfRange(orgKey, 32, 64);
+ Debug.logInfo("BitwardenSecretsProvider: org encryption key derived successfully", MODULE);
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException(
+ "Failed to parse Bitwarden encrypted_payload JSON: " + e.getMessage(), e);
+ }
+ }
+
/** Lists the organization's secrets and returns the ID of the one matching secretName. */
private String findSecretId(String secretName, String bearer) throws GeneralException {
String listUrl = apiUrl + "/organizations/" + organizationId + "/secrets";
@@ -277,9 +330,9 @@ private String findSecretId(String secretName, String bearer) throws GeneralExce
try {
JsonNode root = JSON.readTree(responseJson);
- JsonNode data = root.get("data");
+ JsonNode data = root.get("secrets");
if (data == null || !data.isArray()) {
- throw new GeneralException("Bitwarden secrets list response missing 'data' array");
+ throw new GeneralException("Bitwarden secrets list response missing 'secrets' array");
}
for (JsonNode item : data) {
@@ -334,15 +387,27 @@ private String fetchAndDecryptValue(String secretId, String secretName, String b
}
/**
- * Decrypts a Bitwarden type-2 cipher string.
+ * Decrypts a Bitwarden type-2 cipher string using the instance's org enc/mac keys.
*
* Format: {@code 2.||}
- *
- * - HMAC-SHA256 is verified over {@code IV || ciphertext} using {@code macKey}.
- * - AES-256-CBC decryption uses {@code encKey} and the extracted IV.
- *
*/
String decrypt(String cipherString) throws GeneralException {
+ try {
+ return new String(decryptCipherBytes(cipherString, encKey, macKey), StandardCharsets.UTF_8);
+ } catch (GeneralException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new GeneralException("Bitwarden decryption failed: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Core AES-256-CBC + HMAC-SHA256 decryption for a Bitwarden type-2 cipher string.
+ * Accepts explicit enc/mac keys so it can be used both for the org key derivation
+ * (with the derived keys) and for secret decryption (with the org keys).
+ */
+ private static byte[] decryptCipherBytes(String cipherString, byte[] encKey, byte[] macKey)
+ throws GeneralException {
if (!cipherString.startsWith("2.")) {
throw new GeneralException(
"Unsupported Bitwarden cipher type — expected type 2 (AES-CBC-256-HMAC-SHA256)");
@@ -378,7 +443,7 @@ String decrypt(String cipherString) throws GeneralException {
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(encKey, "AES"),
new IvParameterSpec(iv));
- return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
+ return cipher.doFinal(ciphertext);
} catch (GeneralException e) {
throw e;
@@ -387,6 +452,46 @@ String decrypt(String cipherString) throws GeneralException {
}
}
+ /** HMAC-SHA256(key, data). */
+ private static byte[] hmacSha256(byte[] key, byte[] data) throws GeneralException {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return mac.doFinal(data);
+ } catch (Exception e) {
+ throw new GeneralException("HMAC-SHA256 failed: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * HKDF-Expand (RFC 5869) using HMAC-SHA256.
+ * Produces {@code length} bytes of output keying material from {@code prk} and {@code info}.
+ */
+ private static byte[] hkdfExpand(byte[] prk, byte[] info, int length) throws GeneralException {
+ try {
+ byte[] result = new byte[length];
+ byte[] prev = new byte[0];
+ int offset = 0;
+ int counter = 1;
+
+ while (offset < length) {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(prk, "HmacSHA256"));
+ mac.update(prev);
+ mac.update(info);
+ mac.update((byte) counter++);
+ prev = mac.doFinal();
+
+ int toCopy = Math.min(prev.length, length - offset);
+ System.arraycopy(prev, 0, result, offset, toCopy);
+ offset += toCopy;
+ }
+ return result;
+ } catch (Exception e) {
+ throw new GeneralException("HKDF-Expand failed: " + e.getMessage(), e);
+ }
+ }
+
private static BitwardenHttpClient buildHttpClient() {
int connectTimeout = parseSeconds(prop("bitwarden.connect.timeout.seconds", "5"));
int readTimeout = parseSeconds(prop("bitwarden.read.timeout.seconds", "10"));
From 9d1a6fbad92a13a28c5177bab2138475f9c9a633 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 10 Jun 2026 17:38:24 +0530
Subject: [PATCH 06/18] Added the fallback support in the code base. If remote
services of secret manager/vault is unavailable then it will check the
password(plain or encrypted one) in the passwords.properties file. And added
a fallback parameter in all the property files. And also used proper naming
convention in the hashicorp vault component.
---
.../config/aws-secrets-manager.properties | 8 ++++
.../awssecrets/AwsSecretsManagerProvider.java | 21 +++++++----
.../config/azure-keyvault.properties | 8 ++++
.../AzureKeyVaultSecretsProvider.java | 5 +++
.../config/bitwarden-secrets.properties | 8 ++++
.../bitwarden/BitwardenSecretsProvider.java | 5 +++
.../config/gcp-secret-manager.properties | 16 ++++++--
.../GcpSecretManagerSecretsProvider.java | 5 +++
.../config/hashicorp-vault-secrets.properties | 32 ++++++++++------
.../HashicorpVaultSecretsProvider.java | 37 +++++++++++--------
.../config/onepassword.properties | 8 ++++
.../OnePasswordSecretsProvider.java | 5 +++
12 files changed, 119 insertions(+), 39 deletions(-)
diff --git a/aws-secrets-provider/config/aws-secrets-manager.properties b/aws-secrets-provider/config/aws-secrets-manager.properties
index ba521ee71..2cd11d1ad 100644
--- a/aws-secrets-provider/config/aws-secrets-manager.properties
+++ b/aws-secrets-provider/config/aws-secrets-manager.properties
@@ -51,3 +51,11 @@ aws.secretsmanager.json.field=
# Optional endpoint override. Useful for local testing with LocalStack.
# Example: http://localhost:4566
aws.secretsmanager.endpoint.override=
+
+# If AWS Secrets Manager is unreachable (e.g. outage, network issue), fall back to
+# the value configured for the same key in framework/base/config/passwords.properties.
+# A warning is logged whenever this fallback is used. Values in passwords.properties
+# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword
+# Gradle task.
+# Default: true
+aws.secretsmanager.fallback.enabled=true
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
index 78b093032..046732c46 100644
--- a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
+++ b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
@@ -92,8 +92,8 @@ boolean isExpired() {
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public AwsSecretsManagerProvider() {
this(buildClient(), readTtlMs(),
- UtilProperties.getPropertyValue(CONFIG_RESOURCE, "aws.secretsmanager.secret.name.prefix", ""),
- UtilProperties.getPropertyValue(CONFIG_RESOURCE, "aws.secretsmanager.json.field", ""));
+ prop("aws.secretsmanager.secret.name.prefix", ""),
+ prop("aws.secretsmanager.json.field", ""));
}
/** Package-private constructor used by unit tests to inject a mock client. */
@@ -139,8 +139,17 @@ public void invalidateCache() {
Debug.logInfo("AwsSecretsManagerProvider: secret cache invalidated", MODULE);
}
+ @Override
+ public boolean isFallbackEnabled() {
+ return Boolean.parseBoolean(prop("aws.secretsmanager.fallback.enabled", "true"));
+ }
+
// -- private helpers --
+ private static String prop(String key, String defaultValue) {
+ return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
+ }
+
private String fetchFromAws(String secretName) throws GeneralException {
try {
GetSecretValueResponse response = client.getSecretValue(
@@ -177,9 +186,8 @@ private String extractJsonField(String json, String secretName) throws GeneralEx
}
private static SecretsManagerClient buildClient() {
- String region = UtilProperties.getPropertyValue(CONFIG_RESOURCE, "aws.secretsmanager.region", "");
- String endpointOverride = UtilProperties.getPropertyValue(CONFIG_RESOURCE,
- "aws.secretsmanager.endpoint.override", "");
+ String region = prop("aws.secretsmanager.region", "");
+ String endpointOverride = prop("aws.secretsmanager.endpoint.override", "");
SecretsManagerClientBuilder builder = SecretsManagerClient.builder()
.httpClient(UrlConnectionHttpClient.builder().build());
@@ -197,8 +205,7 @@ private static SecretsManagerClient buildClient() {
}
private static long readTtlMs() {
- String raw = UtilProperties.getPropertyValue(CONFIG_RESOURCE,
- "aws.secretsmanager.cache.ttl.seconds", "3600");
+ String raw = prop("aws.secretsmanager.cache.ttl.seconds", "3600");
try {
return Long.parseLong(raw.trim()) * 1000L;
} catch (NumberFormatException e) {
diff --git a/azure-keyvault-secrets-provider/config/azure-keyvault.properties b/azure-keyvault-secrets-provider/config/azure-keyvault.properties
index fd8f262a6..07ea14247 100644
--- a/azure-keyvault-secrets-provider/config/azure-keyvault.properties
+++ b/azure-keyvault-secrets-provider/config/azure-keyvault.properties
@@ -37,3 +37,11 @@ azure.secret.name.dot.replacement=-
# In-memory cache TTL in seconds (default: 3600 = 1 hour).
# Set to 0 to disable caching (fetches from Azure on every call).
azure.cache.ttl.seconds=3600
+
+# If Azure Key Vault is unreachable (e.g. outage, network issue), fall back to
+# the value configured for the same key in framework/base/config/passwords.properties.
+# A warning is logged whenever this fallback is used. Values in passwords.properties
+# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword
+# Gradle task.
+# Default: true
+azure.fallback.enabled=true
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
index 5d6c3c5ab..517eb7c39 100644
--- a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
+++ b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
@@ -138,6 +138,11 @@ public void invalidateCache() {
Debug.logInfo("AzureKeyVaultSecretsProvider: secret cache invalidated", MODULE);
}
+ @Override
+ public boolean isFallbackEnabled() {
+ return Boolean.parseBoolean(prop("azure.fallback.enabled", "true"));
+ }
+
// -- private helpers --
private static AzureKeyVaultReader readerFrom(SecretClient client) {
diff --git a/bitwarden-secrets-provider/config/bitwarden-secrets.properties b/bitwarden-secrets-provider/config/bitwarden-secrets.properties
index 6552fa9c3..99d838b13 100644
--- a/bitwarden-secrets-provider/config/bitwarden-secrets.properties
+++ b/bitwarden-secrets-provider/config/bitwarden-secrets.properties
@@ -57,3 +57,11 @@ bitwarden.cache.ttl.seconds=3600
# HTTP connect and read timeouts in seconds.
bitwarden.connect.timeout.seconds=5
bitwarden.read.timeout.seconds=10
+
+# If Bitwarden Secrets Manager is unreachable (e.g. outage, network issue), fall back
+# to the value configured for the same key in framework/base/config/passwords.properties.
+# A warning is logged whenever this fallback is used. Values in passwords.properties
+# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword
+# Gradle task.
+# Default: true
+bitwarden.fallback.enabled=true
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
index a0ea407be..395ffbd35 100644
--- a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
@@ -219,6 +219,11 @@ public void invalidateCache() {
Debug.logInfo("BitwardenSecretsProvider: secret cache invalidated", MODULE);
}
+ @Override
+ public boolean isFallbackEnabled() {
+ return Boolean.parseBoolean(prop("bitwarden.fallback.enabled", "true"));
+ }
+
// -- private helpers --
private String fetchFromBitwarden(String secretName) throws GeneralException {
diff --git a/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties b/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
index ccff5dd36..2c866838c 100644
--- a/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
+++ b/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
@@ -1,5 +1,5 @@
###############################################################################
-# GCP Secret Manager — SecretProvider configuration
+# GCP Secret Manager - SecretProvider configuration
#
# To activate this provider:
# 1. Set enabled="true" in gcp-secretmanager-secrets-provider/ofbiz-component.xml
@@ -11,12 +11,12 @@
gcp.project.id=
# Path to a service account JSON key file.
-# Leave empty to use Application Default Credentials (ADC) — recommended when
+# Leave empty to use Application Default Credentials (ADC) - recommended when
# running on GCP (GKE, GCE, Cloud Run, App Engine) or with gcloud auth set up.
gcp.credentials.file=
# Optional prefix prepended to every secret name before lookup.
-# Useful for environment namespacing, e.g. "prod/" makes key "db" → "prod/db".
+# Useful for environment namespacing, e.g. "prod/" makes key "db" -> "prod/db".
gcp.secret.name.prefix=
# GCP Secret Manager secret version to access (default: latest)
@@ -25,10 +25,18 @@ gcp.secret.version=latest
# GCP secret names may only contain letters, digits, hyphens and underscores.
# OFBiz keys like "jdbc-password.mysql-ofbiz" contain a dot, which is invalid.
# Set this to the replacement character (default: -) so the dot is substituted.
-# Example: "jdbc-password.mysql-ofbiz" → "jdbc-password-mysql-ofbiz"
+# Example: "jdbc-password.mysql-ofbiz" -> "jdbc-password-mysql-ofbiz"
# Set to empty to disable replacement (only do this if your keys have no dots).
gcp.secret.name.dot.replacement=-
# In-memory cache TTL in seconds (default: 3600 = 1 hour).
# Set to 0 to disable caching (fetches from GCP on every call).
gcp.cache.ttl.seconds=3600
+
+# If GCP Secret Manager is unreachable (e.g. outage, network issue), fall back to
+# the value configured for the same key in framework/base/config/passwords.properties.
+# A warning is logged whenever this fallback is used. Values in passwords.properties
+# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword
+# Gradle task.
+# Default: true
+gcp.fallback.enabled=true
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
index fefccf6cb..bc774f8cf 100644
--- a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
+++ b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
@@ -153,6 +153,11 @@ public void invalidateCache() {
Debug.logInfo("GcpSecretManagerSecretsProvider: secret cache invalidated", MODULE);
}
+ @Override
+ public boolean isFallbackEnabled() {
+ return Boolean.parseBoolean(prop("gcp.fallback.enabled", "true"));
+ }
+
// -- private helpers --
private static GcpSecretReader readerFrom(SecretManagerServiceClient client) {
diff --git a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
index 9829048e5..071409d9f 100644
--- a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
+++ b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
@@ -27,40 +27,48 @@
####
# Vault server address.
-vault.address=http://127.0.0.1:8200
+hashicorp.vault.address=http://127.0.0.1:8200
# Authentication method: token | approle
-vault.auth.method=token
+hashicorp.vault.auth.method=token
-# Token auth: set vault.token (leave empty when using AppRole).
+# Token auth: set hashicorp.vault.token (leave empty when using AppRole).
# Never commit real tokens — use an environment variable or a secrets manager
# to inject this value at deploy time.
-vault.token=
+hashicorp.vault.token=ofbizhashicorp
# AppRole auth: role_id is non-sensitive; secret_id should be injected at runtime.
-vault.approle.role_id=
-vault.approle.secret_id=
+hashicorp.vault.approle.role_id=
+hashicorp.vault.approle.secret_id=
# KV secrets engine mount path (the path you used when enabling the engine).
-vault.kv.mount=secret
+hashicorp.vault.kv.mount=secret
# KV engine version: 1 or 2 (Vault default is 2 for new mounts).
-vault.kv.version=2
+hashicorp.vault.kv.version=2
# Optional prefix prepended to every OFBiz secret key before the Vault lookup.
# Example: with prefix "myapp/prod/" the key "jdbc-password.ofbiz" becomes
# "secret/myapp/prod/jdbc-password.ofbiz" in Vault.
-vault.secret.name.prefix=
+hashicorp.vault.secret.name.prefix=
# If the secret in Vault holds multiple fields (e.g. {"username":"u","password":"p"}),
# set this to the field name that holds the actual secret value (e.g. "password").
# Leave empty only when the secret has exactly one field — its value is returned as-is.
-vault.field=password
+hashicorp.vault.field=password
# How long (in seconds) to cache a resolved secret value before re-fetching.
# Default: 3600 (1 hour). Set to 0 to disable caching.
-vault.cache.ttl.seconds=3600
+hashicorp.vault.cache.ttl.seconds=3600
# Whether to verify the Vault server's TLS certificate.
# Set to false only for local development — never in production.
-vault.ssl.verify=true
+hashicorp.vault.ssl.verify=true
+
+# If HashiCorp Vault is unreachable (e.g. outage, network issue), fall back to
+# the value configured for the same key in framework/base/config/passwords.properties.
+# A warning is logged whenever this fallback is used. Values in passwords.properties
+# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword
+# Gradle task.
+# Default: true
+hashicorp.vault.fallback.enabled=true
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
index 2b4ec549f..d112df05b 100644
--- a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
+++ b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
@@ -44,12 +44,12 @@
* in config, while the secret_id is injected at deploy time via an environment
* variable or a secrets bootstrap mechanism.
*
- * Both KV v1 and KV v2 engines are supported via the {@code vault.kv.version}
+ *
Both KV v1 and KV v2 engines are supported via the {@code hashicorp.vault.kv.version}
* property. The driver handles the {@code /data/} path rewrite for KV v2
* automatically when {@code engineVersion(2)} is configured.
*
* Resolved secret values are cached in memory for the TTL configured by
- * {@code vault.cache.ttl.seconds} (default 1 hour).
+ * {@code hashicorp.vault.cache.ttl.seconds} (default 1 hour).
*
* Configure via {@code plugins/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties}.
*/
@@ -84,9 +84,9 @@ boolean isExpired() {
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public HashicorpVaultSecretsProvider() {
this(readerFrom(buildVault()),
- prop("vault.kv.mount", "secret"),
- prop("vault.secret.name.prefix", ""),
- prop("vault.field", "password"),
+ prop("hashicorp.vault.kv.mount", "secret"),
+ prop("hashicorp.vault.secret.name.prefix", ""),
+ prop("hashicorp.vault.field", "password"),
readTtlMs());
}
@@ -124,6 +124,11 @@ public void invalidateCache() {
Debug.logInfo("HashicorpVaultSecretsProvider: secret cache invalidated", MODULE);
}
+ @Override
+ public boolean isFallbackEnabled() {
+ return Boolean.parseBoolean(prop("hashicorp.vault.fallback.enabled", "true"));
+ }
+
// -- private helpers --
private String readFromVault(String path) throws GeneralException {
@@ -152,7 +157,7 @@ private String readFromVault(String path) throws GeneralException {
}
throw new GeneralException("Secret at '" + path + "' has " + data.size()
- + " fields — set vault.field to select one");
+ + " fields — set hashicorp.vault.field to select one");
}
private static HashicorpVaultReader readerFrom(Vault vault) {
@@ -163,18 +168,18 @@ private static HashicorpVaultReader readerFrom(Vault vault) {
}
private static Vault buildVault() {
- String address = prop("vault.address", "http://127.0.0.1:8200");
- String authMethod = prop("vault.auth.method", "token");
- int kvVersion = parseKvVersion(prop("vault.kv.version", "2"));
- boolean sslVerify = Boolean.parseBoolean(prop("vault.ssl.verify", "true"));
+ String address = prop("hashicorp.vault.address", "http://127.0.0.1:8200");
+ String authMethod = prop("hashicorp.vault.auth.method", "token");
+ int kvVersion = parseKvVersion(prop("hashicorp.vault.kv.version", "2"));
+ boolean sslVerify = Boolean.parseBoolean(prop("hashicorp.vault.ssl.verify", "true"));
try {
SslConfig ssl = new SslConfig().verify(sslVerify).build();
String token;
if ("approle".equalsIgnoreCase(authMethod)) {
- String roleId = prop("vault.approle.role_id", "");
- String secretId = prop("vault.approle.secret_id", "");
+ String roleId = prop("hashicorp.vault.approle.role_id", "");
+ String secretId = prop("hashicorp.vault.approle.secret_id", "");
VaultConfig bootConfig = new VaultConfig()
.address(address)
.engineVersion(kvVersion)
@@ -185,7 +190,7 @@ private static Vault buildVault() {
.loginByAppRole(roleId, secretId)
.getAuthClientToken();
} else {
- token = prop("vault.token", "");
+ token = prop("hashicorp.vault.token", "");
}
VaultConfig config = new VaultConfig()
@@ -209,16 +214,16 @@ private static int parseKvVersion(String raw) {
int v = Integer.parseInt(raw.trim());
if (v == 1 || v == 2) return v;
} catch (NumberFormatException ignored) { }
- Debug.logWarning("Invalid vault.kv.version '" + raw + "', defaulting to 2", MODULE);
+ Debug.logWarning("Invalid hashicorp.vault.kv.version '" + raw + "', defaulting to 2", MODULE);
return 2;
}
private static long readTtlMs() {
- String raw = prop("vault.cache.ttl.seconds", "3600");
+ String raw = prop("hashicorp.vault.cache.ttl.seconds", "3600");
try {
return Long.parseLong(raw.trim()) * 1000L;
} catch (NumberFormatException e) {
- Debug.logWarning("Invalid vault.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
+ Debug.logWarning("Invalid hashicorp.vault.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
return 3_600_000L;
}
}
diff --git a/onepassword-secrets-provider/config/onepassword.properties b/onepassword-secrets-provider/config/onepassword.properties
index 6a46799df..8dc5fb5e7 100644
--- a/onepassword-secrets-provider/config/onepassword.properties
+++ b/onepassword-secrets-provider/config/onepassword.properties
@@ -54,3 +54,11 @@ op.cache.ttl.seconds=3600
# HTTP connect and read timeouts in seconds.
op.connect.timeout.seconds=5
op.read.timeout.seconds=10
+
+# If the 1Password Connect Server is unreachable (e.g. outage, network issue), fall
+# back to the value configured for the same key in framework/base/config/passwords.properties.
+# A warning is logged whenever this fallback is used. Values in passwords.properties
+# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword
+# Gradle task.
+# Default: true
+op.fallback.enabled=true
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
index 10fb4d522..2b4304785 100644
--- a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
@@ -135,6 +135,11 @@ public void invalidateCache() {
Debug.logInfo("OnePasswordSecretsProvider: secret cache invalidated", MODULE);
}
+ @Override
+ public boolean isFallbackEnabled() {
+ return Boolean.parseBoolean(prop("op.fallback.enabled", "true"));
+ }
+
// -- private helpers --
private String fetchFromConnect(String title) throws GeneralException {
From cfc004baacd51defff9bed72417c33edeb295b5f Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 10 Jun 2026 18:12:47 +0530
Subject: [PATCH 07/18] Fix AWS provider to catch SdkClientException for
fallback support Network-level AWS SDK errors (e.g. connection refused)
extend SdkException, not SecretsManagerException, so they previously escaped
as unhandled RuntimeExceptions and bypassed FallbackSecretProvider.
---
.../apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
index 046732c46..5c64e40f6 100644
--- a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
+++ b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
@@ -32,6 +32,7 @@
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
+import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
@@ -39,7 +40,6 @@
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
import software.amazon.awssdk.services.secretsmanager.model.ResourceNotFoundException;
-import software.amazon.awssdk.services.secretsmanager.model.SecretsManagerException;
/**
* {@link SecretProvider} implementation backed by AWS Secrets Manager.
@@ -162,7 +162,7 @@ private String fetchFromAws(String secretName) throws GeneralException {
return value;
} catch (ResourceNotFoundException e) {
throw new GeneralException("Secret '" + secretName + "' not found in AWS Secrets Manager", e);
- } catch (SecretsManagerException e) {
+ } catch (SdkException e) {
throw new GeneralException("AWS Secrets Manager error for '" + secretName + "': " + e.getMessage(), e);
}
}
From 280b73844134a8b0c66bf05f0c0cbb9a008bd4be Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 10 Jun 2026 18:55:10 +0530
Subject: [PATCH 08/18] Moving the few parameters from .aws/credentials folder
to the properties file. Those parameters were used by aws cli. Now we are
reading access key and secret access key from the properties file. Also
renamed the properties file parameters in onepassword-secrets-provider.
---
.../config/aws-secrets-manager.properties | 24 +++++++++++------
.../awssecrets/AwsSecretsManagerProvider.java | 17 ++++++++++++
.../config/onepassword.properties | 18 ++++++-------
.../OnePasswordSecretsProvider.java | 26 +++++++++----------
4 files changed, 55 insertions(+), 30 deletions(-)
diff --git a/aws-secrets-provider/config/aws-secrets-manager.properties b/aws-secrets-provider/config/aws-secrets-manager.properties
index 2cd11d1ad..15534c406 100644
--- a/aws-secrets-provider/config/aws-secrets-manager.properties
+++ b/aws-secrets-provider/config/aws-secrets-manager.properties
@@ -20,20 +20,28 @@
####
# AWS Secrets Manager — SecretProvider configuration
#
-# Authentication uses the AWS Default Credential Provider Chain:
-# 1. Environment variables: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
-# 2. Java system properties: aws.accessKeyId / aws.secretAccessKey
-# 3. AWS credential profiles file (~/.aws/credentials)
-# 4. EC2 / ECS instance profile / IAM role (recommended for production)
-# 5. AWS SSO
-#
-# No credentials should ever be stored in this file.
+# Authentication:
+# - If both aws.secretsmanager.access.key.id and aws.secretsmanager.secret.access.key
+# are set below, those static credentials are used.
+# - Otherwise, the AWS Default Credential Provider Chain is used:
+# 1. Environment variables: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
+# 2. Java system properties: aws.accessKeyId / aws.secretAccessKey
+# 3. AWS credential profiles file (~/.aws/credentials)
+# 4. EC2 / ECS instance profile / IAM role (recommended for production)
+# 5. AWS SSO
####
# AWS region for the Secrets Manager endpoint.
# Leave empty to let the SDK resolve from the environment (recommended for EC2/ECS).
aws.secretsmanager.region=us-east-1
+# Optional static credentials. Leave both empty to use the AWS Default Credential
+# Provider Chain (recommended for EC2/ECS/EKS via instance/task IAM roles).
+# aws.secretsmanager.secret.access.key may be encrypted with ENC(...), see
+# ConfigCryptoUtil and the encryptDbPassword Gradle task.
+aws.secretsmanager.access.key.id=
+aws.secretsmanager.secret.access.key=
+
# How long (in seconds) to cache a resolved secret value in memory before re-fetching.
# Default: 3600 (1 hour). Set to 0 to disable caching (not recommended in production).
aws.secretsmanager.cache.ttl.seconds=3600
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
index 5c64e40f6..cc93a815e 100644
--- a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
+++ b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
@@ -32,6 +32,8 @@
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
import software.amazon.awssdk.regions.Region;
@@ -188,6 +190,8 @@ private String extractJsonField(String json, String secretName) throws GeneralEx
private static SecretsManagerClient buildClient() {
String region = prop("aws.secretsmanager.region", "");
String endpointOverride = prop("aws.secretsmanager.endpoint.override", "");
+ String accessKeyId = prop("aws.secretsmanager.access.key.id", "");
+ String secretAccessKey = prop("aws.secretsmanager.secret.access.key", "");
SecretsManagerClientBuilder builder = SecretsManagerClient.builder()
.httpClient(UrlConnectionHttpClient.builder().build());
@@ -198,6 +202,19 @@ private static SecretsManagerClient buildClient() {
if (!endpointOverride.isEmpty()) {
builder.endpointOverride(URI.create(endpointOverride));
}
+ if (!accessKeyId.isEmpty() && !secretAccessKey.isEmpty()) {
+ try {
+ secretAccessKey = ConfigCryptoUtil.decryptIfEncrypted(
+ secretAccessKey, "aws.secretsmanager.secret.access.key");
+ } catch (GeneralException e) {
+ throw new IllegalStateException(
+ "Failed to decrypt aws.secretsmanager.secret.access.key: " + e.getMessage(), e);
+ }
+ builder.credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKeyId, secretAccessKey)));
+ Debug.logInfo("AwsSecretsManagerProvider: using static credentials from "
+ + "aws-secrets-manager.properties", MODULE);
+ }
Debug.logInfo("AwsSecretsManagerProvider: initialized"
+ (region.isEmpty() ? " (region from environment)" : " region=" + region), MODULE);
diff --git a/onepassword-secrets-provider/config/onepassword.properties b/onepassword-secrets-provider/config/onepassword.properties
index 8dc5fb5e7..89502d51f 100644
--- a/onepassword-secrets-provider/config/onepassword.properties
+++ b/onepassword-secrets-provider/config/onepassword.properties
@@ -28,32 +28,32 @@
####
# URL of your 1Password Connect Server (no trailing slash).
-op.connect.url=http://localhost:8080
+onepassword.connect.url=http://localhost:8080
# Connect Server access token.
# Inject at deploy time — do not commit a real token.
-op.connect.token=
+onepassword.connect.token=
# UUID of the vault to search for secrets.
# Find it in the 1Password app or via: GET /v1/vaults
-op.vault.id=
+onepassword.vault.id=
# The item field label whose value is returned as the secret.
# Default is "password" — matches the standard Login and Password item templates.
-op.field=password
+onepassword.field=password
# Optional prefix prepended to the OFBiz key when searching for the 1Password item title.
# Example: with prefix "myapp/" the key "jdbc-password.ofbiz" matches an item titled
# "myapp/jdbc-password.ofbiz" in 1Password.
-op.secret.name.prefix=
+onepassword.secret.name.prefix=
# How long (in seconds) to cache a resolved secret value before re-fetching.
# Default: 3600 (1 hour). Set to 0 to disable caching.
-op.cache.ttl.seconds=3600
+onepassword.cache.ttl.seconds=3600
# HTTP connect and read timeouts in seconds.
-op.connect.timeout.seconds=5
-op.read.timeout.seconds=10
+onepassword.connect.timeout.seconds=5
+onepassword.read.timeout.seconds=10
# If the 1Password Connect Server is unreachable (e.g. outage, network issue), fall
# back to the value configured for the same key in framework/base/config/passwords.properties.
@@ -61,4 +61,4 @@ op.read.timeout.seconds=10
# may be encrypted with ENC(...), see ConfigCryptoUtil and the encryptDbPassword
# Gradle task.
# Default: true
-op.fallback.enabled=true
+onepassword.fallback.enabled=true
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
index 2b4304785..acabcf9e6 100644
--- a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
@@ -46,13 +46,13 @@
* field (default {@code "password"}) is returned as the secret.
*
* Authentication uses a static Connect Server access token configured in
- * {@code op.connect.token}. Inject this token at deploy time — do not commit it.
+ * {@code onepassword.connect.token}. Inject this token at deploy time — do not commit it.
*
* API flow per lookup:
*
* - {@code GET /v1/vaults/{vaultId}/items?filter=title eq "{title}"} — find the item UUID
* - {@code GET /v1/vaults/{vaultId}/items/{itemId}} — fetch the full item with field values
- * - Scan {@code fields[]} for the entry whose {@code label} matches {@code op.field}
+ * - Scan {@code fields[]} for the entry whose {@code label} matches {@code onepassword.field}
*
*
* Configure via {@code plugins/onepassword-secrets-provider/config/onepassword.properties}.
@@ -91,11 +91,11 @@ boolean isExpired() {
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public OnePasswordSecretsProvider() {
this(buildHttpClient(),
- prop("op.connect.url", "http://localhost:8080").replaceAll("/+$", ""),
- prop("op.connect.token", ""),
- prop("op.vault.id", ""),
- prop("op.field", "password"),
- prop("op.secret.name.prefix", ""),
+ prop("onepassword.connect.url", "http://localhost:8080").replaceAll("/+$", ""),
+ prop("onepassword.connect.token", ""),
+ prop("onepassword.vault.id", ""),
+ prop("onepassword.field", "password"),
+ prop("onepassword.secret.name.prefix", ""),
readTtlMs());
}
@@ -137,7 +137,7 @@ public void invalidateCache() {
@Override
public boolean isFallbackEnabled() {
- return Boolean.parseBoolean(prop("op.fallback.enabled", "true"));
+ return Boolean.parseBoolean(prop("onepassword.fallback.enabled", "true"));
}
// -- private helpers --
@@ -223,14 +223,14 @@ private String fetchFieldValue(String itemId, String title) throws GeneralExcept
}
private static OnePasswordHttpClient buildHttpClient() {
- int connectTimeout = parseSeconds(prop("op.connect.timeout.seconds", "5"));
- int readTimeout = parseSeconds(prop("op.read.timeout.seconds", "10"));
+ int connectTimeout = parseSeconds(prop("onepassword.connect.timeout.seconds", "5"));
+ int readTimeout = parseSeconds(prop("onepassword.read.timeout.seconds", "10"));
HttpClient javaClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(connectTimeout))
.build();
- Debug.logInfo("OnePasswordSecretsProvider: initialized connect-url=" + prop("op.connect.url", ""),
+ Debug.logInfo("OnePasswordSecretsProvider: initialized connect-url=" + prop("onepassword.connect.url", ""),
MODULE);
return (url, bearerToken) -> {
@@ -267,11 +267,11 @@ private static int parseSeconds(String raw) {
}
private static long readTtlMs() {
- String raw = prop("op.cache.ttl.seconds", "3600");
+ String raw = prop("onepassword.cache.ttl.seconds", "3600");
try {
return Long.parseLong(raw.trim()) * 1000L;
} catch (NumberFormatException e) {
- Debug.logWarning("Invalid op.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
+ Debug.logWarning("Invalid onepassword.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
return 3_600_000L;
}
}
From 37702b76a002dba4c03dff030ab39fe955aa6094 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 11 Jun 2026 00:40:15 +0530
Subject: [PATCH 09/18] I mistakenly committed the token that I created for
testing purpose. Removing it.
---
.../config/hashicorp-vault-secrets.properties | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
index 071409d9f..702510a68 100644
--- a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
+++ b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
@@ -35,7 +35,7 @@ hashicorp.vault.auth.method=token
# Token auth: set hashicorp.vault.token (leave empty when using AppRole).
# Never commit real tokens — use an environment variable or a secrets manager
# to inject this value at deploy time.
-hashicorp.vault.token=ofbizhashicorp
+hashicorp.vault.token=
# AppRole auth: role_id is non-sensitive; secret_id should be injected at runtime.
hashicorp.vault.approle.role_id=
From 6374818eacc0fd66063ab53bc2d7c8ed192d8137 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 11 Jun 2026 10:05:34 +0530
Subject: [PATCH 10/18] BitwardenSecretsProviderTest.java mocked the
secrets-list API response with a "data" array key, but
BitwardenSecretsProvider.findSecretId() (and Bitwarden's real Secrets Manager
API) expects "secrets". Fixed the two mock JSON strings to use "secrets" - 4
previously-failing tests from Bitwarden now pass.
---
.../apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
index 70e6e0f7b..cf9493637 100644
--- a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
+++ b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
@@ -152,7 +152,7 @@ public void getSecret_throwsWhenSecretNotFound() throws Exception {
BitwardenHttpClient client = mock(BitwardenHttpClient.class);
when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE);
when(client.get(contains("/organizations/"), anyString()))
- .thenReturn("{\"data\":[]}"); // empty list
+ .thenReturn("{\"secrets\":[]}"); // empty list
provider(client).getSecret("missing");
}
@@ -190,7 +190,7 @@ private BitwardenHttpClient mockHttpForSecret(String secretKey, String secretVal
BitwardenHttpClient client = mock(BitwardenHttpClient.class);
when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE);
when(client.get(contains("/organizations/"), eq("test-bearer")))
- .thenReturn("{\"data\":[{\"id\":\"" + secretId + "\",\"key\":\""
+ .thenReturn("{\"secrets\":[{\"id\":\"" + secretId + "\",\"key\":\""
+ encryptedKey + "\"}]}");
when(client.get(contains("/secrets/" + secretId), eq("test-bearer")))
.thenReturn("{\"id\":\"" + secretId + "\",\"key\":\"" + encryptedKey
From 49c78f955993bc6c8a4cb54418697edf110a2c9f Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 11 Jun 2026 10:18:44 +0530
Subject: [PATCH 11/18] Renamed few component's method name so that they could
follow proper naming conventions and improve readability.
---
.../AzureKeyVaultSecretsProviderTest.java | 18 ++++++++---------
.../BitwardenSecretsProviderTest.java | 18 ++++++++---------
.../HashicorpVaultSecretsProviderTest.java | 20 +++++++++----------
.../OnePasswordSecretsProviderTest.java | 16 +++++++--------
4 files changed, 36 insertions(+), 36 deletions(-)
diff --git a/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java b/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
index 9f036f2dc..5991c5dc5 100644
--- a/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
+++ b/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
@@ -32,26 +32,26 @@ public class AzureKeyVaultSecretsProviderTest {
// -- Happy path --
@Test
- public void getSecret_returnsSecretValue() throws GeneralException {
+ public void getSecretReturnsSecretValue() throws GeneralException {
AzureKeyVaultReader reader = fixedReader("mykey", "s3cr3t");
assertEquals("s3cr3t", provider(reader, "", "-").getSecret("mykey"));
}
@Test
- public void getSecret_sanitizesDotInKey() throws GeneralException {
+ public void getSecretSanitizesDotInKey() throws GeneralException {
// OFBiz key "jdbc-password.mysql-ofbiz" → Azure name "jdbc-password-mysql-ofbiz"
AzureKeyVaultReader reader = fixedReader("jdbc-password-mysql-ofbiz", "dbpass");
assertEquals("dbpass", provider(reader, "", "-").getSecret("jdbc-password.mysql-ofbiz"));
}
@Test
- public void getSecret_appliesPrefixAfterSanitizing() throws GeneralException {
+ public void getSecretAppliesPrefixAfterSanitizing() throws GeneralException {
AzureKeyVaultReader reader = fixedReader("prod-jdbc-password-mydb", "prodpass");
assertEquals("prodpass", provider(reader, "prod-", "-").getSecret("jdbc-password.mydb"));
}
@Test
- public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException {
+ public void getSecretCachePreventsDuplicateReaderCall() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
AzureKeyVaultReader reader = secretName -> {
calls.incrementAndGet();
@@ -66,7 +66,7 @@ public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException
}
@Test
- public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
+ public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
AzureKeyVaultReader reader = secretName -> {
calls.incrementAndGet();
@@ -82,7 +82,7 @@ public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
}
@Test
- public void getSecret_expiredCacheTriggersRefetch() throws GeneralException {
+ public void getSecretExpiredCacheTriggersRefetch() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
AzureKeyVaultReader reader = secretName -> {
calls.incrementAndGet();
@@ -99,7 +99,7 @@ public void getSecret_expiredCacheTriggersRefetch() throws GeneralException {
}
@Test
- public void getSecret_dotReplacementDisabled_keepsDotsInName() throws GeneralException {
+ public void getSecretDotReplacementDisabledKeepsDotsInName() throws GeneralException {
AzureKeyVaultReader reader = fixedReader("my.key", "val");
assertEquals("val", provider(reader, "", "").getSecret("my.key"));
}
@@ -107,13 +107,13 @@ public void getSecret_dotReplacementDisabled_keepsDotsInName() throws GeneralExc
// -- Error handling --
@Test(expected = GeneralException.class)
- public void getSecret_throwsOnReaderException() throws GeneralException {
+ public void getSecretThrowsOnReaderException() throws GeneralException {
AzureKeyVaultReader reader = secretName -> { throw new RuntimeException("SecretNotFound"); };
provider(reader, "", "-").getSecret("missing");
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsOnEmptyValue() throws GeneralException {
+ public void getSecretThrowsOnEmptyValue() throws GeneralException {
AzureKeyVaultReader reader = secretName -> "";
provider(reader, "", "-").getSecret("mykey");
}
diff --git a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
index cf9493637..e84889d40 100644
--- a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
+++ b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
@@ -72,7 +72,7 @@ public static void generateTestKey() {
// -- Decryption unit tests (no HTTP, pure crypto) --
@Test
- public void decrypt_roundTrip() throws Exception {
+ public void decryptRoundTrip() throws Exception {
String plaintext = "my-secret-password";
String cipherString = encrypt(plaintext);
@@ -81,13 +81,13 @@ public void decrypt_roundTrip() throws Exception {
}
@Test(expected = GeneralException.class)
- public void decrypt_throwsOnWrongCipherType() throws Exception {
+ public void decryptThrowsOnWrongCipherType() throws Exception {
BitwardenSecretsProvider provider = provider(mock(BitwardenHttpClient.class));
provider.decrypt("1.abc|def|ghi"); // type 1, not 2
}
@Test(expected = GeneralException.class)
- public void decrypt_throwsOnTamperedCiphertext() throws Exception {
+ public void decryptThrowsOnTamperedCiphertext() throws Exception {
String cipherString = encrypt("secret");
// Flip a byte in the ciphertext part
String[] parts = cipherString.substring(2).split("\\|");
@@ -102,7 +102,7 @@ public void decrypt_throwsOnTamperedCiphertext() throws Exception {
// -- Full flow tests (with mock HTTP) --
@Test
- public void getSecret_fetchesAndDecryptsSecret() throws Exception {
+ public void getSecretFetchesAndDecryptsSecret() throws Exception {
String secretValue = "db-password-123";
BitwardenHttpClient client = mockHttpForSecret("jdbc-password.mydb", secretValue);
BitwardenSecretsProvider provider = provider(client);
@@ -111,7 +111,7 @@ public void getSecret_fetchesAndDecryptsSecret() throws Exception {
}
@Test
- public void getSecret_appliesSecretNamePrefix() throws Exception {
+ public void getSecretAppliesSecretNamePrefix() throws Exception {
String secretValue = "dbpass";
BitwardenHttpClient client = mockHttpForSecret("prod/jdbc-password.mydb", secretValue);
BitwardenSecretsProvider provider = providerWithPrefix(client, "prod/");
@@ -120,7 +120,7 @@ public void getSecret_appliesSecretNamePrefix() throws Exception {
}
@Test
- public void getSecret_cachePreventsSecondApiCall() throws Exception {
+ public void getSecretCachePreventsSecondApiCall() throws Exception {
BitwardenHttpClient client = mockHttpForSecret("mykey", "val");
BitwardenSecretsProvider provider = provider(client);
@@ -134,7 +134,7 @@ public void getSecret_cachePreventsSecondApiCall() throws Exception {
}
@Test
- public void invalidateCache_forcesRefetch() throws Exception {
+ public void invalidateCacheForcesRefetch() throws Exception {
BitwardenHttpClient client = mockHttpForSecret("mykey", "val");
BitwardenSecretsProvider provider = provider(client);
@@ -148,7 +148,7 @@ public void invalidateCache_forcesRefetch() throws Exception {
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsWhenSecretNotFound() throws Exception {
+ public void getSecretThrowsWhenSecretNotFound() throws Exception {
BitwardenHttpClient client = mock(BitwardenHttpClient.class);
when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE);
when(client.get(contains("/organizations/"), anyString()))
@@ -158,7 +158,7 @@ public void getSecret_throwsWhenSecretNotFound() throws Exception {
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsOnHttpError() throws Exception {
+ public void getSecretThrowsOnHttpError() throws Exception {
BitwardenHttpClient client = mock(BitwardenHttpClient.class);
when(client.post(anyString(), anyString())).thenThrow(new IOException("connection refused"));
diff --git a/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java b/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
index 22afb7cf9..5ca487bef 100644
--- a/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
+++ b/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
@@ -37,7 +37,7 @@ public class HashicorpVaultSecretsProviderTest {
// -- Happy path --
@Test
- public void getSecret_returnsSingleFieldSecret() throws GeneralException {
+ public void getSecretReturnsSingleFieldSecret() throws GeneralException {
HashicorpVaultReader reader = fixedReader("secret/mykey", singleEntry("value", "s3cr3t"));
HashicorpVaultSecretsProvider provider = provider(reader, "", "");
@@ -45,7 +45,7 @@ public void getSecret_returnsSingleFieldSecret() throws GeneralException {
}
@Test
- public void getSecret_extractsNamedField() throws GeneralException {
+ public void getSecretExtractsNamedField() throws GeneralException {
HashicorpVaultReader reader = fixedReader("secret/mykey", twoEntry("username", "user", "password", "s3cr3t"));
HashicorpVaultSecretsProvider provider = provider(reader, "", "password");
@@ -53,7 +53,7 @@ public void getSecret_extractsNamedField() throws GeneralException {
}
@Test
- public void getSecret_appliesKvMountAndPrefix() throws GeneralException {
+ public void getSecretAppliesKvMountAndPrefix() throws GeneralException {
HashicorpVaultReader reader = fixedReader("kv/prod/jdbc-password.ofbiz", singleEntry("value", "dbpass"));
HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "kv", "prod/", "", ONE_HOUR_MS);
@@ -61,7 +61,7 @@ public void getSecret_appliesKvMountAndPrefix() throws GeneralException {
}
@Test
- public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException {
+ public void getSecretCachePreventsDuplicateReaderCall() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
HashicorpVaultReader reader = path -> {
calls.incrementAndGet();
@@ -76,7 +76,7 @@ public void getSecret_cachePreventsDuplicateReaderCall() throws GeneralException
}
@Test
- public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
+ public void invalidateCacheForcesRefetchOnNextCall() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
HashicorpVaultReader reader = path -> {
calls.incrementAndGet();
@@ -92,7 +92,7 @@ public void invalidateCache_forcesRefetchOnNextCall() throws GeneralException {
}
@Test
- public void getSecret_expiredCacheEntryTriggersRefetch() throws GeneralException {
+ public void getSecretExpiredCacheEntryTriggersRefetch() throws GeneralException {
AtomicInteger calls = new AtomicInteger();
HashicorpVaultReader reader = path -> {
calls.incrementAndGet();
@@ -110,25 +110,25 @@ public void getSecret_expiredCacheEntryTriggersRefetch() throws GeneralException
// -- Error handling --
@Test(expected = GeneralException.class)
- public void getSecret_throwsWhenDataIsEmpty() throws GeneralException {
+ public void getSecretThrowsWhenDataIsEmpty() throws GeneralException {
HashicorpVaultReader reader = path -> Collections.emptyMap();
provider(reader, "", "").getSecret("missing");
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsWhenFieldMissing() throws GeneralException {
+ public void getSecretThrowsWhenFieldMissing() throws GeneralException {
HashicorpVaultReader reader = fixedReader("secret/mykey", singleEntry("username", "dbuser"));
provider(reader, "", "password").getSecret("mykey"); // "password" field not present
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsWhenMultiFieldAndNoFieldConfigured() throws GeneralException {
+ public void getSecretThrowsWhenMultiFieldAndNoFieldConfigured() throws GeneralException {
HashicorpVaultReader reader = fixedReader("secret/mykey", twoEntry("username", "u", "password", "p"));
provider(reader, "", "").getSecret("mykey"); // ambiguous — 2 fields, no field config
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsOnVaultException() throws GeneralException {
+ public void getSecretThrowsOnVaultException() throws GeneralException {
HashicorpVaultReader reader = path -> { throw new VaultException("connection refused", 503); };
provider(reader, "", "").getSecret("mykey");
}
diff --git a/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java b/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
index 1cb1dc519..950a16067 100644
--- a/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
+++ b/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
@@ -42,19 +42,19 @@ public class OnePasswordSecretsProviderTest {
// -- Happy path --
@Test
- public void getSecret_returnsFieldValue() throws Exception {
+ public void getSecretReturnsFieldValue() throws Exception {
OnePasswordHttpClient client = buildMockClient("jdbc-password.mydb", ITEM_ID, "s3cr3t");
assertEquals("s3cr3t", provider(client, "password", "").getSecret("jdbc-password.mydb"));
}
@Test
- public void getSecret_appliesSecretNamePrefix() throws Exception {
+ public void getSecretAppliesSecretNamePrefix() throws Exception {
OnePasswordHttpClient client = buildMockClient("prod/jdbc-password.mydb", ITEM_ID, "dbpass");
assertEquals("dbpass", provider(client, "password", "prod/").getSecret("jdbc-password.mydb"));
}
@Test
- public void getSecret_cachePreventsSecondHttpCall() throws Exception {
+ public void getSecretCachePreventsSecondHttpCall() throws Exception {
OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val");
OnePasswordSecretsProvider p = provider(client, "password", "");
@@ -66,7 +66,7 @@ public void getSecret_cachePreventsSecondHttpCall() throws Exception {
}
@Test
- public void invalidateCache_forcesRefetch() throws Exception {
+ public void invalidateCacheForcesRefetch() throws Exception {
OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val");
OnePasswordSecretsProvider p = provider(client, "password", "");
@@ -79,7 +79,7 @@ public void invalidateCache_forcesRefetch() throws Exception {
}
@Test
- public void getSecret_expiredCacheEntryTriggersRefetch() throws Exception {
+ public void getSecretExpiredCacheEntryTriggersRefetch() throws Exception {
OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "val");
OnePasswordSecretsProvider p = provider(client, "password", "", -1L); // instant expiry
@@ -92,7 +92,7 @@ public void getSecret_expiredCacheEntryTriggersRefetch() throws Exception {
// -- Error handling --
@Test(expected = GeneralException.class)
- public void getSecret_throwsWhenItemNotFound() throws Exception {
+ public void getSecretThrowsWhenItemNotFound() throws Exception {
OnePasswordHttpClient client = mock(OnePasswordHttpClient.class);
when(client.get(anyString(), anyString())).thenReturn("[]");
@@ -100,14 +100,14 @@ public void getSecret_throwsWhenItemNotFound() throws Exception {
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsWhenFieldNotPresent() throws Exception {
+ public void getSecretThrowsWhenFieldNotPresent() throws Exception {
// Item exists but has no "password" field — only "username"
OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "username", "dbuser", "password");
provider(client, "password", "").getSecret("mykey");
}
@Test(expected = GeneralException.class)
- public void getSecret_throwsOnHttpError() throws Exception {
+ public void getSecretThrowsOnHttpError() throws Exception {
OnePasswordHttpClient client = mock(OnePasswordHttpClient.class);
when(client.get(anyString(), anyString())).thenThrow(new IOException("connection refused"));
From 87c5c59de105152237b8b876615a575bdb3efaea Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Fri, 12 Jun 2026 09:48:50 +0530
Subject: [PATCH 12/18] =?UTF-8?q?Dependabot=20will=20now=20also=20scan=20e?=
=?UTF-8?q?ach=20of=20these=20plugins'=20build.gradle=20daily=20for=20mino?=
=?UTF-8?q?r/patch=20updates=20to=20their=20respective=20SDKs=20(AWS,=20Az?=
=?UTF-8?q?ure,=20Bitwarden,=20GCP,=20HashiCorp=20Vault,=201Password),=20o?=
=?UTF-8?q?pening=20a=20PR=20against=20trunk=20whenever=20one's=20availabl?=
=?UTF-8?q?e=20=E2=80=94=20major=20version=20bumps=20are=20still=20ignored?=
=?UTF-8?q?=20per=20the=20existing=20ignore=20rule,=20so=20those=20stay=20?=
=?UTF-8?q?manual.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/dependabot.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index fd8eed1f0..a8e0e6c4f 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -23,11 +23,17 @@ updates:
- package-ecosystem: "gradle"
directories:
- "/ai-agent-skills"
+ - "/aws-secrets-provider"
+ - "/azure-keyvault-secrets-provider"
- "/birt"
+ - "/bitwarden-secrets-provider"
- "/example"
- "/firstdatapaymentgateway"
+ - "/gcp-secretmanager-secrets-provider"
+ - "/hashicorp-vault-secrets-provider"
- "/ldap"
- "/lucene"
+ - "/onepassword-secrets-provider"
- "/pricat"
- "/rest-api"
schedule:
From 6e080d6af6f064cbcb2b77472f963f1a61cfad6e Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Wed, 17 Jun 2026 09:00:37 +0530
Subject: [PATCH 13/18] Fixing the following points:
Security: bound and format checks on identifiers and secret values in testSecretProviderConnection and storeEncryptedSecret; usage stats OUT attributes made optional.
Plugin lifecycle: AWS, Azure, Bitwarden, HashiCorp, 1Password, and GCP providers now implement close() for clean shutdown.
Code quality: de duplicated SAFE_IDENTIFIER pattern; clarified the key not found contract in SecretProvider.getSecret() Javadoc.
Testing: new unit tests for FileBasedSecretProvider, SecretProviderFactory, CSV and identifier validation, usage stats, and PBKDF2 iteration defaults.
UI/UX: Active Configuration panel, truncated usage report keys, input maxlength, and submit button Processing state.
Docs: added a PBKDF2 iteration migration guide.
---
.../awssecrets/AwsSecretsManagerProvider.java | 8 +++
.../AzureKeyVaultSecretsProvider.java | 6 ++
.../ofbiz/bitwarden/BitwardenHttpClient.java | 8 +++
.../bitwarden/BitwardenSecretsProvider.java | 18 ++++++
.../GcpSecretManagerSecretsProvider.java | 26 +++++++--
.../HashicorpVaultSecretsProvider.java | 7 +++
.../onepassword/OnePasswordHttpClient.java | 3 +
.../OnePasswordSecretsProvider.java | 56 ++++++++++++-------
8 files changed, 107 insertions(+), 25 deletions(-)
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
index cc93a815e..57c0733cc 100644
--- a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
+++ b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
@@ -136,11 +136,19 @@ public String getSecret(String key) throws GeneralException {
* for each key to re-fetch from AWS Secrets Manager. Useful after a manual
* secret rotation to pick up the new value without restarting OFBiz.
*/
+ @Override
public void invalidateCache() {
cache.clear();
Debug.logInfo("AwsSecretsManagerProvider: secret cache invalidated", MODULE);
}
+ /** Closes the underlying {@link SecretsManagerClient} and releases its connection pool. */
+ @Override
+ public void close() {
+ client.close();
+ Debug.logInfo("AwsSecretsManagerProvider: SecretsManagerClient closed", MODULE);
+ }
+
@Override
public boolean isFallbackEnabled() {
return Boolean.parseBoolean(prop("aws.secretsmanager.fallback.enabled", "true"));
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
index 517eb7c39..20cadec00 100644
--- a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
+++ b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
@@ -133,6 +133,7 @@ public String getSecret(String key) throws GeneralException {
* Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
* to re-fetch from Azure Key Vault. Useful after a secret rotation.
*/
+ @Override
public void invalidateCache() {
cache.clear();
Debug.logInfo("AzureKeyVaultSecretsProvider: secret cache invalidated", MODULE);
@@ -143,6 +144,11 @@ public boolean isFallbackEnabled() {
return Boolean.parseBoolean(prop("azure.fallback.enabled", "true"));
}
+ @Override
+ public void close() {
+ // Azure SecretClient does not implement Closeable; connection resources are managed by the Azure SDK.
+ }
+
// -- private helpers --
private static AzureKeyVaultReader readerFrom(SecretClient client) {
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
index 64260a519..622b7ae71 100644
--- a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
@@ -46,4 +46,12 @@ interface BitwardenHttpClient {
* @throws IOException if the request fails or the server returns a non-2xx status
*/
String post(String url, String formBody) throws IOException;
+
+ /**
+ * Releases any resources held by the underlying HTTP client (e.g. connection pools).
+ * Implementations backed by {@link java.net.http.HttpClient} should close it here;
+ * {@link java.net.http.HttpClient} became {@link AutoCloseable} in Java 21.
+ * The default no-op is safe for mock implementations used in tests.
+ */
+ default void close() { }
}
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
index 395ffbd35..48856c443 100644
--- a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
@@ -214,6 +214,7 @@ public String getSecret(String key) throws GeneralException {
* Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
* to re-fetch and re-decrypt from Bitwarden SM.
*/
+ @Override
public void invalidateCache() {
cache.clear();
Debug.logInfo("BitwardenSecretsProvider: secret cache invalidated", MODULE);
@@ -224,6 +225,11 @@ public boolean isFallbackEnabled() {
return Boolean.parseBoolean(prop("bitwarden.fallback.enabled", "true"));
}
+ @Override
+ public void close() {
+ httpClient.close();
+ }
+
// -- private helpers --
private String fetchFromBitwarden(String secretName) throws GeneralException {
@@ -506,6 +512,18 @@ private static BitwardenHttpClient buildHttpClient() {
.build();
return new BitwardenHttpClient() {
+ @Override
+ public void close() {
+ // HttpClient became AutoCloseable in Java 21; safe to ignore on earlier versions.
+ if (javaClient instanceof AutoCloseable) {
+ try {
+ ((AutoCloseable) javaClient).close();
+ } catch (Exception e) {
+ // Nothing meaningful to do during shutdown.
+ }
+ }
+ }
+
@Override
public String get(String url, String bearerToken) throws IOException {
HttpRequest request = HttpRequest.newBuilder()
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
index bc774f8cf..8b1ae7186 100644
--- a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
+++ b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
@@ -67,6 +67,7 @@ public final class GcpSecretManagerSecretsProvider implements SecretProvider {
private static final String CONFIG_RESOURCE = "gcp-secret-manager";
private final GcpSecretReader secretReader;
+ private final SecretManagerServiceClient gcpClient; // null when injected via test constructor
private final String projectId;
private final String secretNamePrefix;
private final String version;
@@ -95,18 +96,21 @@ boolean isExpired() {
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public GcpSecretManagerSecretsProvider() throws GeneralException {
- this(readerFrom(buildClient()),
- prop("gcp.project.id", ""),
- prop("gcp.secret.name.prefix", ""),
- prop("gcp.secret.version", "latest"),
- prop("gcp.secret.name.dot.replacement", "-"),
- readTtlMs());
+ SecretManagerServiceClient client = buildClient();
+ this.secretReader = readerFrom(client);
+ this.gcpClient = client;
+ this.projectId = prop("gcp.project.id", "");
+ this.secretNamePrefix = prop("gcp.secret.name.prefix", "");
+ this.version = prop("gcp.secret.version", "latest");
+ this.dotReplacement = prop("gcp.secret.name.dot.replacement", "-");
+ this.cacheTtlMs = readTtlMs();
}
/** Package-private constructor used by unit tests to inject a {@link GcpSecretReader} lambda. */
GcpSecretManagerSecretsProvider(GcpSecretReader secretReader, String projectId,
String secretNamePrefix, String version, String dotReplacement, long cacheTtlMs) {
this.secretReader = secretReader;
+ this.gcpClient = null;
this.projectId = projectId;
this.secretNamePrefix = secretNamePrefix;
this.version = version;
@@ -148,11 +152,21 @@ public String getSecret(String key) throws GeneralException {
* Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
* to re-fetch from GCP. Useful after a secret rotation.
*/
+ @Override
public void invalidateCache() {
cache.clear();
Debug.logInfo("GcpSecretManagerSecretsProvider: secret cache invalidated", MODULE);
}
+ /** Closes the underlying {@link SecretManagerServiceClient} and releases its gRPC channel. */
+ @Override
+ public void close() {
+ if (gcpClient != null) {
+ gcpClient.close();
+ Debug.logInfo("GcpSecretManagerSecretsProvider: SecretManagerServiceClient closed", MODULE);
+ }
+ }
+
@Override
public boolean isFallbackEnabled() {
return Boolean.parseBoolean(prop("gcp.fallback.enabled", "true"));
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
index d112df05b..6bdad72a6 100644
--- a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
+++ b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
@@ -119,6 +119,7 @@ public String getSecret(String key) throws GeneralException {
* Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
* for each key to re-fetch from Vault. Useful after a secret rotation.
*/
+ @Override
public void invalidateCache() {
cache.clear();
Debug.logInfo("HashicorpVaultSecretsProvider: secret cache invalidated", MODULE);
@@ -129,6 +130,12 @@ public boolean isFallbackEnabled() {
return Boolean.parseBoolean(prop("hashicorp.vault.fallback.enabled", "true"));
}
+ @Override
+ public void close() {
+ // The Vault Java driver does not expose a close() method on the Vault client;
+ // connection resources are managed internally by the library.
+ }
+
// -- private helpers --
private String readFromVault(String path) throws GeneralException {
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
index fc91c2d9d..a6d9b4e62 100644
--- a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
@@ -36,4 +36,7 @@ interface OnePasswordHttpClient {
* @throws IOException if the request fails or the server returns a non-2xx status
*/
String get(String url, String bearerToken) throws IOException;
+
+ /** Releases any underlying HTTP connection pool resources. No-op by default. */
+ default void close() { }
}
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
index acabcf9e6..ca56dbc58 100644
--- a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
@@ -130,6 +130,7 @@ public String getSecret(String key) throws GeneralException {
* Clears the in-memory cache, forcing the next {@link #getSecret(String)} call
* to re-fetch from the Connect Server. Useful after a secret update in 1Password.
*/
+ @Override
public void invalidateCache() {
cache.clear();
Debug.logInfo("OnePasswordSecretsProvider: secret cache invalidated", MODULE);
@@ -140,6 +141,11 @@ public boolean isFallbackEnabled() {
return Boolean.parseBoolean(prop("onepassword.fallback.enabled", "true"));
}
+ @Override
+ public void close() {
+ httpClient.close();
+ }
+
// -- private helpers --
private String fetchFromConnect(String title) throws GeneralException {
@@ -233,28 +239,40 @@ private static OnePasswordHttpClient buildHttpClient() {
Debug.logInfo("OnePasswordSecretsProvider: initialized connect-url=" + prop("onepassword.connect.url", ""),
MODULE);
- return (url, bearerToken) -> {
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
- .header("Authorization", "Bearer " + bearerToken)
- .header("Accept", "application/json")
- .timeout(Duration.ofSeconds(readTimeout))
- .GET()
- .build();
-
- HttpResponse response;
- try {
- response = javaClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new IOException("HTTP request interrupted", e);
+ return new OnePasswordHttpClient() {
+ @Override
+ public String get(String url, String bearerToken) throws IOException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("Authorization", "Bearer " + bearerToken)
+ .header("Accept", "application/json")
+ .timeout(Duration.ofSeconds(readTimeout))
+ .GET()
+ .build();
+
+ HttpResponse response;
+ try {
+ response = javaClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("HTTP request interrupted", e);
+ }
+
+ int status = response.statusCode();
+ if (status < 200 || status >= 300) {
+ throw new IOException("1Password Connect returned HTTP " + status + " for " + url);
+ }
+ return response.body();
}
- int status = response.statusCode();
- if (status < 200 || status >= 300) {
- throw new IOException("1Password Connect returned HTTP " + status + " for " + url);
+ @Override
+ public void close() {
+ if (javaClient instanceof AutoCloseable) {
+ try {
+ ((AutoCloseable) javaClient).close();
+ } catch (Exception ignored) { }
+ }
}
- return response.body();
};
}
From 44dbfcc0d8c0822581eb578c9d1bd5eb1d51eea0 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 18 Jun 2026 17:16:10 +0530
Subject: [PATCH 14/18] Enable Gradle dependency locking for the secret manager
plugins
Adds dependencyLocking + gradle.lockfile to all six secret-provider
plugins (AWS, Azure, Bitwarden, GCP, HashiCorp, 1Password) so
transitive dependency versions are pinned and reproducible.
Fixes SonarCloud warning about missing lock files.
---
aws-secrets-provider/build.gradle | 4 ++
aws-secrets-provider/gradle.lockfile | 47 ++++++++++++++++
azure-keyvault-secrets-provider/build.gradle | 4 ++
.../gradle.lockfile | 53 +++++++++++++++++++
bitwarden-secrets-provider/build.gradle | 4 ++
bitwarden-secrets-provider/gradle.lockfile | 4 ++
.../build.gradle | 4 ++
.../gradle.lockfile | 4 ++
hashicorp-vault-secrets-provider/build.gradle | 4 ++
.../gradle.lockfile | 5 ++
onepassword-secrets-provider/build.gradle | 4 ++
onepassword-secrets-provider/gradle.lockfile | 4 ++
12 files changed, 141 insertions(+)
create mode 100644 aws-secrets-provider/gradle.lockfile
create mode 100644 azure-keyvault-secrets-provider/gradle.lockfile
create mode 100644 bitwarden-secrets-provider/gradle.lockfile
create mode 100644 gcp-secretmanager-secrets-provider/gradle.lockfile
create mode 100644 hashicorp-vault-secrets-provider/gradle.lockfile
create mode 100644 onepassword-secrets-provider/gradle.lockfile
diff --git a/aws-secrets-provider/build.gradle b/aws-secrets-provider/build.gradle
index 48c5c3dd7..b553c0231 100644
--- a/aws-secrets-provider/build.gradle
+++ b/aws-secrets-provider/build.gradle
@@ -28,3 +28,7 @@ dependencies {
configurations.all {
exclude group: 'commons-logging', module: 'commons-logging'
}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/aws-secrets-provider/gradle.lockfile b/aws-secrets-provider/gradle.lockfile
new file mode 100644
index 000000000..d810d3d06
--- /dev/null
+++ b/aws-secrets-provider/gradle.lockfile
@@ -0,0 +1,47 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+commons-codec:commons-codec:1.17.1=pluginLibsCompile
+io.netty:netty-buffer:4.1.112.Final=pluginLibsCompile
+io.netty:netty-codec-http2:4.1.112.Final=pluginLibsCompile
+io.netty:netty-codec-http:4.1.112.Final=pluginLibsCompile
+io.netty:netty-codec:4.1.112.Final=pluginLibsCompile
+io.netty:netty-common:4.1.112.Final=pluginLibsCompile
+io.netty:netty-handler:4.1.112.Final=pluginLibsCompile
+io.netty:netty-resolver:4.1.112.Final=pluginLibsCompile
+io.netty:netty-transport-classes-epoll:4.1.112.Final=pluginLibsCompile
+io.netty:netty-transport-native-unix-common:4.1.112.Final=pluginLibsCompile
+io.netty:netty-transport:4.1.112.Final=pluginLibsCompile
+org.apache.httpcomponents:httpclient:4.5.13=pluginLibsCompile
+org.apache.httpcomponents:httpcore:4.4.16=pluginLibsCompile
+org.reactivestreams:reactive-streams:1.0.4=pluginLibsCompile
+org.slf4j:slf4j-api:1.7.36=pluginLibsCompile
+software.amazon.awssdk:annotations:2.26.31=pluginLibsCompile
+software.amazon.awssdk:apache-client:2.26.31=pluginLibsCompile
+software.amazon.awssdk:auth:2.26.31=pluginLibsCompile
+software.amazon.awssdk:aws-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:aws-json-protocol:2.26.31=pluginLibsCompile
+software.amazon.awssdk:checksums-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:checksums:2.26.31=pluginLibsCompile
+software.amazon.awssdk:endpoints-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth-aws-eventstream:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth-aws:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-client-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:identity-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:json-utils:2.26.31=pluginLibsCompile
+software.amazon.awssdk:metrics-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:netty-nio-client:2.26.31=pluginLibsCompile
+software.amazon.awssdk:profiles:2.26.31=pluginLibsCompile
+software.amazon.awssdk:protocol-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:regions:2.26.31=pluginLibsCompile
+software.amazon.awssdk:retries-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:retries:2.26.31=pluginLibsCompile
+software.amazon.awssdk:sdk-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:secretsmanager:2.26.31=pluginLibsCompile
+software.amazon.awssdk:third-party-jackson-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:url-connection-client:2.26.31=pluginLibsCompile
+software.amazon.awssdk:utils:2.26.31=pluginLibsCompile
+software.amazon.eventstream:eventstream:1.0.1=pluginLibsCompile
+empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/azure-keyvault-secrets-provider/build.gradle b/azure-keyvault-secrets-provider/build.gradle
index 9f36ae2e2..e7c97a3df 100644
--- a/azure-keyvault-secrets-provider/build.gradle
+++ b/azure-keyvault-secrets-provider/build.gradle
@@ -27,3 +27,7 @@ dependencies {
configurations.all {
exclude group: 'commons-logging', module: 'commons-logging'
}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/azure-keyvault-secrets-provider/gradle.lockfile b/azure-keyvault-secrets-provider/gradle.lockfile
new file mode 100644
index 000000000..f3543ed31
--- /dev/null
+++ b/azure-keyvault-secrets-provider/gradle.lockfile
@@ -0,0 +1,53 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.azure:azure-core-http-netty:1.14.2=pluginLibsCompile
+com.azure:azure-core:1.48.0=pluginLibsCompile
+com.azure:azure-identity:1.12.0=pluginLibsCompile
+com.azure:azure-json:1.1.0=pluginLibsCompile
+com.azure:azure-security-keyvault-secrets:4.8.0=pluginLibsCompile
+com.azure:azure-xml:1.0.0=pluginLibsCompile
+com.fasterxml.jackson.core:jackson-annotations:2.13.5=pluginLibsCompile
+com.fasterxml.jackson.core:jackson-core:2.13.5=pluginLibsCompile
+com.fasterxml.jackson.core:jackson-databind:2.13.5=pluginLibsCompile
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.5=pluginLibsCompile
+com.fasterxml.jackson:jackson-bom:2.13.5=pluginLibsCompile
+com.github.stephenc.jcip:jcip-annotations:1.0-1=pluginLibsCompile
+com.microsoft.azure:msal4j-persistence-extension:1.3.0=pluginLibsCompile
+com.microsoft.azure:msal4j:1.15.0=pluginLibsCompile
+com.nimbusds:content-type:2.3=pluginLibsCompile
+com.nimbusds:lang-tag:1.7=pluginLibsCompile
+com.nimbusds:nimbus-jose-jwt:9.37.3=pluginLibsCompile
+com.nimbusds:oauth2-oidc-sdk:11.9.1=pluginLibsCompile
+io.netty:netty-buffer:4.1.108.Final=pluginLibsCompile
+io.netty:netty-codec-dns:4.1.107.Final=pluginLibsCompile
+io.netty:netty-codec-http2:4.1.108.Final=pluginLibsCompile
+io.netty:netty-codec-http:4.1.108.Final=pluginLibsCompile
+io.netty:netty-codec-socks:4.1.108.Final=pluginLibsCompile
+io.netty:netty-codec:4.1.108.Final=pluginLibsCompile
+io.netty:netty-common:4.1.108.Final=pluginLibsCompile
+io.netty:netty-handler-proxy:4.1.108.Final=pluginLibsCompile
+io.netty:netty-handler:4.1.108.Final=pluginLibsCompile
+io.netty:netty-resolver-dns-classes-macos:4.1.107.Final=pluginLibsCompile
+io.netty:netty-resolver-dns-native-macos:4.1.107.Final=pluginLibsCompile
+io.netty:netty-resolver-dns:4.1.107.Final=pluginLibsCompile
+io.netty:netty-resolver:4.1.108.Final=pluginLibsCompile
+io.netty:netty-tcnative-boringssl-static:2.0.65.Final=pluginLibsCompile
+io.netty:netty-tcnative-classes:2.0.65.Final=pluginLibsCompile
+io.netty:netty-transport-classes-epoll:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport-classes-kqueue:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport-native-epoll:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport-native-kqueue:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport-native-unix-common:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport:4.1.108.Final=pluginLibsCompile
+io.projectreactor.netty:reactor-netty-core:1.0.43=pluginLibsCompile
+io.projectreactor.netty:reactor-netty-http:1.0.43=pluginLibsCompile
+io.projectreactor:reactor-core:3.4.36=pluginLibsCompile
+net.java.dev.jna:jna-platform:5.13.0=pluginLibsCompile
+net.java.dev.jna:jna:5.13.0=pluginLibsCompile
+net.minidev:accessors-smart:2.5.0=pluginLibsCompile
+net.minidev:json-smart:2.5.0=pluginLibsCompile
+org.ow2.asm:asm:9.3=pluginLibsCompile
+org.reactivestreams:reactive-streams:1.0.4=pluginLibsCompile
+org.slf4j:slf4j-api:1.7.36=pluginLibsCompile
+empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/bitwarden-secrets-provider/build.gradle b/bitwarden-secrets-provider/build.gradle
index be905aee6..462bdca67 100644
--- a/bitwarden-secrets-provider/build.gradle
+++ b/bitwarden-secrets-provider/build.gradle
@@ -25,3 +25,7 @@
// embedded in the machine account access token is used to decrypt secret names
// and values client-side.
dependencies {}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/bitwarden-secrets-provider/gradle.lockfile b/bitwarden-secrets-provider/gradle.lockfile
new file mode 100644
index 000000000..b9b85588a
--- /dev/null
+++ b/bitwarden-secrets-provider/gradle.lockfile
@@ -0,0 +1,4 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+empty=pluginLibsCompile,pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/gcp-secretmanager-secrets-provider/build.gradle b/gcp-secretmanager-secrets-provider/build.gradle
index 7980275c3..58d331ebe 100644
--- a/gcp-secretmanager-secrets-provider/build.gradle
+++ b/gcp-secretmanager-secrets-provider/build.gradle
@@ -26,3 +26,7 @@ dependencies {
configurations.all {
exclude group: 'commons-logging', module: 'commons-logging'
}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/gcp-secretmanager-secrets-provider/gradle.lockfile b/gcp-secretmanager-secrets-provider/gradle.lockfile
new file mode 100644
index 000000000..06dc73361
--- /dev/null
+++ b/gcp-secretmanager-secrets-provider/gradle.lockfile
@@ -0,0 +1,4 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/hashicorp-vault-secrets-provider/build.gradle b/hashicorp-vault-secrets-provider/build.gradle
index ae4c90ea9..16a5c53f8 100644
--- a/hashicorp-vault-secrets-provider/build.gradle
+++ b/hashicorp-vault-secrets-provider/build.gradle
@@ -26,3 +26,7 @@ dependencies {
configurations.all {
exclude group: 'commons-logging', module: 'commons-logging'
}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/hashicorp-vault-secrets-provider/gradle.lockfile b/hashicorp-vault-secrets-provider/gradle.lockfile
new file mode 100644
index 000000000..f306eb009
--- /dev/null
+++ b/hashicorp-vault-secrets-provider/gradle.lockfile
@@ -0,0 +1,5 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+io.github.jopenlibs:vault-java-driver:5.4.0=pluginLibsCompile
+empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/onepassword-secrets-provider/build.gradle b/onepassword-secrets-provider/build.gradle
index b2ca70694..70dc7debd 100644
--- a/onepassword-secrets-provider/build.gradle
+++ b/onepassword-secrets-provider/build.gradle
@@ -21,3 +21,7 @@
// Uses Java's built-in java.net.http.HttpClient (Java 11+) and OFBiz's
// bundled jackson-databind for JSON parsing.
dependencies {}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/onepassword-secrets-provider/gradle.lockfile b/onepassword-secrets-provider/gradle.lockfile
new file mode 100644
index 000000000..b9b85588a
--- /dev/null
+++ b/onepassword-secrets-provider/gradle.lockfile
@@ -0,0 +1,4 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+empty=pluginLibsCompile,pluginLibsCompileOnly,pluginLibsRuntime
From 668727621643e87dd3b082abc444903d61e82a89 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 25 Jun 2026 13:24:36 +0530
Subject: [PATCH 15/18] Add per-key alias overrides and shared helpers for
secret-provider plugins
Add key.alias.= support to all seven secret providers (AWS, Azure, GCP, HashiCorp Vault, Bitwarden, 1Password, EnvVar), so a secret key used in OFBiz code can be stored under a different name in the external vault. This is needed because some providers restrict characters (no dots/dashes, length limits), and we don't want to rename keys across the codebase to satisfy one backend.
Add the EnvVar provider as a hot-deploy plugin so secrets can be read from plain environment variables with no vault at all, useful for container/CI setups where an external tool already injects secrets as env vars before OFBiz starts.
Move the duplicated TTL cache and key-alias config loading out of each plugin into one shared helper, SecretProviderUtil, in framework/base, so every provider reuses the same cache and config-reading code instead of its own copy. Add unit tests for the new alias overrides and the shared helper, consolidated into a single test class and properties fixture.
---
.../config/aws-secrets-manager.properties | 10 ++
.../awssecrets/AwsSecretsManagerProvider.java | 58 +++-----
.../AwsSecretsManagerProviderTest.java | 43 ++++++
.../config/azure-keyvault.properties | 9 ++
.../AzureKeyVaultSecretsProvider.java | 58 ++++----
.../AzureKeyVaultSecretsProviderTest.java | 21 +++
.../config/bitwarden-secrets.properties | 8 ++
.../bitwarden/BitwardenSecretsProvider.java | 68 +++++-----
.../BitwardenSecretsProviderTest.java | 30 +++++
envvar-secrets-provider/build.gradle | 28 ++++
.../config/envvar-secrets.properties | 64 +++++++++
envvar-secrets-provider/gradle.lockfile | 4 +
envvar-secrets-provider/ofbiz-component.xml | 30 +++++
.../org/apache/ofbiz/envvar/EnvVarReader.java | 35 +++++
.../ofbiz/envvar/EnvVarSecretProvider.java | 120 +++++++++++++++++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../envvar/EnvVarSecretProviderTest.java | 126 ++++++++++++++++++
.../config/gcp-secret-manager.properties | 9 ++
.../GcpSecretManagerSecretsProvider.java | 64 ++++-----
.../GcpSecretManagerSecretsProviderTest.java | 25 ++++
.../config/hashicorp-vault-secrets.properties | 9 ++
.../HashicorpVaultSecretsProvider.java | 60 ++++-----
.../HashicorpVaultSecretsProviderTest.java | 22 +++
.../config/onepassword.properties | 8 ++
.../OnePasswordSecretsProvider.java | 58 ++++----
.../OnePasswordSecretsProviderTest.java | 38 ++++++
26 files changed, 802 insertions(+), 204 deletions(-)
create mode 100644 envvar-secrets-provider/build.gradle
create mode 100644 envvar-secrets-provider/config/envvar-secrets.properties
create mode 100644 envvar-secrets-provider/gradle.lockfile
create mode 100644 envvar-secrets-provider/ofbiz-component.xml
create mode 100644 envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
create mode 100644 envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
create mode 100644 envvar-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 envvar-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
diff --git a/aws-secrets-provider/config/aws-secrets-manager.properties b/aws-secrets-provider/config/aws-secrets-manager.properties
index 15534c406..7839eaf74 100644
--- a/aws-secrets-provider/config/aws-secrets-manager.properties
+++ b/aws-secrets-provider/config/aws-secrets-manager.properties
@@ -67,3 +67,13 @@ aws.secretsmanager.endpoint.override=
# Gradle task.
# Default: true
aws.secretsmanager.fallback.enabled=true
+
+# Optional per-key overrides for deployments where the OFBiz logical secret key
+# (e.g. "jdbc-password.mysql-ofbiz", documented in SECRET_KEYS.md) cannot be stored
+# verbatim as the AWS Secrets Manager secret name (e.g. naming restrictions in a
+# given AWS account/region setup). The logical key on the left never changes; only
+# the AWS-side secret name on the right needs to satisfy AWS's naming rules.
+# Format: key.alias.=
+# Example:
+# key.alias.jdbc-password.mysql-ofbiz=prod/ofbiz/mysql_db_password
+# Leave unset (the default) for keys that AWS will accept verbatim.
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
index 57c0733cc..853fa358f 100644
--- a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
+++ b/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
@@ -19,7 +19,7 @@
package org.apache.ofbiz.awssecrets;
import java.net.URI;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -28,6 +28,7 @@
import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.secret.SecretProviderUtil;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
@@ -70,51 +71,44 @@ public final class AwsSecretsManagerProvider implements SecretProvider {
private final long cacheTtlMs;
private final String secretNamePrefix;
private final String jsonField;
+ private final Map keyAliases;
- private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
-
- private static final class CacheEntry {
- private final String value;
- private final long expiresAt;
-
- CacheEntry(String value, long ttlMs) {
- this.value = value;
- this.expiresAt = System.currentTimeMillis() + ttlMs;
- }
-
- String getValue() {
- return value;
- }
-
- boolean isExpired() {
- return System.currentTimeMillis() >= expiresAt;
- }
- }
+ private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public AwsSecretsManagerProvider() {
- this(buildClient(), readTtlMs(),
+ this(buildClient(),
+ SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "aws.secretsmanager.cache.ttl.seconds", 3600, MODULE),
prop("aws.secretsmanager.secret.name.prefix", ""),
- prop("aws.secretsmanager.json.field", ""));
+ prop("aws.secretsmanager.json.field", ""),
+ SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE));
}
/** Package-private constructor used by unit tests to inject a mock client. */
AwsSecretsManagerProvider(SecretsManagerClient client, long cacheTtlMs,
String secretNamePrefix, String jsonField) {
+ this(client, cacheTtlMs, secretNamePrefix, jsonField, Map.of());
+ }
+
+ /** Package-private constructor used by unit tests to inject a mock client and key aliases. */
+ AwsSecretsManagerProvider(SecretsManagerClient client, long cacheTtlMs,
+ String secretNamePrefix, String jsonField, Map keyAliases) {
this.client = client;
this.cacheTtlMs = cacheTtlMs;
this.secretNamePrefix = secretNamePrefix;
this.jsonField = jsonField;
+ this.keyAliases = keyAliases;
}
@Override
public String getSecret(String key) throws GeneralException {
- CacheEntry cached = cache.get(key);
- if (cached != null && !cached.isExpired()) {
- return cached.getValue();
+ String cached = cache.get(key);
+ if (cached != null) {
+ return cached;
}
- String secretName = secretNamePrefix + key;
+ String physicalKey = keyAliases.getOrDefault(key, key);
+ String secretName = secretNamePrefix + physicalKey;
String secretValue = fetchFromAws(secretName);
if (!jsonField.isEmpty()) {
@@ -127,7 +121,7 @@ public String getSecret(String key) throws GeneralException {
secretValue = ConfigCryptoUtil.decryptIfEncrypted(secretValue, secretName);
- cache.put(key, new CacheEntry(secretValue, cacheTtlMs));
+ cache.put(key, secretValue, cacheTtlMs);
return secretValue;
}
@@ -229,14 +223,4 @@ private static SecretsManagerClient buildClient() {
return builder.build();
}
- private static long readTtlMs() {
- String raw = prop("aws.secretsmanager.cache.ttl.seconds", "3600");
- try {
- return Long.parseLong(raw.trim()) * 1000L;
- } catch (NumberFormatException e) {
- Debug.logWarning("Invalid aws.secretsmanager.cache.ttl.seconds '" + raw
- + "', defaulting to 3600s", MODULE);
- return 3_600_000L;
- }
- }
}
diff --git a/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java b/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
index fb6f64b69..d3604d1ea 100644
--- a/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
+++ b/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
@@ -25,6 +25,8 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import java.util.Map;
+
import org.apache.ofbiz.base.util.GeneralException;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
@@ -116,6 +118,47 @@ public void getSecretExpiredCacheEntryTriggersRefetch() throws GeneralException
verify(client, times(2)).getSecretValue(any(GetSecretValueRequest.class));
}
+ // -- Key aliasing --
+
+ @Test
+ public void getSecretUsesAliasedNameWhenConfigured() throws GeneralException {
+ SecretsManagerClient client = mock(SecretsManagerClient.class);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GetSecretValueRequest.class);
+ when(client.getSecretValue(captor.capture())).thenReturn(response("val"));
+
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "",
+ Map.of("jdbc-password.mysql-ofbiz", "prod/ofbiz/mysql_db_password"));
+ provider.getSecret("jdbc-password.mysql-ofbiz");
+
+ assertEquals("prod/ofbiz/mysql_db_password", captor.getValue().secretId());
+ }
+
+ @Test
+ public void getSecretCachesByLogicalKeyNotAliasedName() throws GeneralException {
+ SecretsManagerClient client = mock(SecretsManagerClient.class);
+ when(client.getSecretValue(any(GetSecretValueRequest.class))).thenReturn(response("val"));
+
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "",
+ Map.of("jdbc-password.mysql-ofbiz", "prod/ofbiz/mysql_db_password"));
+ provider.getSecret("jdbc-password.mysql-ofbiz");
+ provider.getSecret("jdbc-password.mysql-ofbiz"); // second call — should hit cache
+
+ verify(client, times(1)).getSecretValue(any(GetSecretValueRequest.class));
+ }
+
+ @Test
+ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws GeneralException {
+ SecretsManagerClient client = mock(SecretsManagerClient.class);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(GetSecretValueRequest.class);
+ when(client.getSecretValue(captor.capture())).thenReturn(response("val"));
+
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "",
+ Map.of("some.other.key", "some/other/alias"));
+ provider.getSecret("jdbc-password.mysql-ofbiz");
+
+ assertEquals("jdbc-password.mysql-ofbiz", captor.getValue().secretId());
+ }
+
// -- Error handling --
@Test(expected = GeneralException.class)
diff --git a/azure-keyvault-secrets-provider/config/azure-keyvault.properties b/azure-keyvault-secrets-provider/config/azure-keyvault.properties
index 07ea14247..4202494c0 100644
--- a/azure-keyvault-secrets-provider/config/azure-keyvault.properties
+++ b/azure-keyvault-secrets-provider/config/azure-keyvault.properties
@@ -34,6 +34,15 @@ azure.secret.name.prefix=
# Set to empty to disable replacement (only do this if your keys have no dots).
azure.secret.name.dot.replacement=-
+# Optional per-key overrides for keys where the dot-replacement convention above still
+# doesn't produce an acceptable Azure Key Vault secret name. The logical key on the left
+# never changes; only the Azure-side secret name on the right needs to satisfy Azure's
+# naming rules. Checked before dot-replacement is applied.
+# Format: key.alias.=
+# Example:
+# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password
+# Leave unset (the default) for keys the dot-replacement convention already handles.
+
# In-memory cache TTL in seconds (default: 3600 = 1 hour).
# Set to 0 to disable caching (fetches from Azure on every call).
azure.cache.ttl.seconds=3600
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
index 20cadec00..3d2315c51 100644
--- a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
+++ b/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
@@ -18,7 +18,7 @@
*******************************************************************************/
package org.apache.ofbiz.azurekeyvault;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
import com.azure.core.credential.TokenCredential;
import com.azure.identity.ClientSecretCredentialBuilder;
@@ -30,6 +30,7 @@
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.secret.SecretProviderUtil;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
@@ -54,6 +55,12 @@
* Set {@code azure.secret.name.dot.replacement=-} (the default) to replace dots with
* hyphens, so the key maps to {@code jdbc-password-mysql-ofbiz} in the vault.
*
+ * Per-key naming overrides
+ * If even the dot-replacement convention above doesn't produce an acceptable name for a
+ * specific key, set {@code key.alias.=} to store that one key
+ * under an explicit name instead. The alias is checked first; only keys with no alias entry
+ * fall through to the dot-replacement convention.
+ *
* Configure via {@code plugins/azure-keyvault-secrets-provider/config/azure-keyvault.properties}.
*/
@ThreadSafe
@@ -66,48 +73,45 @@ public final class AzureKeyVaultSecretsProvider implements SecretProvider {
private final String secretNamePrefix;
private final String dotReplacement;
private final long cacheTtlMs;
+ private final Map keyAliases;
- private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
-
- private static final class CacheEntry {
- final String value;
- final long expiresAt;
-
- CacheEntry(String value, long ttlMs) {
- this.value = value;
- this.expiresAt = System.currentTimeMillis() + ttlMs;
- }
-
- boolean isExpired() {
- return System.currentTimeMillis() >= expiresAt;
- }
- }
+ private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public AzureKeyVaultSecretsProvider() {
this(readerFrom(buildClient()),
prop("azure.secret.name.prefix", ""),
prop("azure.secret.name.dot.replacement", "-"),
- readTtlMs());
+ SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "azure.cache.ttl.seconds", 3600, MODULE),
+ SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE));
}
/** Package-private constructor used by unit tests to inject an {@link AzureKeyVaultReader} lambda. */
AzureKeyVaultSecretsProvider(AzureKeyVaultReader vaultReader, String secretNamePrefix,
String dotReplacement, long cacheTtlMs) {
+ this(vaultReader, secretNamePrefix, dotReplacement, cacheTtlMs, Map.of());
+ }
+
+ /** Package-private constructor used by unit tests to inject a vault reader and key aliases. */
+ AzureKeyVaultSecretsProvider(AzureKeyVaultReader vaultReader, String secretNamePrefix,
+ String dotReplacement, long cacheTtlMs, Map keyAliases) {
this.vaultReader = vaultReader;
this.secretNamePrefix = secretNamePrefix;
this.dotReplacement = dotReplacement;
this.cacheTtlMs = cacheTtlMs;
+ this.keyAliases = keyAliases;
}
@Override
public String getSecret(String key) throws GeneralException {
- CacheEntry cached = cache.get(key);
- if (cached != null && !cached.isExpired()) {
- return cached.value;
+ String cached = cache.get(key);
+ if (cached != null) {
+ return cached;
}
- String sanitizedKey = dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement);
+ String physicalKey = keyAliases.get(key);
+ String sanitizedKey = physicalKey != null ? physicalKey
+ : dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement);
String secretName = secretNamePrefix + sanitizedKey;
String value;
@@ -125,7 +129,7 @@ public String getSecret(String key) throws GeneralException {
value = ConfigCryptoUtil.decryptIfEncrypted(value, secretName);
- cache.put(key, new CacheEntry(value, cacheTtlMs));
+ cache.put(key, value, cacheTtlMs);
return value;
}
@@ -179,16 +183,6 @@ private static SecretClient buildClient() {
.buildClient();
}
- private static long readTtlMs() {
- String raw = prop("azure.cache.ttl.seconds", "3600");
- try {
- return Long.parseLong(raw.trim()) * 1000L;
- } catch (NumberFormatException e) {
- Debug.logWarning("Invalid azure.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
- return 3_600_000L;
- }
- }
-
private static String prop(String key, String defaultValue) {
return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
}
diff --git a/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java b/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
index 5991c5dc5..8386565c3 100644
--- a/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
+++ b/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
@@ -20,6 +20,7 @@
import static org.junit.Assert.assertEquals;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.ofbiz.base.util.GeneralException;
@@ -104,6 +105,26 @@ public void getSecretDotReplacementDisabledKeepsDotsInName() throws GeneralExcep
assertEquals("val", provider(reader, "", "").getSecret("my.key"));
}
+ // -- Key aliasing --
+
+ @Test
+ public void getSecretUsesAliasedNameInsteadOfDotReplacement() throws GeneralException {
+ AzureKeyVaultReader reader = fixedReader("prod-ofbiz-mysql-db-password", "dbpass");
+ AzureKeyVaultSecretsProvider p = new AzureKeyVaultSecretsProvider(reader, "", "-", ONE_HOUR_MS,
+ Map.of("jdbc-password.mysql-ofbiz", "prod-ofbiz-mysql-db-password"));
+
+ assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecretFallsBackToDotReplacementWhenNoAliasConfigured() throws GeneralException {
+ AzureKeyVaultReader reader = fixedReader("jdbc-password-mysql-ofbiz", "dbpass");
+ AzureKeyVaultSecretsProvider p = new AzureKeyVaultSecretsProvider(reader, "", "-", ONE_HOUR_MS,
+ Map.of("some.other.key", "some-other-alias"));
+
+ assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
// -- Error handling --
@Test(expected = GeneralException.class)
diff --git a/bitwarden-secrets-provider/config/bitwarden-secrets.properties b/bitwarden-secrets-provider/config/bitwarden-secrets.properties
index 99d838b13..b1e992820 100644
--- a/bitwarden-secrets-provider/config/bitwarden-secrets.properties
+++ b/bitwarden-secrets-provider/config/bitwarden-secrets.properties
@@ -50,6 +50,14 @@ bitwarden.organization.id=
# Bitwarden secret whose decrypted key is "myapp/jdbc-password.ofbiz".
bitwarden.secret.name.prefix=
+# Optional per-key overrides for deployments where a specific key needs to match a
+# Bitwarden secret title that differs from the OFBiz logical key. The logical key on the
+# left never changes; only the secret title on the right needs to match what's in Bitwarden.
+# Format: key.alias.=
+# Example:
+# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password
+# Leave unset (the default) for keys whose title matches the logical key verbatim.
+
# How long (in seconds) to cache resolved secret values before re-fetching.
# Default: 3600 (1 hour). Set to 0 to disable caching.
bitwarden.cache.ttl.seconds=3600
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
index 48856c443..4c2dbfdf3 100644
--- a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
+++ b/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
@@ -28,7 +28,7 @@
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
import javax.crypto.Cipher;
import javax.crypto.Mac;
@@ -41,6 +41,7 @@
import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.secret.SecretProviderUtil;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
@@ -68,6 +69,12 @@
* Secret keys (names) and values are returned from the API in encrypted form.
* This provider decrypts them using AES-256-CBC with HMAC-SHA256 integrity verification.
*
+ * Per-key naming overrides
+ * Secrets are looked up by matching the OFBiz key (with prefix) against the secret's
+ * title in Bitwarden. If a specific key needs a different title, set
+ * {@code key.alias.=}. The alias is checked first; keys with no
+ * alias entry use the logical key unchanged.
+ *
* Configure via {@code plugins/bitwarden-secrets-provider/config/bitwarden-secrets.properties}.
*/
@ThreadSafe
@@ -83,6 +90,7 @@ public final class BitwardenSecretsProvider implements SecretProvider {
private final String organizationId;
private final String secretNamePrefix;
private final long cacheTtlMs;
+ private final Map keyAliases;
// Parsed from the access token — never stored in config
private final String oauthClientId;
@@ -97,21 +105,7 @@ public final class BitwardenSecretsProvider implements SecretProvider {
private volatile String bearerToken = null;
private volatile long bearerTokenExpiresAt = 0L;
- private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
-
- private static final class CacheEntry {
- final String value;
- final long expiresAt;
-
- CacheEntry(String value, long ttlMs) {
- this.value = value;
- this.expiresAt = System.currentTimeMillis() + ttlMs;
- }
-
- boolean isExpired() {
- return System.currentTimeMillis() >= expiresAt;
- }
- }
+ private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public BitwardenSecretsProvider() throws GeneralException {
@@ -120,8 +114,9 @@ public BitwardenSecretsProvider() throws GeneralException {
prop("bitwarden.identity.url", "https://identity.bitwarden.com").replaceAll("/+$", ""),
prop("bitwarden.organization.id", ""),
prop("bitwarden.secret.name.prefix", ""),
- readTtlMs(),
- prop("bitwarden.access.token", ""));
+ SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "bitwarden.cache.ttl.seconds", 3600, MODULE),
+ prop("bitwarden.access.token", ""),
+ SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE));
}
/**
@@ -131,6 +126,18 @@ public BitwardenSecretsProvider() throws GeneralException {
BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl,
String organizationId, String secretNamePrefix, long cacheTtlMs,
String clientId, String clientSecret, byte[] encKey, byte[] macKey) {
+ this(httpClient, apiUrl, identityUrl, organizationId, secretNamePrefix, cacheTtlMs,
+ clientId, clientSecret, encKey, macKey, Map.of());
+ }
+
+ /**
+ * Package-private constructor used by unit tests to inject mock HTTP client, pre-parsed
+ * key material, and key aliases.
+ */
+ BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl,
+ String organizationId, String secretNamePrefix, long cacheTtlMs,
+ String clientId, String clientSecret, byte[] encKey, byte[] macKey,
+ Map keyAliases) {
this.httpClient = httpClient;
this.apiUrl = apiUrl;
this.identityUrl = identityUrl;
@@ -142,18 +149,20 @@ public BitwardenSecretsProvider() throws GeneralException {
this.sealingKey = null; // not needed when enc/mac keys are injected directly
this.encKey = encKey;
this.macKey = macKey;
+ this.keyAliases = keyAliases;
}
/** Full constructor called by the public no-arg constructor after parsing the token. */
private BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl, String identityUrl,
String organizationId, String secretNamePrefix, long cacheTtlMs,
- String rawAccessToken) throws GeneralException {
+ String rawAccessToken, Map keyAliases) throws GeneralException {
this.httpClient = httpClient;
this.apiUrl = apiUrl;
this.identityUrl = identityUrl;
this.organizationId = organizationId;
this.secretNamePrefix = secretNamePrefix;
this.cacheTtlMs = cacheTtlMs;
+ this.keyAliases = keyAliases;
// Parse: "0..:"
int colonIdx = rawAccessToken.lastIndexOf(':');
@@ -197,16 +206,17 @@ private BitwardenSecretsProvider(BitwardenHttpClient httpClient, String apiUrl,
@Override
public String getSecret(String key) throws GeneralException {
- CacheEntry cached = cache.get(key);
- if (cached != null && !cached.isExpired()) {
- return cached.value;
+ String cached = cache.get(key);
+ if (cached != null) {
+ return cached;
}
- String secretName = secretNamePrefix + key;
+ String physicalKey = keyAliases.getOrDefault(key, key);
+ String secretName = secretNamePrefix + physicalKey;
String value = fetchFromBitwarden(secretName);
value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
- cache.put(key, new CacheEntry(value, cacheTtlMs));
+ cache.put(key, value, cacheTtlMs);
return value;
}
@@ -575,17 +585,9 @@ private static int parseSeconds(String raw) {
}
}
- private static long readTtlMs() {
- String raw = prop("bitwarden.cache.ttl.seconds", "3600");
- try {
- return Long.parseLong(raw.trim()) * 1000L;
- } catch (NumberFormatException e) {
- Debug.logWarning("Invalid bitwarden.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
- return 3_600_000L;
- }
- }
private static String prop(String key, String defaultValue) {
return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
}
+
}
diff --git a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
index e84889d40..c670b21fd 100644
--- a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
+++ b/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
@@ -32,6 +32,7 @@
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
+import java.util.Map;
import javax.crypto.Cipher;
import javax.crypto.Mac;
@@ -147,6 +148,28 @@ public void invalidateCacheForcesRefetch() throws Exception {
verify(client, times(2)).get(contains("/secrets/"), anyString());
}
+ // -- Key aliasing --
+
+ @Test
+ public void getSecretUsesAliasedTitle() throws Exception {
+ String secretValue = "dbpass";
+ BitwardenHttpClient client = mockHttpForSecret("prod-ofbiz-mysql-db-password", secretValue);
+ BitwardenSecretsProvider provider = providerWithAliases(client, "",
+ Map.of("jdbc-password.mysql-ofbiz", "prod-ofbiz-mysql-db-password"));
+
+ assertEquals(secretValue, provider.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws Exception {
+ String secretValue = "dbpass";
+ BitwardenHttpClient client = mockHttpForSecret("jdbc-password.mysql-ofbiz", secretValue);
+ BitwardenSecretsProvider provider = providerWithAliases(client, "",
+ Map.of("some.other.key", "some-other-alias"));
+
+ assertEquals(secretValue, provider.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
@Test(expected = GeneralException.class)
public void getSecretThrowsWhenSecretNotFound() throws Exception {
BitwardenHttpClient client = mock(BitwardenHttpClient.class);
@@ -178,6 +201,13 @@ private BitwardenSecretsProvider providerWithPrefix(BitwardenHttpClient client,
"service-account.test-id", "test-secret", TEST_ENC_KEY, TEST_MAC_KEY);
}
+ private BitwardenSecretsProvider providerWithAliases(BitwardenHttpClient client, String prefix,
+ Map keyAliases) throws GeneralException {
+ return new BitwardenSecretsProvider(client, API_URL, IDENTITY_URL,
+ ORG_ID, prefix, ONE_HOUR_MS,
+ "service-account.test-id", "test-secret", TEST_ENC_KEY, TEST_MAC_KEY, keyAliases);
+ }
+
/**
* Builds a mock HTTP client that returns a bearer token on POST and serves a
* secrets list + secret detail using properly encrypted cipher strings.
diff --git a/envvar-secrets-provider/build.gradle b/envvar-secrets-provider/build.gradle
new file mode 100644
index 000000000..87a7c44c9
--- /dev/null
+++ b/envvar-secrets-provider/build.gradle
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+// No external SDK required. Reads secrets from this process's own
+// environment variables (java.lang.System.getenv), which is itself how
+// Kubernetes (External Secrets Operator, Doppler/Infisical wrappers, etc.)
+// or any other env-var-based secret injector hands a value to this JVM.
+dependencies {}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/envvar-secrets-provider/config/envvar-secrets.properties b/envvar-secrets-provider/config/envvar-secrets.properties
new file mode 100644
index 000000000..06dfc8437
--- /dev/null
+++ b/envvar-secrets-provider/config/envvar-secrets.properties
@@ -0,0 +1,64 @@
+###############################################################################
+# 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.
+###############################################################################
+
+####
+# Environment Variable SecretProvider configuration
+#
+# Resolves secrets from THIS PROCESS'S OWN environment variables — i.e.
+# whatever an external mechanism (Kubernetes External Secrets Operator
+# syncing a Secret into envFrom, a Doppler/Infisical "run" wrapper, plain
+# systemd EnvironmentFile, etc.) has already placed into the JVM's
+# environment before OFBiz started. This provider performs no network
+# calls of its own.
+#
+# Name transform (fixed by default — this is the documented convention so
+# any external tool can predict the env var name without reading OFBiz
+# source; see external-secret-injection-architecture.md):
+# 1. Take the OFBiz secret key, e.g. "jdbc-password.mysql-ofbiz"
+# 2. Uppercase it, replace every character that isn't A-Z or 0-9 with "_"
+# -> "JDBC_PASSWORD_MYSQL_OFBIZ"
+# 3. Prepend envvar.name.prefix
+# -> "OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ"
+#
+# NOTE: Only ONE SecretProvider plugin may be active at a time.
+# Deploy only the plugin that matches your environment.
+####
+
+# Prefix prepended to the transformed key when looking up the environment
+# variable. Change only if OFBIZ_ collides with something else in
+# your deployment's environment.
+envvar.name.prefix=OFBIZ_
+
+# Optional per-key overrides for deployments where a specific key needs to resolve to
+# an explicit environment variable name instead of the fixed transform above (e.g. a
+# naming collision with something else already in the process environment). The logical
+# key on the left never changes; the value on the right is used verbatim as the
+# environment variable name (the prefix above is NOT re-applied to it).
+# Format: key.alias.=
+# Example:
+# key.alias.jdbc-password.mysql-ofbiz=PROD_OFBIZ_MYSQL_DB_PASSWORD
+# Leave unset (the default) for keys the fixed transform already handles.
+
+# If the expected environment variable is not set, fall back to the value
+# configured for the same key in framework/base/config/passwords.properties.
+# A warning is logged whenever this fallback is used. Values in
+# passwords.properties may be encrypted with ENC(...), see ConfigCryptoUtil
+# and the generateDBPassword/generateEncryptedSecret Gradle tasks.
+# Default: true
+envvar.fallback.enabled=true
diff --git a/envvar-secrets-provider/gradle.lockfile b/envvar-secrets-provider/gradle.lockfile
new file mode 100644
index 000000000..b9b85588a
--- /dev/null
+++ b/envvar-secrets-provider/gradle.lockfile
@@ -0,0 +1,4 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+empty=pluginLibsCompile,pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/envvar-secrets-provider/ofbiz-component.xml b/envvar-secrets-provider/ofbiz-component.xml
new file mode 100644
index 000000000..f2004ab55
--- /dev/null
+++ b/envvar-secrets-provider/ofbiz-component.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java b/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
new file mode 100644
index 000000000..4cd00633f
--- /dev/null
+++ b/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.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.ofbiz.envvar;
+
+/**
+ * Thin seam over environment variable reads used by {@link EnvVarSecretProvider}.
+ * Kept package-private so tests can substitute a fake map without depending on
+ * real process environment variables (which a running JVM cannot set for itself).
+ */
+@FunctionalInterface
+interface EnvVarReader {
+ /**
+ * Retrieves the current value of the named environment variable.
+ *
+ * @param name the environment variable name
+ * @return the value, or {@code null} if not set
+ */
+ String getenv(String name);
+}
diff --git a/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java b/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
new file mode 100644
index 000000000..698788ee7
--- /dev/null
+++ b/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
@@ -0,0 +1,120 @@
+/*******************************************************************************
+ * 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.ofbiz.envvar;
+
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
+import org.apache.ofbiz.base.lang.ThreadSafe;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.secret.SecretProviderUtil;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.base.util.UtilValidate;
+
+/**
+ * {@link SecretProvider} implementation that resolves secrets from this
+ * process's own environment variables, instead of calling out to a remote
+ * vault SDK/API the way the other six provider plugins do.
+ *
+ * This is the generic counterpart to the file-mount mechanism the CSI
+ * Secrets Store Driver provides: whatever external tool already placed a
+ * value into this JVM's environment before OFBiz started — a Kubernetes
+ * External Secrets Operator sync into {@code envFrom}, a Doppler/Infisical
+ * {@code run} wrapper, a plain systemd {@code EnvironmentFile} — this
+ * provider can resolve, with no knowledge of which tool did the placing.
+ *
+ * Name transform
+ * Environment variable names are restricted to shell-identifier
+ * characters, unlike OFBiz secret keys (e.g. {@code jdbc-password.mysql-ofbiz}),
+ * so the key is deterministically transformed: uppercased, every character
+ * that isn't {@code A-Z} or {@code 0-9} replaced with {@code _}, then
+ * prefixed with {@code envvar.name.prefix} (default {@code OFBIZ_}):
+ *
+ * jdbc-password.mysql-ofbiz -> OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ
+ *
+ * This transform is fixed by default — see {@code config/envvar-secrets.properties}
+ * — so any external tool can predict the expected env var name without reading OFBiz
+ * source. A deployment that needs a specific key to resolve to a different env var name
+ * (e.g. a name collision with something else already in its environment) may set
+ * {@code key.alias.=} as an explicit per-key override; this is
+ * opt-in and does not change the default transform for every other key.
+ *
+ * No caching is implemented: unlike a remote API call, reading this
+ * process's own environment is already an in-memory lookup, and a JVM's
+ * environment variables are immutable for its lifetime, so there is
+ * nothing to gain from caching the result.
+ */
+@ThreadSafe
+public final class EnvVarSecretProvider implements SecretProvider {
+
+ private static final String MODULE = EnvVarSecretProvider.class.getName();
+ private static final String CONFIG_RESOURCE = "envvar-secrets";
+
+ private final EnvVarReader reader;
+ private final String namePrefix;
+ private final Map keyAliases;
+
+ /** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
+ public EnvVarSecretProvider() {
+ this(System::getenv, prop("envvar.name.prefix", "OFBIZ_"), SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE));
+ }
+
+ /** Package-private constructor used by unit tests to inject a fake environment. */
+ EnvVarSecretProvider(EnvVarReader reader, String namePrefix) {
+ this(reader, namePrefix, Map.of());
+ }
+
+ /** Package-private constructor used by unit tests to inject a fake environment and key aliases. */
+ EnvVarSecretProvider(EnvVarReader reader, String namePrefix, Map keyAliases) {
+ this.reader = reader;
+ this.namePrefix = namePrefix;
+ this.keyAliases = keyAliases;
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ String envName = keyAliases.getOrDefault(key, toEnvVarName(key));
+ String value = reader.getenv(envName);
+ if (UtilValidate.isEmpty(value)) {
+ throw new GeneralException("Secret key '" + key + "' not found — expected environment "
+ + "variable '" + envName + "' to be set");
+ }
+ return ConfigCryptoUtil.decryptIfEncrypted(value, key);
+ }
+
+ @Override
+ public boolean isFallbackEnabled() {
+ return Boolean.parseBoolean(prop("envvar.fallback.enabled", "true"));
+ }
+
+ /**
+ * Transforms an OFBiz secret key into the environment variable name this
+ * provider will look up. Package-private so it can be unit tested directly.
+ */
+ String toEnvVarName(String key) {
+ return namePrefix + key.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "_");
+ }
+
+ private static String prop(String name, String defaultValue) {
+ return UtilProperties.getPropertyValue(CONFIG_RESOURCE, name, defaultValue);
+ }
+
+}
diff --git a/envvar-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/envvar-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..b5bafe336
--- /dev/null
+++ b/envvar-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.envvar.EnvVarSecretProvider
diff --git a/envvar-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java b/envvar-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
new file mode 100644
index 000000000..5d2deafb0
--- /dev/null
+++ b/envvar-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
@@ -0,0 +1,126 @@
+/*******************************************************************************
+ * 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.ofbiz.envvar;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.ofbiz.base.util.GeneralException;
+import org.junit.Test;
+
+/**
+ * Tests for {@link EnvVarSecretProvider}.
+ *
+ * Uses a fake {@link EnvVarReader} backed by a plain {@link Map}, since a
+ * running JVM cannot set real environment variables for itself.
+ * {@code ENC(...)} decryption itself is covered by
+ * {@code ConfigCryptoUtilTest}, not re-tested here — consistent with how the
+ * other six provider plugins' test classes treat that integration.
+ */
+public class EnvVarSecretProviderTest {
+
+ private EnvVarSecretProvider provider(Map env) {
+ return providerWithPrefix(env, "OFBIZ_");
+ }
+
+ private EnvVarSecretProvider providerWithPrefix(Map env, String prefix) {
+ return new EnvVarSecretProvider(env::get, prefix);
+ }
+
+ private EnvVarSecretProvider providerWithAliases(Map env, Map keyAliases) {
+ return new EnvVarSecretProvider(env::get, "OFBIZ_", keyAliases);
+ }
+
+ @Test
+ public void getSecretReturnsValueFromEnvVar() throws Exception {
+ Map env = new HashMap<>();
+ env.put("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", "demo-secret-from-vault-poc");
+
+ assertEquals("demo-secret-from-vault-poc",
+ provider(env).getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void toEnvVarNameTransformsDotsAndDashesToUnderscoresAndUppercases() {
+ EnvVarSecretProvider provider = provider(new HashMap<>());
+
+ assertEquals("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ",
+ provider.toEnvVarName("jdbc-password.mysql-ofbiz"));
+ assertEquals("OFBIZ_PAYMENT_AUTHORIZEDOTNET_TRANKEY",
+ provider.toEnvVarName("payment.authorizedotnet.trankey"));
+ }
+
+ @Test
+ public void getSecretAppliesCustomPrefix() throws Exception {
+ Map env = new HashMap<>();
+ env.put("MYAPP_JDBC_PASSWORD_MYSQL_OFBIZ", "custom-prefixed-value");
+
+ assertEquals("custom-prefixed-value",
+ providerWithPrefix(env, "MYAPP_").getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecretPassesThroughPlainValueUnchanged() throws Exception {
+ // Confirms the ConfigCryptoUtil.decryptIfEncrypted() call is a no-op
+ // for ordinary (non-ENC(...)) values - it must not mangle them.
+ Map env = new HashMap<>();
+ env.put("OFBIZ_SOME_KEY", "plain-value-no-wrapping");
+
+ assertEquals("plain-value-no-wrapping", provider(env).getSecret("some.key"));
+ }
+
+ // -- Key aliasing --
+
+ @Test
+ public void getSecretUsesAliasedEnvVarName() throws Exception {
+ Map env = new HashMap<>();
+ env.put("PROD_OFBIZ_MYSQL_DB_PASSWORD", "dbpass");
+
+ EnvVarSecretProvider provider = providerWithAliases(env,
+ Map.of("jdbc-password.mysql-ofbiz", "PROD_OFBIZ_MYSQL_DB_PASSWORD"));
+
+ assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecretFallsBackToFixedTransformWhenNoAliasConfigured() throws Exception {
+ Map env = new HashMap<>();
+ env.put("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", "dbpass");
+
+ EnvVarSecretProvider provider = providerWithAliases(env,
+ Map.of("some.other.key", "SOME_OTHER_ALIAS"));
+
+ assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecretThrowsWhenEnvVarNotSet() throws Exception {
+ provider(new HashMap<>()).getSecret("jdbc-password.mysql-ofbiz");
+ }
+
+ @Test(expected = GeneralException.class)
+ public void getSecretThrowsWhenEnvVarEmpty() throws Exception {
+ Map env = new HashMap<>();
+ env.put("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", "");
+
+ provider(env).getSecret("jdbc-password.mysql-ofbiz");
+ }
+}
diff --git a/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties b/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
index 2c866838c..cc546c40a 100644
--- a/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
+++ b/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
@@ -29,6 +29,15 @@ gcp.secret.version=latest
# Set to empty to disable replacement (only do this if your keys have no dots).
gcp.secret.name.dot.replacement=-
+# Optional per-key overrides for keys where the dot-replacement convention above still
+# doesn't produce an acceptable GCP Secret Manager secret name. The logical key on the
+# left never changes; only the GCP-side secret name on the right needs to satisfy GCP's
+# naming rules. Checked before dot-replacement is applied.
+# Format: key.alias.=
+# Example:
+# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password
+# Leave unset (the default) for keys the dot-replacement convention already handles.
+
# In-memory cache TTL in seconds (default: 3600 = 1 hour).
# Set to 0 to disable caching (fetches from GCP on every call).
gcp.cache.ttl.seconds=3600
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
index 8b1ae7186..125ff5869 100644
--- a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
+++ b/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
@@ -20,7 +20,7 @@
import java.io.FileInputStream;
import java.io.IOException;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
import com.google.api.gax.core.FixedCredentialsProvider;
import com.google.auth.oauth2.GoogleCredentials;
@@ -31,6 +31,7 @@
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.secret.SecretProviderUtil;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
@@ -58,6 +59,12 @@
* Built as:
* {@code projects/{projectId}/secrets/{prefix}{sanitizedKey}/versions/{version}}
*
+ * Per-key naming overrides
+ * If even the dot-replacement convention above doesn't produce an acceptable name for a
+ * specific key, set {@code key.alias.=} to store that one key
+ * under an explicit name instead. The alias is checked first; only keys with no alias entry
+ * fall through to the dot-replacement convention.
+ *
* Configure via {@code plugins/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties}.
*/
@ThreadSafe
@@ -73,26 +80,9 @@ public final class GcpSecretManagerSecretsProvider implements SecretProvider {
private final String version;
private final String dotReplacement;
private final long cacheTtlMs;
+ private final Map keyAliases;
- private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
-
- private static final class CacheEntry {
- private final String value;
- private final long expiresAt;
-
- CacheEntry(String value, long ttlMs) {
- this.value = value;
- this.expiresAt = System.currentTimeMillis() + ttlMs;
- }
-
- String getValue() {
- return value;
- }
-
- boolean isExpired() {
- return System.currentTimeMillis() >= expiresAt;
- }
- }
+ private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public GcpSecretManagerSecretsProvider() throws GeneralException {
@@ -103,12 +93,20 @@ public GcpSecretManagerSecretsProvider() throws GeneralException {
this.secretNamePrefix = prop("gcp.secret.name.prefix", "");
this.version = prop("gcp.secret.version", "latest");
this.dotReplacement = prop("gcp.secret.name.dot.replacement", "-");
- this.cacheTtlMs = readTtlMs();
+ this.cacheTtlMs = SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "gcp.cache.ttl.seconds", 3600, MODULE);
+ this.keyAliases = SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE);
}
/** Package-private constructor used by unit tests to inject a {@link GcpSecretReader} lambda. */
GcpSecretManagerSecretsProvider(GcpSecretReader secretReader, String projectId,
String secretNamePrefix, String version, String dotReplacement, long cacheTtlMs) {
+ this(secretReader, projectId, secretNamePrefix, version, dotReplacement, cacheTtlMs, Map.of());
+ }
+
+ /** Package-private constructor used by unit tests to inject a reader and key aliases. */
+ GcpSecretManagerSecretsProvider(GcpSecretReader secretReader, String projectId,
+ String secretNamePrefix, String version, String dotReplacement, long cacheTtlMs,
+ Map keyAliases) {
this.secretReader = secretReader;
this.gcpClient = null;
this.projectId = projectId;
@@ -116,16 +114,19 @@ public GcpSecretManagerSecretsProvider() throws GeneralException {
this.version = version;
this.dotReplacement = dotReplacement;
this.cacheTtlMs = cacheTtlMs;
+ this.keyAliases = keyAliases;
}
@Override
public String getSecret(String key) throws GeneralException {
- CacheEntry cached = cache.get(key);
- if (cached != null && !cached.isExpired()) {
- return cached.getValue();
+ String cached = cache.get(key);
+ if (cached != null) {
+ return cached;
}
- String sanitizedKey = dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement);
+ String physicalKey = keyAliases.get(key);
+ String sanitizedKey = physicalKey != null ? physicalKey
+ : dotReplacement.isEmpty() ? key : key.replace(".", dotReplacement);
String secretName = secretNamePrefix + sanitizedKey;
String resourceName = "projects/" + projectId + "/secrets/" + secretName + "/versions/" + version;
@@ -144,7 +145,7 @@ public String getSecret(String key) throws GeneralException {
value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
- cache.put(key, new CacheEntry(value, cacheTtlMs));
+ cache.put(key, value, cacheTtlMs);
return value;
}
@@ -213,17 +214,8 @@ private static SecretManagerServiceClient buildClient() throws GeneralException
}
}
- private static long readTtlMs() {
- String raw = prop("gcp.cache.ttl.seconds", "3600");
- try {
- return Long.parseLong(raw.trim()) * 1000L;
- } catch (NumberFormatException e) {
- Debug.logWarning("Invalid gcp.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
- return 3_600_000L;
- }
- }
-
private static String prop(String key, String defaultValue) {
return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
}
+
}
diff --git a/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java b/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
index a33f17e10..86eaec9e9 100644
--- a/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
+++ b/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
@@ -20,6 +20,7 @@
import static org.junit.Assert.assertEquals;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.ofbiz.base.util.GeneralException;
@@ -119,6 +120,30 @@ public void getSecretDotReplacementDisabledKeepsDotsInName() throws GeneralExcep
assertEquals("val", provider(reader, "", "").getSecret("my.key"));
}
+ // -- Key aliasing --
+
+ @Test
+ public void getSecretUsesAliasedNameInsteadOfDotReplacement() throws GeneralException {
+ GcpSecretReader reader = fixedReader(
+ "projects/my-project/secrets/prod-ofbiz-mysql-db-password/versions/latest", "dbpass");
+ GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider(
+ reader, PROJECT, "", "latest", "-", ONE_HOUR_MS,
+ Map.of("jdbc-password.mysql-ofbiz", "prod-ofbiz-mysql-db-password"));
+
+ assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecretFallsBackToDotReplacementWhenNoAliasConfigured() throws GeneralException {
+ GcpSecretReader reader = fixedReader(
+ "projects/my-project/secrets/jdbc-password-mysql-ofbiz/versions/latest", "dbpass");
+ GcpSecretManagerSecretsProvider p = new GcpSecretManagerSecretsProvider(
+ reader, PROJECT, "", "latest", "-", ONE_HOUR_MS,
+ Map.of("some.other.key", "some-other-alias"));
+
+ assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
// -- Error handling --
@Test(expected = GeneralException.class)
diff --git a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
index 702510a68..3d7641053 100644
--- a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
+++ b/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
@@ -57,6 +57,15 @@ hashicorp.vault.secret.name.prefix=
# Leave empty only when the secret has exactly one field — its value is returned as-is.
hashicorp.vault.field=password
+# Optional per-key overrides for deployments where a specific mount/policy requires a
+# different path segment than the OFBiz logical key. The logical key on the left never
+# changes; only the Vault-side path segment on the right needs to satisfy that mount's
+# naming rules.
+# Format: key.alias.=
+# Example:
+# key.alias.jdbc-password.mysql-ofbiz=prod/ofbiz/mysql_db_password
+# Leave unset (the default) for keys Vault will accept verbatim.
+
# How long (in seconds) to cache a resolved secret value before re-fetching.
# Default: 3600 (1 hour). Set to 0 to disable caching.
hashicorp.vault.cache.ttl.seconds=3600
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
index 6bdad72a6..5e2c398e7 100644
--- a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
+++ b/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
@@ -18,10 +18,8 @@
*******************************************************************************/
package org.apache.ofbiz.hashicorpvault;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
import java.util.Collections;
+import java.util.Map;
import io.github.jopenlibs.vault.SslConfig;
import io.github.jopenlibs.vault.Vault;
@@ -32,6 +30,7 @@
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.secret.SecretProviderUtil;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
@@ -51,6 +50,12 @@
* Resolved secret values are cached in memory for the TTL configured by
* {@code hashicorp.vault.cache.ttl.seconds} (default 1 hour).
*
+ * Per-key naming overrides
+ * Vault KV paths generally accept the dot-separated OFBiz key verbatim, but if a specific
+ * mount or policy requires a different path segment, set
+ * {@code key.alias.=} to override that one key. The alias is
+ * checked first; keys with no alias entry use the logical key unchanged.
+ *
* Configure via {@code plugins/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties}.
*/
@ThreadSafe
@@ -64,22 +69,9 @@ public final class HashicorpVaultSecretsProvider implements SecretProvider {
private final String secretNamePrefix;
private final String field;
private final long cacheTtlMs;
+ private final Map keyAliases;
- private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
-
- private static final class CacheEntry {
- final String value;
- final long expiresAt;
-
- CacheEntry(String value, long ttlMs) {
- this.value = value;
- this.expiresAt = System.currentTimeMillis() + ttlMs;
- }
-
- boolean isExpired() {
- return System.currentTimeMillis() >= expiresAt;
- }
- }
+ private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public HashicorpVaultSecretsProvider() {
@@ -87,31 +79,40 @@ public HashicorpVaultSecretsProvider() {
prop("hashicorp.vault.kv.mount", "secret"),
prop("hashicorp.vault.secret.name.prefix", ""),
prop("hashicorp.vault.field", "password"),
- readTtlMs());
+ SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "hashicorp.vault.cache.ttl.seconds", 3600, MODULE),
+ SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE));
}
/** Package-private constructor used by unit tests to inject a {@link HashicorpVaultReader} lambda. */
HashicorpVaultSecretsProvider(HashicorpVaultReader vaultReader, String kvMount, String secretNamePrefix,
String field, long cacheTtlMs) {
+ this(vaultReader, kvMount, secretNamePrefix, field, cacheTtlMs, Map.of());
+ }
+
+ /** Package-private constructor used by unit tests to inject a vault reader and key aliases. */
+ HashicorpVaultSecretsProvider(HashicorpVaultReader vaultReader, String kvMount, String secretNamePrefix,
+ String field, long cacheTtlMs, Map keyAliases) {
this.vaultReader = vaultReader;
this.kvMount = kvMount;
this.secretNamePrefix = secretNamePrefix;
this.field = field;
this.cacheTtlMs = cacheTtlMs;
+ this.keyAliases = keyAliases;
}
@Override
public String getSecret(String key) throws GeneralException {
- CacheEntry cached = cache.get(key);
- if (cached != null && !cached.isExpired()) {
- return cached.value;
+ String cached = cache.get(key);
+ if (cached != null) {
+ return cached;
}
- String path = kvMount + "/" + secretNamePrefix + key;
+ String physicalKey = keyAliases.getOrDefault(key, key);
+ String path = kvMount + "/" + secretNamePrefix + physicalKey;
String value = readFromVault(path);
value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
- cache.put(key, new CacheEntry(value, cacheTtlMs));
+ cache.put(key, value, cacheTtlMs);
return value;
}
@@ -225,17 +226,8 @@ private static int parseKvVersion(String raw) {
return 2;
}
- private static long readTtlMs() {
- String raw = prop("hashicorp.vault.cache.ttl.seconds", "3600");
- try {
- return Long.parseLong(raw.trim()) * 1000L;
- } catch (NumberFormatException e) {
- Debug.logWarning("Invalid hashicorp.vault.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
- return 3_600_000L;
- }
- }
-
private static String prop(String key, String defaultValue) {
return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
}
+
}
diff --git a/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java b/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
index 5ca487bef..e6a4d899c 100644
--- a/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
+++ b/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
@@ -107,6 +107,28 @@ public void getSecretExpiredCacheEntryTriggersRefetch() throws GeneralException
assertEquals(2, calls.get());
}
+ // -- Key aliasing --
+
+ @Test
+ public void getSecretUsesAliasedPathSegment() throws GeneralException {
+ HashicorpVaultReader reader = fixedReader("secret/prod/ofbiz/mysql_db_password",
+ singleEntry("value", "dbpass"));
+ HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "secret", "", "",
+ ONE_HOUR_MS, Map.of("jdbc-password.mysql-ofbiz", "prod/ofbiz/mysql_db_password"));
+
+ assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
+ @Test
+ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws GeneralException {
+ HashicorpVaultReader reader = fixedReader("secret/jdbc-password.mysql-ofbiz",
+ singleEntry("value", "dbpass"));
+ HashicorpVaultSecretsProvider provider = new HashicorpVaultSecretsProvider(reader, "secret", "", "",
+ ONE_HOUR_MS, Map.of("some.other.key", "some/other/alias"));
+
+ assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
// -- Error handling --
@Test(expected = GeneralException.class)
diff --git a/onepassword-secrets-provider/config/onepassword.properties b/onepassword-secrets-provider/config/onepassword.properties
index 89502d51f..373dfc87b 100644
--- a/onepassword-secrets-provider/config/onepassword.properties
+++ b/onepassword-secrets-provider/config/onepassword.properties
@@ -47,6 +47,14 @@ onepassword.field=password
# "myapp/jdbc-password.ofbiz" in 1Password.
onepassword.secret.name.prefix=
+# Optional per-key overrides for deployments where a specific key needs to match a
+# 1Password item title that differs from the OFBiz logical key. The logical key on the
+# left never changes; only the item title on the right needs to match what's in 1Password.
+# Format: key.alias.=
+# Example:
+# key.alias.jdbc-password.mysql-ofbiz=prod-ofbiz-mysql-db-password
+# Leave unset (the default) for keys whose title matches the logical key verbatim.
+
# How long (in seconds) to cache a resolved secret value before re-fetching.
# Default: 3600 (1 hour). Set to 0 to disable caching.
onepassword.cache.ttl.seconds=3600
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
index ca56dbc58..1b1796b40 100644
--- a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
+++ b/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
@@ -26,7 +26,7 @@
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -34,6 +34,7 @@
import org.apache.ofbiz.base.crypto.ConfigCryptoUtil;
import org.apache.ofbiz.base.lang.ThreadSafe;
import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.secret.SecretProviderUtil;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilProperties;
@@ -55,6 +56,12 @@
* Scan {@code fields[]} for the entry whose {@code label} matches {@code onepassword.field}
*
*
+ * Per-key naming overrides
+ * Items are looked up by matching the OFBiz key (with prefix) against the item's
+ * title in 1Password. If a specific key needs a different title, set
+ * {@code key.alias.=}. The alias is checked first; keys with no
+ * alias entry use the logical key unchanged.
+ *
* Configure via {@code plugins/onepassword-secrets-provider/config/onepassword.properties}.
*/
@ThreadSafe
@@ -71,22 +78,9 @@ public final class OnePasswordSecretsProvider implements SecretProvider {
private final String field;
private final String secretNamePrefix;
private final long cacheTtlMs;
+ private final Map keyAliases;
- private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
-
- private static final class CacheEntry {
- final String value;
- final long expiresAt;
-
- CacheEntry(String value, long ttlMs) {
- this.value = value;
- this.expiresAt = System.currentTimeMillis() + ttlMs;
- }
-
- boolean isExpired() {
- return System.currentTimeMillis() >= expiresAt;
- }
- }
+ private final SecretProviderUtil.Cache cache = new SecretProviderUtil.Cache<>();
/** Public no-arg constructor required by {@link java.util.ServiceLoader}. */
public OnePasswordSecretsProvider() {
@@ -96,12 +90,20 @@ public OnePasswordSecretsProvider() {
prop("onepassword.vault.id", ""),
prop("onepassword.field", "password"),
prop("onepassword.secret.name.prefix", ""),
- readTtlMs());
+ SecretProviderUtil.readTtlMs(CONFIG_RESOURCE, "onepassword.cache.ttl.seconds", 3600, MODULE),
+ SecretProviderUtil.loadKeyAliases(CONFIG_RESOURCE));
}
/** Package-private constructor used by unit tests to inject a mock HTTP client. */
OnePasswordSecretsProvider(OnePasswordHttpClient httpClient, String connectUrl, String token,
String vaultId, String field, String secretNamePrefix, long cacheTtlMs) {
+ this(httpClient, connectUrl, token, vaultId, field, secretNamePrefix, cacheTtlMs, Map.of());
+ }
+
+ /** Package-private constructor used by unit tests to inject a mock HTTP client and key aliases. */
+ OnePasswordSecretsProvider(OnePasswordHttpClient httpClient, String connectUrl, String token,
+ String vaultId, String field, String secretNamePrefix, long cacheTtlMs,
+ Map keyAliases) {
this.httpClient = httpClient;
this.connectUrl = connectUrl;
this.token = token;
@@ -109,20 +111,21 @@ public OnePasswordSecretsProvider() {
this.field = field;
this.secretNamePrefix = secretNamePrefix;
this.cacheTtlMs = cacheTtlMs;
+ this.keyAliases = keyAliases;
}
@Override
public String getSecret(String key) throws GeneralException {
- CacheEntry cached = cache.get(key);
- if (cached != null && !cached.isExpired()) {
- return cached.value;
+ String cached = cache.get(key);
+ if (cached != null) {
+ return cached;
}
- String title = secretNamePrefix + key;
+ String title = secretNamePrefix + keyAliases.getOrDefault(key, key);
String value = fetchFromConnect(title);
value = ConfigCryptoUtil.decryptIfEncrypted(value, key);
- cache.put(key, new CacheEntry(value, cacheTtlMs));
+ cache.put(key, value, cacheTtlMs);
return value;
}
@@ -284,17 +287,8 @@ private static int parseSeconds(String raw) {
}
}
- private static long readTtlMs() {
- String raw = prop("onepassword.cache.ttl.seconds", "3600");
- try {
- return Long.parseLong(raw.trim()) * 1000L;
- } catch (NumberFormatException e) {
- Debug.logWarning("Invalid onepassword.cache.ttl.seconds '" + raw + "', defaulting to 3600s", MODULE);
- return 3_600_000L;
- }
- }
-
private static String prop(String key, String defaultValue) {
return UtilProperties.getPropertyValue(CONFIG_RESOURCE, key, defaultValue);
}
+
}
diff --git a/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java b/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
index 950a16067..577c16b56 100644
--- a/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
+++ b/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
@@ -19,17 +19,23 @@
package org.apache.ofbiz.onepassword;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
import org.apache.ofbiz.base.util.GeneralException;
import org.junit.Test;
+import org.mockito.ArgumentCaptor;
public class OnePasswordSecretsProviderTest {
@@ -89,6 +95,32 @@ public void getSecretExpiredCacheEntryTriggersRefetch() throws Exception {
verify(client, times(4)).get(anyString(), anyString());
}
+ // -- Key aliasing --
+
+ @Test
+ public void getSecretUsesAliasedTitle() throws Exception {
+ String aliasedTitle = "prod-ofbiz-mysql-db-password";
+ OnePasswordHttpClient client = buildMockClient(aliasedTitle, ITEM_ID, "dbpass");
+ OnePasswordSecretsProvider p = providerWithAliases(client, "password", "",
+ Map.of("jdbc-password.mysql-ofbiz", aliasedTitle));
+
+ assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz"));
+
+ ArgumentCaptor urlCaptor = ArgumentCaptor.forClass(String.class);
+ verify(client, atLeastOnce()).get(urlCaptor.capture(), eq(TOKEN));
+ String expectedFilter = URLEncoder.encode("title eq \"" + aliasedTitle + "\"", StandardCharsets.UTF_8);
+ assertTrue(urlCaptor.getAllValues().stream().anyMatch(u -> u.contains(expectedFilter)));
+ }
+
+ @Test
+ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws Exception {
+ OnePasswordHttpClient client = buildMockClient("jdbc-password.mysql-ofbiz", ITEM_ID, "dbpass");
+ OnePasswordSecretsProvider p = providerWithAliases(client, "password", "",
+ Map.of("some.other.key", "some-other-alias"));
+
+ assertEquals("dbpass", p.getSecret("jdbc-password.mysql-ofbiz"));
+ }
+
// -- Error handling --
@Test(expected = GeneralException.class)
@@ -125,6 +157,12 @@ private static OnePasswordSecretsProvider provider(OnePasswordHttpClient client,
return new OnePasswordSecretsProvider(client, BASE_URL, TOKEN, VAULT_ID, field, prefix, ttlMs);
}
+ private static OnePasswordSecretsProvider providerWithAliases(OnePasswordHttpClient client, String field,
+ String prefix, Map keyAliases) {
+ return new OnePasswordSecretsProvider(client, BASE_URL, TOKEN, VAULT_ID, field, prefix, ONE_HOUR_MS,
+ keyAliases);
+ }
+
/**
* Builds a mock that routes by URL: search requests get the item list,
* item-fetch requests get the full item with a "password" field.
From 2bdef91cfba24db6bebda3e86f04aef86ca88ea4 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Thu, 25 Jun 2026 17:13:11 +0530
Subject: [PATCH 16/18] Renaming the component name. envvar in continuation was
looking odd to me since I created it.
---
.../build.gradle | 0
.../config/env-var-secrets.properties | 0
.../gradle.lockfile | 0
.../ofbiz-component.xml | 4 ++--
.../src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java | 0
.../java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java | 4 ++--
.../services/org.apache.ofbiz.base.secret.SecretProvider | 0
.../org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java | 0
8 files changed, 4 insertions(+), 4 deletions(-)
rename {envvar-secrets-provider => env-var-secrets-provider}/build.gradle (100%)
rename envvar-secrets-provider/config/envvar-secrets.properties => env-var-secrets-provider/config/env-var-secrets.properties (100%)
rename {envvar-secrets-provider => env-var-secrets-provider}/gradle.lockfile (100%)
rename {envvar-secrets-provider => env-var-secrets-provider}/ofbiz-component.xml (86%)
rename {envvar-secrets-provider => env-var-secrets-provider}/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java (100%)
rename {envvar-secrets-provider => env-var-secrets-provider}/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java (98%)
rename {envvar-secrets-provider => env-var-secrets-provider}/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider (100%)
rename {envvar-secrets-provider => env-var-secrets-provider}/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java (100%)
diff --git a/envvar-secrets-provider/build.gradle b/env-var-secrets-provider/build.gradle
similarity index 100%
rename from envvar-secrets-provider/build.gradle
rename to env-var-secrets-provider/build.gradle
diff --git a/envvar-secrets-provider/config/envvar-secrets.properties b/env-var-secrets-provider/config/env-var-secrets.properties
similarity index 100%
rename from envvar-secrets-provider/config/envvar-secrets.properties
rename to env-var-secrets-provider/config/env-var-secrets.properties
diff --git a/envvar-secrets-provider/gradle.lockfile b/env-var-secrets-provider/gradle.lockfile
similarity index 100%
rename from envvar-secrets-provider/gradle.lockfile
rename to env-var-secrets-provider/gradle.lockfile
diff --git a/envvar-secrets-provider/ofbiz-component.xml b/env-var-secrets-provider/ofbiz-component.xml
similarity index 86%
rename from envvar-secrets-provider/ofbiz-component.xml
rename to env-var-secrets-provider/ofbiz-component.xml
index f2004ab55..e52b298c5 100644
--- a/envvar-secrets-provider/ofbiz-component.xml
+++ b/env-var-secrets-provider/ofbiz-component.xml
@@ -18,13 +18,13 @@ specific language governing permissions and limitations
under the License.
-->
-
-
+
diff --git a/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java b/env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
similarity index 100%
rename from envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
rename to env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
diff --git a/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java b/env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
similarity index 98%
rename from envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
rename to env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
index 698788ee7..bd54300ac 100644
--- a/envvar-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
+++ b/env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
@@ -50,7 +50,7 @@
*
* jdbc-password.mysql-ofbiz -> OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ
*
- * This transform is fixed by default — see {@code config/envvar-secrets.properties}
+ *
This transform is fixed by default — see {@code config/env-var-secrets.properties}
* — so any external tool can predict the expected env var name without reading OFBiz
* source. A deployment that needs a specific key to resolve to a different env var name
* (e.g. a name collision with something else already in its environment) may set
@@ -66,7 +66,7 @@
public final class EnvVarSecretProvider implements SecretProvider {
private static final String MODULE = EnvVarSecretProvider.class.getName();
- private static final String CONFIG_RESOURCE = "envvar-secrets";
+ private static final String CONFIG_RESOURCE = "env-var-secrets";
private final EnvVarReader reader;
private final String namePrefix;
diff --git a/envvar-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/env-var-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
similarity index 100%
rename from envvar-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
rename to env-var-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
diff --git a/envvar-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java b/env-var-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
similarity index 100%
rename from envvar-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
rename to env-var-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
From bc720bdb5ce7f0a9098295d0e2e42b68f10dc96d Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Tue, 14 Jul 2026 23:57:52 +0530
Subject: [PATCH 17/18] 1) Merge 7 secrets-provider plugins into a single
plugins/secretshub component
2) Keep each provider's original package and config file unchanged during the move
3) Add ActiveSecretProvider as the single ServiceLoader entry point for all providers
4) Select the active provider at runtime via secret.provider.active in secretshub.properties
5) Avoid eager construction of unused provider SDK clients by lazily building only the selected one
6) Add ActiveSecretProviderTest covering blank, unrecognized, and valid provider selection
7) Combine build.gradle dependencies from all 7 plugins into one merged build.gradle
8) Regenerate gradle.lockfile from the merged dependency resolution graph
9) Remove the 7 original standalone secrets-provider plugin directories
10) Update root build.gradle comment to reflect the single merged META-INF/services entry
---
aws-secrets-provider/build.gradle | 34 -----
aws-secrets-provider/gradle.lockfile | 47 ------
aws-secrets-provider/ofbiz-component.xml | 30 ----
...rg.apache.ofbiz.base.secret.SecretProvider | 1 -
azure-keyvault-secrets-provider/build.gradle | 33 -----
.../gradle.lockfile | 53 -------
.../ofbiz-component.xml | 30 ----
...rg.apache.ofbiz.base.secret.SecretProvider | 1 -
bitwarden-secrets-provider/build.gradle | 31 ----
bitwarden-secrets-provider/gradle.lockfile | 4 -
...rg.apache.ofbiz.base.secret.SecretProvider | 1 -
env-var-secrets-provider/build.gradle | 28 ----
env-var-secrets-provider/gradle.lockfile | 4 -
env-var-secrets-provider/ofbiz-component.xml | 30 ----
...rg.apache.ofbiz.base.secret.SecretProvider | 1 -
.../build.gradle | 32 -----
.../gradle.lockfile | 4 -
.../ofbiz-component.xml | 30 ----
...rg.apache.ofbiz.base.secret.SecretProvider | 1 -
hashicorp-vault-secrets-provider/build.gradle | 32 -----
.../gradle.lockfile | 5 -
.../ofbiz-component.xml | 30 ----
...rg.apache.ofbiz.base.secret.SecretProvider | 1 -
onepassword-secrets-provider/build.gradle | 27 ----
onepassword-secrets-provider/gradle.lockfile | 4 -
.../ofbiz-component.xml | 30 ----
...rg.apache.ofbiz.base.secret.SecretProvider | 1 -
secretshub/build.gradle | 51 +++++++
.../config/aws-secrets-manager.properties | 0
.../config/azure-keyvault.properties | 0
.../config/bitwarden-secrets.properties | 0
.../config/env-var-secrets.properties | 0
.../config/gcp-secret-manager.properties | 0
.../config/hashicorp-vault-secrets.properties | 0
.../config/onepassword.properties | 0
secretshub/config/secretshub.properties | 36 +++++
secretshub/gradle.lockfile | 134 ++++++++++++++++++
.../ofbiz-component.xml | 5 +-
.../awssecrets/AwsSecretsManagerProvider.java | 0
.../azurekeyvault/AzureKeyVaultReader.java | 0
.../AzureKeyVaultSecretsProvider.java | 0
.../ofbiz/bitwarden/BitwardenHttpClient.java | 0
.../bitwarden/BitwardenSecretsProvider.java | 0
.../org/apache/ofbiz/envvar/EnvVarReader.java | 0
.../ofbiz/envvar/EnvVarSecretProvider.java | 0
.../GcpSecretManagerSecretsProvider.java | 0
.../gcpsecretmanager/GcpSecretReader.java | 0
.../hashicorpvault/HashicorpVaultReader.java | 0
.../HashicorpVaultSecretsProvider.java | 0
.../onepassword/OnePasswordHttpClient.java | 0
.../OnePasswordSecretsProvider.java | 0
.../secretshub/ActiveSecretProvider.java | 111 +++++++++++++++
...rg.apache.ofbiz.base.secret.SecretProvider | 1 +
.../AwsSecretsManagerProviderTest.java | 0
.../AzureKeyVaultSecretsProviderTest.java | 0
.../BitwardenSecretsProviderTest.java | 0
.../envvar/EnvVarSecretProviderTest.java | 0
.../GcpSecretManagerSecretsProviderTest.java | 0
.../HashicorpVaultSecretsProviderTest.java | 0
.../OnePasswordSecretsProviderTest.java | 0
.../secretshub/ActiveSecretProviderTest.java | 61 ++++++++
61 files changed, 397 insertions(+), 527 deletions(-)
delete mode 100644 aws-secrets-provider/build.gradle
delete mode 100644 aws-secrets-provider/gradle.lockfile
delete mode 100644 aws-secrets-provider/ofbiz-component.xml
delete mode 100644 aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
delete mode 100644 azure-keyvault-secrets-provider/build.gradle
delete mode 100644 azure-keyvault-secrets-provider/gradle.lockfile
delete mode 100644 azure-keyvault-secrets-provider/ofbiz-component.xml
delete mode 100644 azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
delete mode 100644 bitwarden-secrets-provider/build.gradle
delete mode 100644 bitwarden-secrets-provider/gradle.lockfile
delete mode 100644 bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
delete mode 100644 env-var-secrets-provider/build.gradle
delete mode 100644 env-var-secrets-provider/gradle.lockfile
delete mode 100644 env-var-secrets-provider/ofbiz-component.xml
delete mode 100644 env-var-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
delete mode 100644 gcp-secretmanager-secrets-provider/build.gradle
delete mode 100644 gcp-secretmanager-secrets-provider/gradle.lockfile
delete mode 100644 gcp-secretmanager-secrets-provider/ofbiz-component.xml
delete mode 100644 gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
delete mode 100644 hashicorp-vault-secrets-provider/build.gradle
delete mode 100644 hashicorp-vault-secrets-provider/gradle.lockfile
delete mode 100644 hashicorp-vault-secrets-provider/ofbiz-component.xml
delete mode 100644 hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
delete mode 100644 onepassword-secrets-provider/build.gradle
delete mode 100644 onepassword-secrets-provider/gradle.lockfile
delete mode 100644 onepassword-secrets-provider/ofbiz-component.xml
delete mode 100644 onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
create mode 100644 secretshub/build.gradle
rename {aws-secrets-provider => secretshub}/config/aws-secrets-manager.properties (100%)
rename {azure-keyvault-secrets-provider => secretshub}/config/azure-keyvault.properties (100%)
rename {bitwarden-secrets-provider => secretshub}/config/bitwarden-secrets.properties (100%)
rename {env-var-secrets-provider => secretshub}/config/env-var-secrets.properties (100%)
rename {gcp-secretmanager-secrets-provider => secretshub}/config/gcp-secret-manager.properties (100%)
rename {hashicorp-vault-secrets-provider => secretshub}/config/hashicorp-vault-secrets.properties (100%)
rename {onepassword-secrets-provider => secretshub}/config/onepassword.properties (100%)
create mode 100644 secretshub/config/secretshub.properties
create mode 100644 secretshub/gradle.lockfile
rename {bitwarden-secrets-provider => secretshub}/ofbiz-component.xml (83%)
rename {aws-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java (100%)
rename {azure-keyvault-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java (100%)
rename {azure-keyvault-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java (100%)
rename {bitwarden-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java (100%)
rename {bitwarden-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java (100%)
rename {env-var-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java (100%)
rename {env-var-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java (100%)
rename {gcp-secretmanager-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java (100%)
rename {gcp-secretmanager-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java (100%)
rename {hashicorp-vault-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java (100%)
rename {hashicorp-vault-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java (100%)
rename {onepassword-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java (100%)
rename {onepassword-secrets-provider => secretshub}/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java (100%)
create mode 100644 secretshub/src/main/java/org/apache/ofbiz/secretshub/ActiveSecretProvider.java
create mode 100644 secretshub/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
rename {aws-secrets-provider => secretshub}/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java (100%)
rename {azure-keyvault-secrets-provider => secretshub}/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java (100%)
rename {bitwarden-secrets-provider => secretshub}/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java (100%)
rename {env-var-secrets-provider => secretshub}/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java (100%)
rename {gcp-secretmanager-secrets-provider => secretshub}/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java (100%)
rename {hashicorp-vault-secrets-provider => secretshub}/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java (100%)
rename {onepassword-secrets-provider => secretshub}/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java (100%)
create mode 100644 secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java
diff --git a/aws-secrets-provider/build.gradle b/aws-secrets-provider/build.gradle
deleted file mode 100644
index b553c0231..000000000
--- a/aws-secrets-provider/build.gradle
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * 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.
- */
-
-// AWS SDK for Java v2 — Apache License 2.0
-// https://github.com/aws/aws-sdk-java-v2
-dependencies {
- pluginLibsCompile 'software.amazon.awssdk:secretsmanager:2.26.31'
- pluginLibsCompile 'software.amazon.awssdk:url-connection-client:2.26.31'
-}
-
-// AWS SDK v2 bundles several items that conflict with OFBiz's global exclusions
-configurations.all {
- exclude group: 'commons-logging', module: 'commons-logging'
-}
-
-dependencyLocking {
- lockAllConfigurations()
-}
diff --git a/aws-secrets-provider/gradle.lockfile b/aws-secrets-provider/gradle.lockfile
deleted file mode 100644
index d810d3d06..000000000
--- a/aws-secrets-provider/gradle.lockfile
+++ /dev/null
@@ -1,47 +0,0 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-commons-codec:commons-codec:1.17.1=pluginLibsCompile
-io.netty:netty-buffer:4.1.112.Final=pluginLibsCompile
-io.netty:netty-codec-http2:4.1.112.Final=pluginLibsCompile
-io.netty:netty-codec-http:4.1.112.Final=pluginLibsCompile
-io.netty:netty-codec:4.1.112.Final=pluginLibsCompile
-io.netty:netty-common:4.1.112.Final=pluginLibsCompile
-io.netty:netty-handler:4.1.112.Final=pluginLibsCompile
-io.netty:netty-resolver:4.1.112.Final=pluginLibsCompile
-io.netty:netty-transport-classes-epoll:4.1.112.Final=pluginLibsCompile
-io.netty:netty-transport-native-unix-common:4.1.112.Final=pluginLibsCompile
-io.netty:netty-transport:4.1.112.Final=pluginLibsCompile
-org.apache.httpcomponents:httpclient:4.5.13=pluginLibsCompile
-org.apache.httpcomponents:httpcore:4.4.16=pluginLibsCompile
-org.reactivestreams:reactive-streams:1.0.4=pluginLibsCompile
-org.slf4j:slf4j-api:1.7.36=pluginLibsCompile
-software.amazon.awssdk:annotations:2.26.31=pluginLibsCompile
-software.amazon.awssdk:apache-client:2.26.31=pluginLibsCompile
-software.amazon.awssdk:auth:2.26.31=pluginLibsCompile
-software.amazon.awssdk:aws-core:2.26.31=pluginLibsCompile
-software.amazon.awssdk:aws-json-protocol:2.26.31=pluginLibsCompile
-software.amazon.awssdk:checksums-spi:2.26.31=pluginLibsCompile
-software.amazon.awssdk:checksums:2.26.31=pluginLibsCompile
-software.amazon.awssdk:endpoints-spi:2.26.31=pluginLibsCompile
-software.amazon.awssdk:http-auth-aws-eventstream:2.26.31=pluginLibsCompile
-software.amazon.awssdk:http-auth-aws:2.26.31=pluginLibsCompile
-software.amazon.awssdk:http-auth-spi:2.26.31=pluginLibsCompile
-software.amazon.awssdk:http-auth:2.26.31=pluginLibsCompile
-software.amazon.awssdk:http-client-spi:2.26.31=pluginLibsCompile
-software.amazon.awssdk:identity-spi:2.26.31=pluginLibsCompile
-software.amazon.awssdk:json-utils:2.26.31=pluginLibsCompile
-software.amazon.awssdk:metrics-spi:2.26.31=pluginLibsCompile
-software.amazon.awssdk:netty-nio-client:2.26.31=pluginLibsCompile
-software.amazon.awssdk:profiles:2.26.31=pluginLibsCompile
-software.amazon.awssdk:protocol-core:2.26.31=pluginLibsCompile
-software.amazon.awssdk:regions:2.26.31=pluginLibsCompile
-software.amazon.awssdk:retries-spi:2.26.31=pluginLibsCompile
-software.amazon.awssdk:retries:2.26.31=pluginLibsCompile
-software.amazon.awssdk:sdk-core:2.26.31=pluginLibsCompile
-software.amazon.awssdk:secretsmanager:2.26.31=pluginLibsCompile
-software.amazon.awssdk:third-party-jackson-core:2.26.31=pluginLibsCompile
-software.amazon.awssdk:url-connection-client:2.26.31=pluginLibsCompile
-software.amazon.awssdk:utils:2.26.31=pluginLibsCompile
-software.amazon.eventstream:eventstream:1.0.1=pluginLibsCompile
-empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/aws-secrets-provider/ofbiz-component.xml b/aws-secrets-provider/ofbiz-component.xml
deleted file mode 100644
index 619c5611d..000000000
--- a/aws-secrets-provider/ofbiz-component.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
deleted file mode 100644
index 8d42eba0c..000000000
--- a/aws-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
+++ /dev/null
@@ -1 +0,0 @@
-org.apache.ofbiz.awssecrets.AwsSecretsManagerProvider
diff --git a/azure-keyvault-secrets-provider/build.gradle b/azure-keyvault-secrets-provider/build.gradle
deleted file mode 100644
index e7c97a3df..000000000
--- a/azure-keyvault-secrets-provider/build.gradle
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * 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.
- */
-
-// Azure SDK for Java — Key Vault Secrets (MIT License)
-// https://github.com/Azure/azure-sdk-for-java
-dependencies {
- pluginLibsCompile 'com.azure:azure-security-keyvault-secrets:4.8.0'
- pluginLibsCompile 'com.azure:azure-identity:1.12.0'
-}
-
-configurations.all {
- exclude group: 'commons-logging', module: 'commons-logging'
-}
-
-dependencyLocking {
- lockAllConfigurations()
-}
diff --git a/azure-keyvault-secrets-provider/gradle.lockfile b/azure-keyvault-secrets-provider/gradle.lockfile
deleted file mode 100644
index f3543ed31..000000000
--- a/azure-keyvault-secrets-provider/gradle.lockfile
+++ /dev/null
@@ -1,53 +0,0 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-com.azure:azure-core-http-netty:1.14.2=pluginLibsCompile
-com.azure:azure-core:1.48.0=pluginLibsCompile
-com.azure:azure-identity:1.12.0=pluginLibsCompile
-com.azure:azure-json:1.1.0=pluginLibsCompile
-com.azure:azure-security-keyvault-secrets:4.8.0=pluginLibsCompile
-com.azure:azure-xml:1.0.0=pluginLibsCompile
-com.fasterxml.jackson.core:jackson-annotations:2.13.5=pluginLibsCompile
-com.fasterxml.jackson.core:jackson-core:2.13.5=pluginLibsCompile
-com.fasterxml.jackson.core:jackson-databind:2.13.5=pluginLibsCompile
-com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.5=pluginLibsCompile
-com.fasterxml.jackson:jackson-bom:2.13.5=pluginLibsCompile
-com.github.stephenc.jcip:jcip-annotations:1.0-1=pluginLibsCompile
-com.microsoft.azure:msal4j-persistence-extension:1.3.0=pluginLibsCompile
-com.microsoft.azure:msal4j:1.15.0=pluginLibsCompile
-com.nimbusds:content-type:2.3=pluginLibsCompile
-com.nimbusds:lang-tag:1.7=pluginLibsCompile
-com.nimbusds:nimbus-jose-jwt:9.37.3=pluginLibsCompile
-com.nimbusds:oauth2-oidc-sdk:11.9.1=pluginLibsCompile
-io.netty:netty-buffer:4.1.108.Final=pluginLibsCompile
-io.netty:netty-codec-dns:4.1.107.Final=pluginLibsCompile
-io.netty:netty-codec-http2:4.1.108.Final=pluginLibsCompile
-io.netty:netty-codec-http:4.1.108.Final=pluginLibsCompile
-io.netty:netty-codec-socks:4.1.108.Final=pluginLibsCompile
-io.netty:netty-codec:4.1.108.Final=pluginLibsCompile
-io.netty:netty-common:4.1.108.Final=pluginLibsCompile
-io.netty:netty-handler-proxy:4.1.108.Final=pluginLibsCompile
-io.netty:netty-handler:4.1.108.Final=pluginLibsCompile
-io.netty:netty-resolver-dns-classes-macos:4.1.107.Final=pluginLibsCompile
-io.netty:netty-resolver-dns-native-macos:4.1.107.Final=pluginLibsCompile
-io.netty:netty-resolver-dns:4.1.107.Final=pluginLibsCompile
-io.netty:netty-resolver:4.1.108.Final=pluginLibsCompile
-io.netty:netty-tcnative-boringssl-static:2.0.65.Final=pluginLibsCompile
-io.netty:netty-tcnative-classes:2.0.65.Final=pluginLibsCompile
-io.netty:netty-transport-classes-epoll:4.1.108.Final=pluginLibsCompile
-io.netty:netty-transport-classes-kqueue:4.1.108.Final=pluginLibsCompile
-io.netty:netty-transport-native-epoll:4.1.108.Final=pluginLibsCompile
-io.netty:netty-transport-native-kqueue:4.1.108.Final=pluginLibsCompile
-io.netty:netty-transport-native-unix-common:4.1.108.Final=pluginLibsCompile
-io.netty:netty-transport:4.1.108.Final=pluginLibsCompile
-io.projectreactor.netty:reactor-netty-core:1.0.43=pluginLibsCompile
-io.projectreactor.netty:reactor-netty-http:1.0.43=pluginLibsCompile
-io.projectreactor:reactor-core:3.4.36=pluginLibsCompile
-net.java.dev.jna:jna-platform:5.13.0=pluginLibsCompile
-net.java.dev.jna:jna:5.13.0=pluginLibsCompile
-net.minidev:accessors-smart:2.5.0=pluginLibsCompile
-net.minidev:json-smart:2.5.0=pluginLibsCompile
-org.ow2.asm:asm:9.3=pluginLibsCompile
-org.reactivestreams:reactive-streams:1.0.4=pluginLibsCompile
-org.slf4j:slf4j-api:1.7.36=pluginLibsCompile
-empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/azure-keyvault-secrets-provider/ofbiz-component.xml b/azure-keyvault-secrets-provider/ofbiz-component.xml
deleted file mode 100644
index 65ed6fdfd..000000000
--- a/azure-keyvault-secrets-provider/ofbiz-component.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
deleted file mode 100644
index 3aac1713e..000000000
--- a/azure-keyvault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
+++ /dev/null
@@ -1 +0,0 @@
-org.apache.ofbiz.azurekeyvault.AzureKeyVaultSecretsProvider
diff --git a/bitwarden-secrets-provider/build.gradle b/bitwarden-secrets-provider/build.gradle
deleted file mode 100644
index 462bdca67..000000000
--- a/bitwarden-secrets-provider/build.gradle
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * 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.
- */
-
-// No external SDK required.
-// Uses Java's built-in java.net.http.HttpClient (Java 11+), javax.crypto for
-// AES-256-CBC + HMAC-SHA256 decryption, and OFBiz's bundled jackson-databind.
-//
-// Bitwarden Secrets Manager encrypts all data end-to-end. The symmetric key
-// embedded in the machine account access token is used to decrypt secret names
-// and values client-side.
-dependencies {}
-
-dependencyLocking {
- lockAllConfigurations()
-}
diff --git a/bitwarden-secrets-provider/gradle.lockfile b/bitwarden-secrets-provider/gradle.lockfile
deleted file mode 100644
index b9b85588a..000000000
--- a/bitwarden-secrets-provider/gradle.lockfile
+++ /dev/null
@@ -1,4 +0,0 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-empty=pluginLibsCompile,pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
deleted file mode 100644
index 8f6a30013..000000000
--- a/bitwarden-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
+++ /dev/null
@@ -1 +0,0 @@
-org.apache.ofbiz.bitwarden.BitwardenSecretsProvider
diff --git a/env-var-secrets-provider/build.gradle b/env-var-secrets-provider/build.gradle
deleted file mode 100644
index 87a7c44c9..000000000
--- a/env-var-secrets-provider/build.gradle
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * 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.
- */
-
-// No external SDK required. Reads secrets from this process's own
-// environment variables (java.lang.System.getenv), which is itself how
-// Kubernetes (External Secrets Operator, Doppler/Infisical wrappers, etc.)
-// or any other env-var-based secret injector hands a value to this JVM.
-dependencies {}
-
-dependencyLocking {
- lockAllConfigurations()
-}
diff --git a/env-var-secrets-provider/gradle.lockfile b/env-var-secrets-provider/gradle.lockfile
deleted file mode 100644
index b9b85588a..000000000
--- a/env-var-secrets-provider/gradle.lockfile
+++ /dev/null
@@ -1,4 +0,0 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-empty=pluginLibsCompile,pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/env-var-secrets-provider/ofbiz-component.xml b/env-var-secrets-provider/ofbiz-component.xml
deleted file mode 100644
index e52b298c5..000000000
--- a/env-var-secrets-provider/ofbiz-component.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/env-var-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/env-var-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
deleted file mode 100644
index b5bafe336..000000000
--- a/env-var-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
+++ /dev/null
@@ -1 +0,0 @@
-org.apache.ofbiz.envvar.EnvVarSecretProvider
diff --git a/gcp-secretmanager-secrets-provider/build.gradle b/gcp-secretmanager-secrets-provider/build.gradle
deleted file mode 100644
index 58d331ebe..000000000
--- a/gcp-secretmanager-secrets-provider/build.gradle
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * 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.
- */
-
-// Google Cloud Secret Manager Java client (Apache License 2.0)
-// https://github.com/googleapis/java-secretmanager
-dependencies {
- pluginLibsCompile 'com.google.cloud:google-cloud-secretmanager:2.36.0'
-}
-
-configurations.all {
- exclude group: 'commons-logging', module: 'commons-logging'
-}
-
-dependencyLocking {
- lockAllConfigurations()
-}
diff --git a/gcp-secretmanager-secrets-provider/gradle.lockfile b/gcp-secretmanager-secrets-provider/gradle.lockfile
deleted file mode 100644
index 06dc73361..000000000
--- a/gcp-secretmanager-secrets-provider/gradle.lockfile
+++ /dev/null
@@ -1,4 +0,0 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/gcp-secretmanager-secrets-provider/ofbiz-component.xml b/gcp-secretmanager-secrets-provider/ofbiz-component.xml
deleted file mode 100644
index 7608b2281..000000000
--- a/gcp-secretmanager-secrets-provider/ofbiz-component.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
deleted file mode 100644
index 7421bf9dd..000000000
--- a/gcp-secretmanager-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
+++ /dev/null
@@ -1 +0,0 @@
-org.apache.ofbiz.gcpsecretmanager.GcpSecretManagerSecretsProvider
diff --git a/hashicorp-vault-secrets-provider/build.gradle b/hashicorp-vault-secrets-provider/build.gradle
deleted file mode 100644
index 16a5c53f8..000000000
--- a/hashicorp-vault-secrets-provider/build.gradle
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * 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.
- */
-
-// HashiCorp Vault Java Driver — Apache License 2.0
-// https://github.com/jopenlibs/vault-java-driver
-dependencies {
- pluginLibsCompile 'io.github.jopenlibs:vault-java-driver:5.4.0'
-}
-
-configurations.all {
- exclude group: 'commons-logging', module: 'commons-logging'
-}
-
-dependencyLocking {
- lockAllConfigurations()
-}
diff --git a/hashicorp-vault-secrets-provider/gradle.lockfile b/hashicorp-vault-secrets-provider/gradle.lockfile
deleted file mode 100644
index f306eb009..000000000
--- a/hashicorp-vault-secrets-provider/gradle.lockfile
+++ /dev/null
@@ -1,5 +0,0 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-io.github.jopenlibs:vault-java-driver:5.4.0=pluginLibsCompile
-empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/hashicorp-vault-secrets-provider/ofbiz-component.xml b/hashicorp-vault-secrets-provider/ofbiz-component.xml
deleted file mode 100644
index 37ea052c7..000000000
--- a/hashicorp-vault-secrets-provider/ofbiz-component.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
deleted file mode 100644
index 2e36cff5e..000000000
--- a/hashicorp-vault-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
+++ /dev/null
@@ -1 +0,0 @@
-org.apache.ofbiz.hashicorpvault.HashicorpVaultSecretsProvider
diff --git a/onepassword-secrets-provider/build.gradle b/onepassword-secrets-provider/build.gradle
deleted file mode 100644
index 70dc7debd..000000000
--- a/onepassword-secrets-provider/build.gradle
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * 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.
- */
-
-// No external SDK required.
-// Uses Java's built-in java.net.http.HttpClient (Java 11+) and OFBiz's
-// bundled jackson-databind for JSON parsing.
-dependencies {}
-
-dependencyLocking {
- lockAllConfigurations()
-}
diff --git a/onepassword-secrets-provider/gradle.lockfile b/onepassword-secrets-provider/gradle.lockfile
deleted file mode 100644
index b9b85588a..000000000
--- a/onepassword-secrets-provider/gradle.lockfile
+++ /dev/null
@@ -1,4 +0,0 @@
-# This is a Gradle generated file for dependency locking.
-# Manual edits can break the build and are not advised.
-# This file is expected to be part of source control.
-empty=pluginLibsCompile,pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/onepassword-secrets-provider/ofbiz-component.xml b/onepassword-secrets-provider/ofbiz-component.xml
deleted file mode 100644
index 10f7cafe5..000000000
--- a/onepassword-secrets-provider/ofbiz-component.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
deleted file mode 100644
index 128ee9165..000000000
--- a/onepassword-secrets-provider/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
+++ /dev/null
@@ -1 +0,0 @@
-org.apache.ofbiz.onepassword.OnePasswordSecretsProvider
diff --git a/secretshub/build.gradle b/secretshub/build.gradle
new file mode 100644
index 000000000..8aea0cb55
--- /dev/null
+++ b/secretshub/build.gradle
@@ -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.
+ */
+
+// Combined dependencies for all 7 bundled SecretProvider implementations.
+// Only the provider selected via secret.provider.active in secretshub.properties
+// is ever constructed at runtime (see ActiveSecretProvider), but all 7 SDKs must
+// be on the classpath at build time since any one of them may be chosen.
+//
+// AWS SDK for Java v2 — Apache License 2.0 — https://github.com/aws/aws-sdk-java-v2
+// Azure SDK for Java — Key Vault Secrets (MIT License) — https://github.com/Azure/azure-sdk-for-java
+// Google Cloud Secret Manager Java client (Apache License 2.0) — https://github.com/googleapis/java-secretmanager
+// HashiCorp Vault Java Driver — Apache License 2.0 — https://github.com/jopenlibs/vault-java-driver
+//
+// Bitwarden, env-var, and 1Password contribute no third-party dependencies here —
+// JDK java.net.http.HttpClient only.
+dependencies {
+ pluginLibsCompile 'software.amazon.awssdk:secretsmanager:2.26.31'
+ pluginLibsCompile 'software.amazon.awssdk:url-connection-client:2.26.31'
+
+ pluginLibsCompile 'com.azure:azure-security-keyvault-secrets:4.8.0'
+ pluginLibsCompile 'com.azure:azure-identity:1.12.0'
+
+ pluginLibsCompile 'com.google.cloud:google-cloud-secretmanager:2.36.0'
+
+ pluginLibsCompile 'io.github.jopenlibs:vault-java-driver:5.4.0'
+}
+
+// AWS SDK v2 and others bundle several items that conflict with OFBiz's global exclusions
+configurations.all {
+ exclude group: 'commons-logging', module: 'commons-logging'
+}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
diff --git a/aws-secrets-provider/config/aws-secrets-manager.properties b/secretshub/config/aws-secrets-manager.properties
similarity index 100%
rename from aws-secrets-provider/config/aws-secrets-manager.properties
rename to secretshub/config/aws-secrets-manager.properties
diff --git a/azure-keyvault-secrets-provider/config/azure-keyvault.properties b/secretshub/config/azure-keyvault.properties
similarity index 100%
rename from azure-keyvault-secrets-provider/config/azure-keyvault.properties
rename to secretshub/config/azure-keyvault.properties
diff --git a/bitwarden-secrets-provider/config/bitwarden-secrets.properties b/secretshub/config/bitwarden-secrets.properties
similarity index 100%
rename from bitwarden-secrets-provider/config/bitwarden-secrets.properties
rename to secretshub/config/bitwarden-secrets.properties
diff --git a/env-var-secrets-provider/config/env-var-secrets.properties b/secretshub/config/env-var-secrets.properties
similarity index 100%
rename from env-var-secrets-provider/config/env-var-secrets.properties
rename to secretshub/config/env-var-secrets.properties
diff --git a/gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties b/secretshub/config/gcp-secret-manager.properties
similarity index 100%
rename from gcp-secretmanager-secrets-provider/config/gcp-secret-manager.properties
rename to secretshub/config/gcp-secret-manager.properties
diff --git a/hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties b/secretshub/config/hashicorp-vault-secrets.properties
similarity index 100%
rename from hashicorp-vault-secrets-provider/config/hashicorp-vault-secrets.properties
rename to secretshub/config/hashicorp-vault-secrets.properties
diff --git a/onepassword-secrets-provider/config/onepassword.properties b/secretshub/config/onepassword.properties
similarity index 100%
rename from onepassword-secrets-provider/config/onepassword.properties
rename to secretshub/config/onepassword.properties
diff --git a/secretshub/config/secretshub.properties b/secretshub/config/secretshub.properties
new file mode 100644
index 000000000..644c8c601
--- /dev/null
+++ b/secretshub/config/secretshub.properties
@@ -0,0 +1,36 @@
+###############################################################################
+# 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.
+###############################################################################
+
+####
+# secretshub — active SecretProvider switch
+#
+# This component bundles 7 SecretProvider implementations (AWS Secrets Manager,
+# Azure Key Vault, Bitwarden Secrets Manager, environment variables, GCP Secret
+# Manager, HashiCorp Vault, 1Password Connect) but only ONE may be active at a
+# time. Set the property below to select it; org.apache.ofbiz.secretshub.
+# ActiveSecretProvider is the only class ServiceLoader ever constructs, and it
+# lazily builds only the provider named here.
+#
+# After choosing a provider, fill in its own settings in its existing
+# *.properties file in this same config/ directory (e.g. aws-secrets-manager.properties
+# for aws, hashicorp-vault-secrets.properties for hashicorp-vault, etc.)
+#
+# Valid values: aws | azure | bitwarden | env-var | gcp | hashicorp-vault | onepassword
+####
+secret.provider.active=
diff --git a/secretshub/gradle.lockfile b/secretshub/gradle.lockfile
new file mode 100644
index 000000000..46b553285
--- /dev/null
+++ b/secretshub/gradle.lockfile
@@ -0,0 +1,134 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.azure:azure-core-http-netty:1.14.2=pluginLibsCompile
+com.azure:azure-core:1.48.0=pluginLibsCompile
+com.azure:azure-identity:1.12.0=pluginLibsCompile
+com.azure:azure-json:1.1.0=pluginLibsCompile
+com.azure:azure-security-keyvault-secrets:4.8.0=pluginLibsCompile
+com.azure:azure-xml:1.0.0=pluginLibsCompile
+com.fasterxml.jackson.core:jackson-annotations:2.13.5=pluginLibsCompile
+com.fasterxml.jackson.core:jackson-core:2.13.5=pluginLibsCompile
+com.fasterxml.jackson.core:jackson-databind:2.13.5=pluginLibsCompile
+com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.5=pluginLibsCompile
+com.fasterxml.jackson:jackson-bom:2.13.5=pluginLibsCompile
+com.github.stephenc.jcip:jcip-annotations:1.0-1=pluginLibsCompile
+com.google.android:annotations:4.1.1.4=pluginLibsCompile
+com.google.api.grpc:proto-google-cloud-secretmanager-v1:2.36.0=pluginLibsCompile
+com.google.api.grpc:proto-google-cloud-secretmanager-v1beta1:2.36.0=pluginLibsCompile
+com.google.api.grpc:proto-google-common-protos:2.34.0=pluginLibsCompile
+com.google.api.grpc:proto-google-iam-v1:1.29.0=pluginLibsCompile
+com.google.api:api-common:2.26.0=pluginLibsCompile
+com.google.api:gax-grpc:2.43.0=pluginLibsCompile
+com.google.api:gax-httpjson:2.43.0=pluginLibsCompile
+com.google.api:gax:2.43.0=pluginLibsCompile
+com.google.auth:google-auth-library-credentials:1.23.0=pluginLibsCompile
+com.google.auth:google-auth-library-oauth2-http:1.23.0=pluginLibsCompile
+com.google.auto.value:auto-value-annotations:1.10.4=pluginLibsCompile
+com.google.cloud:google-cloud-secretmanager:2.36.0=pluginLibsCompile
+com.google.code.findbugs:jsr305:3.0.2=pluginLibsCompile
+com.google.code.gson:gson:2.10.1=pluginLibsCompile
+com.google.errorprone:error_prone_annotations:2.24.1=pluginLibsCompile
+com.google.guava:failureaccess:1.0.1=pluginLibsCompile
+com.google.guava:guava:32.1.3-jre FAILED=pluginLibsCompile
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=pluginLibsCompile
+com.google.http-client:google-http-client-gson:1.44.1=pluginLibsCompile
+com.google.http-client:google-http-client:1.44.1=pluginLibsCompile
+com.google.j2objc:j2objc-annotations:2.8=pluginLibsCompile
+com.google.protobuf:protobuf-java-util:3.25.2=pluginLibsCompile
+com.google.protobuf:protobuf-java:3.25.2=pluginLibsCompile
+com.google.re2j:re2j:1.7=pluginLibsCompile
+com.microsoft.azure:msal4j-persistence-extension:1.3.0=pluginLibsCompile
+com.microsoft.azure:msal4j:1.15.0=pluginLibsCompile
+com.nimbusds:content-type:2.3=pluginLibsCompile
+com.nimbusds:lang-tag:1.7=pluginLibsCompile
+com.nimbusds:nimbus-jose-jwt:9.37.3=pluginLibsCompile
+com.nimbusds:oauth2-oidc-sdk:11.9.1=pluginLibsCompile
+commons-codec:commons-codec:1.17.1=pluginLibsCompile
+io.github.jopenlibs:vault-java-driver:5.4.0=pluginLibsCompile
+io.grpc:grpc-alts:1.61.1=pluginLibsCompile
+io.grpc:grpc-api:1.61.1=pluginLibsCompile
+io.grpc:grpc-auth:1.61.1=pluginLibsCompile
+io.grpc:grpc-context:1.61.1=pluginLibsCompile
+io.grpc:grpc-core:1.61.1=pluginLibsCompile
+io.grpc:grpc-googleapis:1.61.1=pluginLibsCompile
+io.grpc:grpc-grpclb:1.61.1=pluginLibsCompile
+io.grpc:grpc-inprocess:1.61.1=pluginLibsCompile
+io.grpc:grpc-netty-shaded:1.61.1=pluginLibsCompile
+io.grpc:grpc-protobuf-lite:1.61.1=pluginLibsCompile
+io.grpc:grpc-protobuf:1.61.1=pluginLibsCompile
+io.grpc:grpc-services:1.61.1=pluginLibsCompile
+io.grpc:grpc-stub:1.61.1=pluginLibsCompile
+io.grpc:grpc-util:1.61.1=pluginLibsCompile
+io.grpc:grpc-xds:1.61.1=pluginLibsCompile
+io.netty:netty-buffer:4.1.112.Final=pluginLibsCompile
+io.netty:netty-codec-dns:4.1.107.Final=pluginLibsCompile
+io.netty:netty-codec-http2:4.1.112.Final=pluginLibsCompile
+io.netty:netty-codec-http:4.1.112.Final=pluginLibsCompile
+io.netty:netty-codec-socks:4.1.108.Final=pluginLibsCompile
+io.netty:netty-codec:4.1.112.Final=pluginLibsCompile
+io.netty:netty-common:4.1.112.Final=pluginLibsCompile
+io.netty:netty-handler-proxy:4.1.108.Final=pluginLibsCompile
+io.netty:netty-handler:4.1.112.Final=pluginLibsCompile
+io.netty:netty-resolver-dns-classes-macos:4.1.107.Final=pluginLibsCompile
+io.netty:netty-resolver-dns-native-macos:4.1.107.Final=pluginLibsCompile
+io.netty:netty-resolver-dns:4.1.107.Final=pluginLibsCompile
+io.netty:netty-resolver:4.1.112.Final=pluginLibsCompile
+io.netty:netty-tcnative-boringssl-static:2.0.65.Final=pluginLibsCompile
+io.netty:netty-tcnative-classes:2.0.65.Final=pluginLibsCompile
+io.netty:netty-transport-classes-epoll:4.1.112.Final=pluginLibsCompile
+io.netty:netty-transport-classes-kqueue:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport-native-epoll:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport-native-kqueue:4.1.108.Final=pluginLibsCompile
+io.netty:netty-transport-native-unix-common:4.1.112.Final=pluginLibsCompile
+io.netty:netty-transport:4.1.112.Final=pluginLibsCompile
+io.opencensus:opencensus-api:0.31.1=pluginLibsCompile
+io.opencensus:opencensus-contrib-http-util:0.31.1=pluginLibsCompile
+io.opencensus:opencensus-proto:0.2.0=pluginLibsCompile
+io.perfmark:perfmark-api:0.27.0=pluginLibsCompile
+io.projectreactor.netty:reactor-netty-core:1.0.43=pluginLibsCompile
+io.projectreactor.netty:reactor-netty-http:1.0.43=pluginLibsCompile
+io.projectreactor:reactor-core:3.4.36=pluginLibsCompile
+javax.annotation:javax.annotation-api:1.3.2=pluginLibsCompile
+net.java.dev.jna:jna-platform:5.13.0=pluginLibsCompile
+net.java.dev.jna:jna:5.13.0=pluginLibsCompile
+net.minidev:accessors-smart:2.5.0=pluginLibsCompile
+net.minidev:json-smart:2.5.0=pluginLibsCompile
+org.apache.httpcomponents:httpclient:4.5.14=pluginLibsCompile
+org.apache.httpcomponents:httpcore:4.4.16=pluginLibsCompile
+org.checkerframework:checker-qual:3.42.0=pluginLibsCompile
+org.codehaus.mojo:animal-sniffer-annotations:1.23=pluginLibsCompile
+org.conscrypt:conscrypt-openjdk-uber:2.5.2=pluginLibsCompile
+org.ow2.asm:asm:9.3=pluginLibsCompile
+org.reactivestreams:reactive-streams:1.0.4=pluginLibsCompile
+org.slf4j:slf4j-api:1.7.36=pluginLibsCompile
+org.threeten:threetenbp:1.6.8=pluginLibsCompile
+software.amazon.awssdk:annotations:2.26.31=pluginLibsCompile
+software.amazon.awssdk:apache-client:2.26.31=pluginLibsCompile
+software.amazon.awssdk:auth:2.26.31=pluginLibsCompile
+software.amazon.awssdk:aws-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:aws-json-protocol:2.26.31=pluginLibsCompile
+software.amazon.awssdk:checksums-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:checksums:2.26.31=pluginLibsCompile
+software.amazon.awssdk:endpoints-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth-aws-eventstream:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth-aws:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-auth:2.26.31=pluginLibsCompile
+software.amazon.awssdk:http-client-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:identity-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:json-utils:2.26.31=pluginLibsCompile
+software.amazon.awssdk:metrics-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:netty-nio-client:2.26.31=pluginLibsCompile
+software.amazon.awssdk:profiles:2.26.31=pluginLibsCompile
+software.amazon.awssdk:protocol-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:regions:2.26.31=pluginLibsCompile
+software.amazon.awssdk:retries-spi:2.26.31=pluginLibsCompile
+software.amazon.awssdk:retries:2.26.31=pluginLibsCompile
+software.amazon.awssdk:sdk-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:secretsmanager:2.26.31=pluginLibsCompile
+software.amazon.awssdk:third-party-jackson-core:2.26.31=pluginLibsCompile
+software.amazon.awssdk:url-connection-client:2.26.31=pluginLibsCompile
+software.amazon.awssdk:utils:2.26.31=pluginLibsCompile
+software.amazon.eventstream:eventstream:1.0.1=pluginLibsCompile
+empty=pluginLibsCompileOnly,pluginLibsRuntime
diff --git a/bitwarden-secrets-provider/ofbiz-component.xml b/secretshub/ofbiz-component.xml
similarity index 83%
rename from bitwarden-secrets-provider/ofbiz-component.xml
rename to secretshub/ofbiz-component.xml
index c5c3a8aec..88a5822f1 100644
--- a/bitwarden-secrets-provider/ofbiz-component.xml
+++ b/secretshub/ofbiz-component.xml
@@ -18,13 +18,14 @@ specific language governing permissions and limitations
under the License.
-->
-
-
+
diff --git a/aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java b/secretshub/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
similarity index 100%
rename from aws-secrets-provider/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
rename to secretshub/src/main/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProvider.java
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java b/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java
similarity index 100%
rename from azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java
rename to secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultReader.java
diff --git a/azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
similarity index 100%
rename from azure-keyvault-secrets-provider/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
rename to secretshub/src/main/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProvider.java
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
similarity index 100%
rename from bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
rename to secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenHttpClient.java
diff --git a/bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
similarity index 100%
rename from bitwarden-secrets-provider/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
rename to secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
diff --git a/env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java b/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
similarity index 100%
rename from env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
rename to secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarReader.java
diff --git a/env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java b/secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
similarity index 100%
rename from env-var-secrets-provider/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
rename to secretshub/src/main/java/org/apache/ofbiz/envvar/EnvVarSecretProvider.java
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
similarity index 100%
rename from gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
rename to secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProvider.java
diff --git a/gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java b/secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java
similarity index 100%
rename from gcp-secretmanager-secrets-provider/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java
rename to secretshub/src/main/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretReader.java
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java b/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java
similarity index 100%
rename from hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java
rename to secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultReader.java
diff --git a/hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
similarity index 100%
rename from hashicorp-vault-secrets-provider/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
rename to secretshub/src/main/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProvider.java
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
similarity index 100%
rename from onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
rename to secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordHttpClient.java
diff --git a/onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
similarity index 100%
rename from onepassword-secrets-provider/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
rename to secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
diff --git a/secretshub/src/main/java/org/apache/ofbiz/secretshub/ActiveSecretProvider.java b/secretshub/src/main/java/org/apache/ofbiz/secretshub/ActiveSecretProvider.java
new file mode 100644
index 000000000..0f6051efc
--- /dev/null
+++ b/secretshub/src/main/java/org/apache/ofbiz/secretshub/ActiveSecretProvider.java
@@ -0,0 +1,111 @@
+/*******************************************************************************
+ * 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.ofbiz.secretshub;
+
+import java.util.Locale;
+import java.util.Properties;
+
+import org.apache.ofbiz.awssecrets.AwsSecretsManagerProvider;
+import org.apache.ofbiz.azurekeyvault.AzureKeyVaultSecretsProvider;
+import org.apache.ofbiz.base.secret.SecretProvider;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.bitwarden.BitwardenSecretsProvider;
+import org.apache.ofbiz.envvar.EnvVarSecretProvider;
+import org.apache.ofbiz.gcpsecretmanager.GcpSecretManagerSecretsProvider;
+import org.apache.ofbiz.hashicorpvault.HashicorpVaultSecretsProvider;
+import org.apache.ofbiz.onepassword.OnePasswordSecretsProvider;
+
+/**
+ * {@link SecretProvider} that delegates to exactly one of the bundled providers, selected via
+ * {@code secret.provider.active} in {@code secretshub.properties}.
+ *
+ * This is the only class the {@code secretshub} component registers under
+ * {@code META-INF/services/org.apache.ofbiz.base.secret.SecretProvider}. Concatenating the 7
+ * bundled providers' own service registrations would make {@link java.util.ServiceLoader}
+ * discover and construct all of them, eagerly building SDK clients for providers that were
+ * never configured. Routing everything through this single delegating class keeps the
+ * "only one provider active at a time" contract that used to be enforced by each plugin's own
+ * {@code ofbiz-component.xml enabled} flag.
+ */
+public final class ActiveSecretProvider implements SecretProvider {
+
+ private static final String MODULE = ActiveSecretProvider.class.getName();
+
+ private static final String VALID_VALUES = "aws | azure | bitwarden | env-var | gcp | hashicorp-vault | onepassword";
+
+ private final SecretProvider delegate;
+
+ /** Required no-arg constructor for {@link java.util.ServiceLoader}. */
+ public ActiveSecretProvider() throws GeneralException {
+ Properties props = UtilProperties.getProperties("secretshub");
+ String active = props == null ? null : props.getProperty("secret.provider.active");
+ active = active == null ? "" : active.trim().toLowerCase(Locale.ROOT);
+ this.delegate = build(active);
+ Debug.logInfo("ActiveSecretProvider: secret.provider.active=" + active
+ + ", delegating to " + delegate.getClass().getName(), MODULE);
+ }
+
+ /** Package-private for direct unit testing of the selection/fail-fast logic. */
+ static SecretProvider build(String active) throws GeneralException {
+ switch (active) {
+ case "aws":
+ return new AwsSecretsManagerProvider();
+ case "azure":
+ return new AzureKeyVaultSecretsProvider();
+ case "bitwarden":
+ return new BitwardenSecretsProvider();
+ case "env-var":
+ return new EnvVarSecretProvider();
+ case "gcp":
+ return new GcpSecretManagerSecretsProvider();
+ case "hashicorp-vault":
+ return new HashicorpVaultSecretsProvider();
+ case "onepassword":
+ return new OnePasswordSecretsProvider();
+ case "":
+ throw new IllegalStateException("secret.provider.active is blank in secretshub.properties. "
+ + "Set it to one of: " + VALID_VALUES);
+ default:
+ throw new IllegalStateException("Unrecognized secret.provider.active value '" + active
+ + "' in secretshub.properties. Valid values: " + VALID_VALUES);
+ }
+ }
+
+ @Override
+ public String getSecret(String key) throws GeneralException {
+ return delegate.getSecret(key);
+ }
+
+ @Override
+ public boolean isFallbackEnabled() {
+ return delegate.isFallbackEnabled();
+ }
+
+ @Override
+ public void close() {
+ delegate.close();
+ }
+
+ @Override
+ public void invalidateCache() {
+ delegate.invalidateCache();
+ }
+}
diff --git a/secretshub/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider b/secretshub/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
new file mode 100644
index 000000000..361d1e7ca
--- /dev/null
+++ b/secretshub/src/main/resources/META-INF/services/org.apache.ofbiz.base.secret.SecretProvider
@@ -0,0 +1 @@
+org.apache.ofbiz.secretshub.ActiveSecretProvider
diff --git a/aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
similarity index 100%
rename from aws-secrets-provider/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
rename to secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
diff --git a/azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
similarity index 100%
rename from azure-keyvault-secrets-provider/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
rename to secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
diff --git a/bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
similarity index 100%
rename from bitwarden-secrets-provider/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
rename to secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
diff --git a/env-var-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
similarity index 100%
rename from env-var-secrets-provider/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
rename to secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
diff --git a/gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
similarity index 100%
rename from gcp-secretmanager-secrets-provider/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
rename to secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
diff --git a/hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
similarity index 100%
rename from hashicorp-vault-secrets-provider/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
rename to secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
diff --git a/onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
similarity index 100%
rename from onepassword-secrets-provider/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
rename to secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
diff --git a/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java
new file mode 100644
index 000000000..a24864f92
--- /dev/null
+++ b/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java
@@ -0,0 +1,61 @@
+/*******************************************************************************
+ * 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.ofbiz.secretshub;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.apache.ofbiz.envvar.EnvVarSecretProvider;
+import org.junit.Test;
+
+/**
+ * Tests for {@link ActiveSecretProvider}'s provider-selection/fail-fast logic.
+ *
+ * Only {@code env-var} is exercised for the "valid name" case: its no-arg constructor
+ * does no network I/O, unlike the other six bundled providers (which build a remote SDK
+ * client or make a real HTTP call), so it is the only one safe to construct in a plain
+ * unit test.
+ */
+public class ActiveSecretProviderTest {
+
+ @Test
+ public void buildThrowsWhenActiveIsBlank() throws Exception {
+ try {
+ ActiveSecretProvider.build("");
+ fail("expected IllegalStateException for blank secret.provider.active");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("blank"));
+ }
+ }
+
+ @Test
+ public void buildThrowsWhenActiveIsUnrecognized() throws Exception {
+ try {
+ ActiveSecretProvider.build("not-a-real-provider");
+ fail("expected IllegalStateException for unrecognized secret.provider.active");
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage().contains("not-a-real-provider"));
+ }
+ }
+
+ @Test
+ public void buildReturnsMatchingProviderForValidName() throws Exception {
+ assertTrue(ActiveSecretProvider.build("env-var") instanceof EnvVarSecretProvider);
+ }
+}
From 7fcd41401765e110d637670951942f2738bf9e38 Mon Sep 17 00:00:00 2001
From: Ashish Vijaywargiya
Date: Mon, 20 Jul 2026 21:14:11 +0530
Subject: [PATCH 18/18] Fix checkstyle/javadoc violations in secretshub
providers and migrate tests to JUnit Jupiter
Fixes SeparatorWrap/MultipleVariableDeclarations/StaticVariableName checkstyle errors, an unclosed
javadoc tag, and converts all 8 secretshub test classes from JUnit 4 to JUnit Jupiter.
---
secretshub/build.gradle | 12 +++++
.../bitwarden/BitwardenSecretsProvider.java | 9 ++--
.../OnePasswordSecretsProvider.java | 3 +-
.../AwsSecretsManagerProviderTest.java | 29 ++++++------
.../AzureKeyVaultSecretsProviderTest.java | 21 +++++----
.../BitwardenSecretsProviderTest.java | 44 ++++++++++---------
.../envvar/EnvVarSecretProviderTest.java | 17 +++----
.../GcpSecretManagerSecretsProviderTest.java | 17 +++----
.../HashicorpVaultSecretsProviderTest.java | 35 ++++++++-------
.../OnePasswordSecretsProviderTest.java | 19 ++++----
.../secretshub/ActiveSecretProviderTest.java | 6 +--
11 files changed, 122 insertions(+), 90 deletions(-)
diff --git a/secretshub/build.gradle b/secretshub/build.gradle
index 8aea0cb55..c7bdc1b6c 100644
--- a/secretshub/build.gradle
+++ b/secretshub/build.gradle
@@ -35,6 +35,18 @@ dependencies {
pluginLibsCompile 'com.azure:azure-security-keyvault-secrets:4.8.0'
pluginLibsCompile 'com.azure:azure-identity:1.12.0'
+ // Azure's HTTP client (azure-core-http-netty / reactor-netty) lists io.netty:netty-tcnative-boringssl-static
+ // as an OPTIONAL transitive dependency, so Gradle never downloads it and Netty falls back to the plain JDK
+ // SSLEngine for TLS. That fallback is functionally correct (confirmed against a real Azure Key Vault) — the
+ // only effect of its absence is losing OpenSSL-accelerated TLS throughput, which only matters at production
+ // scale, not for local development. Netty logs the missing-library fallback at DEBUG (see the io.netty /
+ // reactor / com.azure logger entries in framework/base/config/log4j2.xml); it is not an error.
+ // To opt in to native TLS acceleration, uncomment the line below. The version is already pinned by the
+ // existing dependency graph (see `./gradlew dependencies -p plugins/secretshub`), so no version guesswork is
+ // needed; it resolves to the same "uber" jar bundling native binaries for linux-x86_64, linux-aarch_64,
+ // osx-x86_64, osx-aarch_64, and windows-x86_64, so it stays portable across dev machines. Regenerate the
+ // lockfile after uncommenting (rm gradle.lockfile && ./gradlew :plugins:secretshub:dependencies --write-locks).
+ // pluginLibsRuntime 'io.netty:netty-tcnative-boringssl-static:2.0.65.Final'
pluginLibsCompile 'com.google.cloud:google-cloud-secretmanager:2.36.0'
diff --git a/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
index 4c2dbfdf3..9be3b607d 100644
--- a/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
+++ b/secretshub/src/main/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProvider.java
@@ -63,7 +63,7 @@
*
* This derived key decrypts the {@code encrypted_payload} field returned by the OAuth endpoint.
* The payload contains a JSON object {@code {"encryptionKey": ""}} whose value is the
- * 64-byte org symmetric key used for all subsequent secret decryption.
+ * 64-byte org symmetric key used for all subsequent secret decryption.
*
* End-to-end encryption
* Secret keys (names) and values are returned from the API in encrypted form.
@@ -439,7 +439,9 @@ private static byte[] decryptCipherBytes(String cipherString, byte[] encKey, byt
"Invalid Bitwarden cipher string — expected '2.||'");
}
- byte[] iv, ciphertext, expectedHmac;
+ byte[] iv;
+ byte[] ciphertext;
+ byte[] expectedHmac;
try {
iv = Base64.getDecoder().decode(parts[0]);
ciphertext = Base64.getDecoder().decode(parts[1]);
@@ -526,8 +528,9 @@ private static BitwardenHttpClient buildHttpClient() {
public void close() {
// HttpClient became AutoCloseable in Java 21; safe to ignore on earlier versions.
if (javaClient instanceof AutoCloseable) {
+ AutoCloseable closeable = (AutoCloseable) javaClient;
try {
- ((AutoCloseable) javaClient).close();
+ closeable.close();
} catch (Exception e) {
// Nothing meaningful to do during shutdown.
}
diff --git a/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
index 1b1796b40..073b71352 100644
--- a/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
+++ b/secretshub/src/main/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProvider.java
@@ -271,8 +271,9 @@ public String get(String url, String bearerToken) throws IOException {
@Override
public void close() {
if (javaClient instanceof AutoCloseable) {
+ AutoCloseable closeable = (AutoCloseable) javaClient;
try {
- ((AutoCloseable) javaClient).close();
+ closeable.close();
} catch (Exception ignored) { }
}
}
diff --git a/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
index d3604d1ea..0e88ab64f 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/awssecrets/AwsSecretsManagerProviderTest.java
@@ -18,7 +18,8 @@
*******************************************************************************/
package org.apache.ofbiz.awssecrets;
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
@@ -28,7 +29,7 @@
import java.util.Map;
import org.apache.ofbiz.base.util.GeneralException;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
@@ -161,34 +162,34 @@ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws General
// -- Error handling --
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenSecretNotFound() throws GeneralException {
+ @Test
+ public void getSecretThrowsWhenSecretNotFound() {
SecretsManagerClient client = mock(SecretsManagerClient.class);
when(client.getSecretValue(any(GetSecretValueRequest.class)))
.thenThrow(ResourceNotFoundException.builder().message("not found").build());
- new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "")
- .getSecret("jdbc-password.missing");
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "");
+ assertThrows(GeneralException.class, () -> provider.getSecret("jdbc-password.missing"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenJsonFieldMissing() throws GeneralException {
+ @Test
+ public void getSecretThrowsWhenJsonFieldMissing() {
String json = "{\"username\":\"dbuser\"}"; // no "password" field
SecretsManagerClient client = clientReturning("jdbc-password.mydb", json);
- new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "password")
- .getSecret("jdbc-password.mydb");
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "password");
+ assertThrows(GeneralException.class, () -> provider.getSecret("jdbc-password.mydb"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenSecretStringIsNull() throws GeneralException {
+ @Test
+ public void getSecretThrowsWhenSecretStringIsNull() {
SecretsManagerClient client = mock(SecretsManagerClient.class);
// secretString() returns null — this is a binary secret
when(client.getSecretValue(any(GetSecretValueRequest.class)))
.thenReturn(GetSecretValueResponse.builder().build());
- new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "")
- .getSecret("binary-secret");
+ AwsSecretsManagerProvider provider = new AwsSecretsManagerProvider(client, ONE_HOUR_MS, "", "");
+ assertThrows(GeneralException.class, () -> provider.getSecret("binary-secret"));
}
// -- helpers --
diff --git a/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
index 8386565c3..01b0cf2da 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/azurekeyvault/AzureKeyVaultSecretsProviderTest.java
@@ -18,13 +18,14 @@
*******************************************************************************/
package org.apache.ofbiz.azurekeyvault;
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.ofbiz.base.util.GeneralException;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
public class AzureKeyVaultSecretsProviderTest {
@@ -127,16 +128,18 @@ public void getSecretFallsBackToDotReplacementWhenNoAliasConfigured() throws Gen
// -- Error handling --
- @Test(expected = GeneralException.class)
- public void getSecretThrowsOnReaderException() throws GeneralException {
- AzureKeyVaultReader reader = secretName -> { throw new RuntimeException("SecretNotFound"); };
- provider(reader, "", "-").getSecret("missing");
+ @Test
+ public void getSecretThrowsOnReaderException() {
+ AzureKeyVaultReader reader = secretName -> {
+ throw new RuntimeException("SecretNotFound");
+ };
+ assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("missing"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsOnEmptyValue() throws GeneralException {
+ @Test
+ public void getSecretThrowsOnEmptyValue() {
AzureKeyVaultReader reader = secretName -> "";
- provider(reader, "", "-").getSecret("mykey");
+ assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("mykey"));
}
// -- helpers --
diff --git a/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
index c670b21fd..0dd2774f6 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/bitwarden/BitwardenSecretsProviderTest.java
@@ -18,7 +18,8 @@
*******************************************************************************/
package org.apache.ofbiz.bitwarden;
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
@@ -40,8 +41,8 @@
import javax.crypto.spec.SecretKeySpec;
import org.apache.ofbiz.base.util.GeneralException;
-import org.junit.BeforeClass;
-import org.junit.Test;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
/**
* Tests for {@link BitwardenSecretsProvider}.
@@ -59,15 +60,15 @@ public class BitwardenSecretsProviderTest {
private static final String BEARER_TOKEN_RESPONSE =
"{\"access_token\":\"test-bearer\",\"expires_in\":3600}";
- private static byte[] TEST_ENC_KEY;
- private static byte[] TEST_MAC_KEY;
+ private static byte[] testEncKey;
+ private static byte[] testMacKey;
- @BeforeClass
+ @BeforeAll
public static void generateTestKey() {
byte[] keyBytes = new byte[64];
new SecureRandom().nextBytes(keyBytes);
- TEST_ENC_KEY = Arrays.copyOfRange(keyBytes, 0, 32);
- TEST_MAC_KEY = Arrays.copyOfRange(keyBytes, 32, 64);
+ testEncKey = Arrays.copyOfRange(keyBytes, 0, 32);
+ testMacKey = Arrays.copyOfRange(keyBytes, 32, 64);
}
// -- Decryption unit tests (no HTTP, pure crypto) --
@@ -81,13 +82,14 @@ public void decryptRoundTrip() throws Exception {
assertEquals(plaintext, provider.decrypt(cipherString));
}
- @Test(expected = GeneralException.class)
+ @Test
public void decryptThrowsOnWrongCipherType() throws Exception {
BitwardenSecretsProvider provider = provider(mock(BitwardenHttpClient.class));
- provider.decrypt("1.abc|def|ghi"); // type 1, not 2
+ // type 1, not 2
+ assertThrows(GeneralException.class, () -> provider.decrypt("1.abc|def|ghi"));
}
- @Test(expected = GeneralException.class)
+ @Test
public void decryptThrowsOnTamperedCiphertext() throws Exception {
String cipherString = encrypt("secret");
// Flip a byte in the ciphertext part
@@ -97,7 +99,7 @@ public void decryptThrowsOnTamperedCiphertext() throws Exception {
String tampered = "2." + parts[0] + "|" + Base64.getEncoder().encodeToString(ct) + "|" + parts[2];
BitwardenSecretsProvider provider = provider(mock(BitwardenHttpClient.class));
- provider.decrypt(tampered);
+ assertThrows(GeneralException.class, () -> provider.decrypt(tampered));
}
// -- Full flow tests (with mock HTTP) --
@@ -170,22 +172,24 @@ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws Excepti
assertEquals(secretValue, provider.getSecret("jdbc-password.mysql-ofbiz"));
}
- @Test(expected = GeneralException.class)
+ @Test
public void getSecretThrowsWhenSecretNotFound() throws Exception {
BitwardenHttpClient client = mock(BitwardenHttpClient.class);
when(client.post(anyString(), anyString())).thenReturn(BEARER_TOKEN_RESPONSE);
when(client.get(contains("/organizations/"), anyString()))
.thenReturn("{\"secrets\":[]}"); // empty list
- provider(client).getSecret("missing");
+ BitwardenSecretsProvider provider = provider(client);
+ assertThrows(GeneralException.class, () -> provider.getSecret("missing"));
}
- @Test(expected = GeneralException.class)
+ @Test
public void getSecretThrowsOnHttpError() throws Exception {
BitwardenHttpClient client = mock(BitwardenHttpClient.class);
when(client.post(anyString(), anyString())).thenThrow(new IOException("connection refused"));
- provider(client).getSecret("mykey");
+ BitwardenSecretsProvider provider = provider(client);
+ assertThrows(GeneralException.class, () -> provider.getSecret("mykey"));
}
// -- helpers --
@@ -198,14 +202,14 @@ private BitwardenSecretsProvider providerWithPrefix(BitwardenHttpClient client,
throws GeneralException {
return new BitwardenSecretsProvider(client, API_URL, IDENTITY_URL,
ORG_ID, prefix, ONE_HOUR_MS,
- "service-account.test-id", "test-secret", TEST_ENC_KEY, TEST_MAC_KEY);
+ "service-account.test-id", "test-secret", testEncKey, testMacKey);
}
private BitwardenSecretsProvider providerWithAliases(BitwardenHttpClient client, String prefix,
Map keyAliases) throws GeneralException {
return new BitwardenSecretsProvider(client, API_URL, IDENTITY_URL,
ORG_ID, prefix, ONE_HOUR_MS,
- "service-account.test-id", "test-secret", TEST_ENC_KEY, TEST_MAC_KEY, keyAliases);
+ "service-account.test-id", "test-secret", testEncKey, testMacKey, keyAliases);
}
/**
@@ -234,11 +238,11 @@ private String encrypt(String plaintext) throws Exception {
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
- cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(TEST_ENC_KEY, "AES"), new IvParameterSpec(iv));
+ cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(testEncKey, "AES"), new IvParameterSpec(iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
- mac.init(new SecretKeySpec(TEST_MAC_KEY, "HmacSHA256"));
+ mac.init(new SecretKeySpec(testMacKey, "HmacSHA256"));
mac.update(iv);
byte[] hmac = mac.doFinal(ciphertext);
diff --git a/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
index 5d2deafb0..7e01d4e0c 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/envvar/EnvVarSecretProviderTest.java
@@ -18,13 +18,14 @@
*******************************************************************************/
package org.apache.ofbiz.envvar;
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.HashMap;
import java.util.Map;
import org.apache.ofbiz.base.util.GeneralException;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
/**
* Tests for {@link EnvVarSecretProvider}.
@@ -111,16 +112,16 @@ public void getSecretFallsBackToFixedTransformWhenNoAliasConfigured() throws Exc
assertEquals("dbpass", provider.getSecret("jdbc-password.mysql-ofbiz"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenEnvVarNotSet() throws Exception {
- provider(new HashMap<>()).getSecret("jdbc-password.mysql-ofbiz");
+ @Test
+ public void getSecretThrowsWhenEnvVarNotSet() {
+ assertThrows(GeneralException.class, () -> provider(new HashMap<>()).getSecret("jdbc-password.mysql-ofbiz"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenEnvVarEmpty() throws Exception {
+ @Test
+ public void getSecretThrowsWhenEnvVarEmpty() {
Map env = new HashMap<>();
env.put("OFBIZ_JDBC_PASSWORD_MYSQL_OFBIZ", "");
- provider(env).getSecret("jdbc-password.mysql-ofbiz");
+ assertThrows(GeneralException.class, () -> provider(env).getSecret("jdbc-password.mysql-ofbiz"));
}
}
diff --git a/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
index 86eaec9e9..c6ebcb1ce 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/gcpsecretmanager/GcpSecretManagerSecretsProviderTest.java
@@ -18,13 +18,14 @@
*******************************************************************************/
package org.apache.ofbiz.gcpsecretmanager;
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.ofbiz.base.util.GeneralException;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
public class GcpSecretManagerSecretsProviderTest {
@@ -146,18 +147,18 @@ public void getSecretFallsBackToDotReplacementWhenNoAliasConfigured() throws Gen
// -- Error handling --
- @Test(expected = GeneralException.class)
- public void getSecretThrowsOnReaderException() throws GeneralException {
+ @Test
+ public void getSecretThrowsOnReaderException() {
GcpSecretReader reader = resourceName -> {
throw new Exception("NOT_FOUND");
};
- provider(reader, "", "-").getSecret("missing");
+ assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("missing"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsOnEmptyValue() throws GeneralException {
+ @Test
+ public void getSecretThrowsOnEmptyValue() {
GcpSecretReader reader = resourceName -> "";
- provider(reader, "", "-").getSecret("mykey");
+ assertThrows(GeneralException.class, () -> provider(reader, "", "-").getSecret("mykey"));
}
// -- helpers --
diff --git a/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
index e6a4d899c..049ef17d7 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/hashicorpvault/HashicorpVaultSecretsProviderTest.java
@@ -18,7 +18,8 @@
*******************************************************************************/
package org.apache.ofbiz.hashicorpvault;
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Collections;
import java.util.HashMap;
@@ -28,7 +29,7 @@
import io.github.jopenlibs.vault.VaultException;
import org.apache.ofbiz.base.util.GeneralException;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
public class HashicorpVaultSecretsProviderTest {
@@ -131,28 +132,32 @@ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws General
// -- Error handling --
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenDataIsEmpty() throws GeneralException {
+ @Test
+ public void getSecretThrowsWhenDataIsEmpty() {
HashicorpVaultReader reader = path -> Collections.emptyMap();
- provider(reader, "", "").getSecret("missing");
+ assertThrows(GeneralException.class, () -> provider(reader, "", "").getSecret("missing"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenFieldMissing() throws GeneralException {
+ @Test
+ public void getSecretThrowsWhenFieldMissing() {
HashicorpVaultReader reader = fixedReader("secret/mykey", singleEntry("username", "dbuser"));
- provider(reader, "", "password").getSecret("mykey"); // "password" field not present
+ // "password" field not present
+ assertThrows(GeneralException.class, () -> provider(reader, "", "password").getSecret("mykey"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsWhenMultiFieldAndNoFieldConfigured() throws GeneralException {
+ @Test
+ public void getSecretThrowsWhenMultiFieldAndNoFieldConfigured() {
HashicorpVaultReader reader = fixedReader("secret/mykey", twoEntry("username", "u", "password", "p"));
- provider(reader, "", "").getSecret("mykey"); // ambiguous — 2 fields, no field config
+ // ambiguous — 2 fields, no field config
+ assertThrows(GeneralException.class, () -> provider(reader, "", "").getSecret("mykey"));
}
- @Test(expected = GeneralException.class)
- public void getSecretThrowsOnVaultException() throws GeneralException {
- HashicorpVaultReader reader = path -> { throw new VaultException("connection refused", 503); };
- provider(reader, "", "").getSecret("mykey");
+ @Test
+ public void getSecretThrowsOnVaultException() {
+ HashicorpVaultReader reader = path -> {
+ throw new VaultException("connection refused", 503);
+ };
+ assertThrows(GeneralException.class, () -> provider(reader, "", "").getSecret("mykey"));
}
// -- helpers --
diff --git a/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
index 577c16b56..a741f0e11 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/onepassword/OnePasswordSecretsProviderTest.java
@@ -18,8 +18,9 @@
*******************************************************************************/
package org.apache.ofbiz.onepassword;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
@@ -34,7 +35,7 @@
import java.util.Map;
import org.apache.ofbiz.base.util.GeneralException;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
public class OnePasswordSecretsProviderTest {
@@ -123,27 +124,27 @@ public void getSecretFallsBackToLogicalKeyWhenNoAliasConfigured() throws Excepti
// -- Error handling --
- @Test(expected = GeneralException.class)
+ @Test
public void getSecretThrowsWhenItemNotFound() throws Exception {
OnePasswordHttpClient client = mock(OnePasswordHttpClient.class);
when(client.get(anyString(), anyString())).thenReturn("[]");
- provider(client, "password", "").getSecret("missing");
+ assertThrows(GeneralException.class, () -> provider(client, "password", "").getSecret("missing"));
}
- @Test(expected = GeneralException.class)
+ @Test
public void getSecretThrowsWhenFieldNotPresent() throws Exception {
// Item exists but has no "password" field — only "username"
OnePasswordHttpClient client = buildMockClient("mykey", ITEM_ID, "username", "dbuser", "password");
- provider(client, "password", "").getSecret("mykey");
+ assertThrows(GeneralException.class, () -> provider(client, "password", "").getSecret("mykey"));
}
- @Test(expected = GeneralException.class)
+ @Test
public void getSecretThrowsOnHttpError() throws Exception {
OnePasswordHttpClient client = mock(OnePasswordHttpClient.class);
when(client.get(anyString(), anyString())).thenThrow(new IOException("connection refused"));
- provider(client, "password", "").getSecret("mykey");
+ assertThrows(GeneralException.class, () -> provider(client, "password", "").getSecret("mykey"));
}
// -- helpers --
diff --git a/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java b/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java
index a24864f92..2d53178df 100644
--- a/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java
+++ b/secretshub/src/test/java/org/apache/ofbiz/secretshub/ActiveSecretProviderTest.java
@@ -18,11 +18,11 @@
*******************************************************************************/
package org.apache.ofbiz.secretshub;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
import org.apache.ofbiz.envvar.EnvVarSecretProvider;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
/**
* Tests for {@link ActiveSecretProvider}'s provider-selection/fail-fast logic.